7 Commits
Author SHA1 Message Date
adrianbonpin 62e3b96bad chore(release): v0.8.3 version bump + changelog (#46)
Release / linux (push) Successful in 59m20s
Release / windows (push) Canceled after 0s
* chore(release): bump 0.8.2 -> 0.8.3

Version bumped across desktop/package.json, Cargo.toml (+ only the `gridline`
entry in Cargo.lock — the sqlx family is untouched), tauri.conf.json, both
landing-page `const version` strings, the README's two download tables and
'current release' banners, the MAINTENANCE comment, and the version tests.

Adds the 0.8.3 changelog entry and README release note covering the external
client-tool resolution fix (issue #44), the MariaDB client-option fix, the four
restyled tool pages, and the app-icon revert.

Historical references (older changelog entries, '(v0.8.2)' feature notes) are
deliberately left alone.

* test(ui): scope CopyButton's unhandled-rejection capture to its own error

The listener is process-global and Vitest runs several test files per worker, so
an unrelated file's rejection could be recorded and fail this test. Filter the
capture to the error this test creates, and stop the listener in a finally so a
failing assertion cannot leak it into later tests.

Verified non-vacuous: against a CopyButton without the try/catch both 'stays
quiet' tests still fail with the expected assertion.
2026-09-30 20:29:13 +08:00
adrianbonpin daa38d163e fix(tools): resolve external MySQL/PG client tools correctly (#44)
Addresses #44 (closed after the v0.8.3 release builds are green).

Three independent defects in one shared tool-resolution path: bundled tools were
looked up at the wrong path (so bundled fallback never worked, for PostgreSQL
too), macOS GUI apps inherit launchd's minimal PATH, and only mariadb-dump/mariadb
were probed — never mysqldump/mysql, which is what brew install mysql-client
installs. Both engines now resolve through one headless, cached, timed resolver,
plus a non-blocking client/server version warning.

Also fixes passing MySQL-only CLI options to the bundled MariaDB client, and
carries the tool-page UI polish and the requested #40 icon revert.
2026-09-30 20:04:16 +08:00
adrianbonpin 092b3b2831 chore(release): v0.8.2 (#43)
Release / linux (push) Successful in 39m3s
Release / windows (push) Canceled after 0s
Version bump 0.8.1 -> 0.8.2 across the app shell, the website and the README, plus v0.8.2 changelog entries for the native MySQL execution work merged in #42.

- desktop/package.json, desktop/src-tauri/Cargo.toml, desktop/src-tauri/tauri.conf.json, Cargo.lock (cargo-regenerated; gridline only - webview2-com-macros stays 0.8.1)
- www/public/landing.js + www/src/pages/index.astro const version; new top entry in www/src/data/changelog.ts
- version.test.ts, bundle-config.test.ts, docs-coverage.test.ts updated to 0.8.2
- README: both banners, both download tables (asset names + tags, incl. apt/dnf strings), MAINTENANCE comment, new v0.8.2 bullet

Verified: cargo build (gridline v0.8.2) + cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests exit 0; tsc clean.
2026-09-29 08:57:32 +08:00
adrianbonpin 88e7e45818 feat(mysql): native SQL execution — fix #41 type decoding, in-process restore, .sql open-with (#42)
Fixes #41.

MySQL columns now decode by their declared type instead of a blind try_get chain,
so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP)
renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver
(no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle
is implemented. .sql files can be opened with Gridline from the OS file manager.

Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests
exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
2026-09-29 08:52:47 +08:00
adrianbonpin ce4a73b0bb fix(icons): stop macOS drawing the app icon on a legacy plate (#40)
On macOS 26+ the Dock and any launcher that resolves app icons through
NSWorkspace.icon(forFile:) (Spotlight, Vicinae, Raycast, ...) drew a grey
rounded-square "legacy plate" behind Gridline's icon, i.e. a white border
around the app icon at every render size below ~72px.

Reproduced outside any launcher: NSWorkspace.icon(forFile:) on
Gridline.app returns the plated icon at 16-64px and the clean icon at
>=72px. The trigger lives in icon.icns, not in the app -- moving that file
into Zed.app reproduces the border, moving Zed's icns into Gridline.app
removes it.

Two things in the file cause it:

  - ic12 (64x64)                     -> plates the 40-64px range
  - is32/s8mk (16x16 RGB+mask)
    il32/l8mk (32x32 RGB+mask)       -> plates the 16-32px range

macOS treats padded small representations as legacy, and this icns carries
them in both places.

Fix, in two parts.

1. Strip those chunks. scripts/fix-icns.mjs does it; `tauri icon` re-emits
   them on every run, so it has to be a committed, repeatable step.
   scripts/verify-app-icon.mjs is the regression check: it builds a
   throwaway .app around the committed icon.icns, asks AppKit to render it
   at 16..512px and fails if any size comes back plated. It fails (6/11
   plated) on the previous icns and passes on this one.

2. Regenerate the raster set from icon.svg via
   scripts/regenerate-icons.sh, so the icons directory matches its source
   of truth again. The SVG has been full-bleed since the initial commit
   (md5 unchanged at d872e42/3dab09f/6854d88/HEAD) while the rasters were
   replaced with a hand-tuned padded version at v0.2.0 and had drifted
   from it ever since. The script generates into a temp dir because
   `tauri icon` only takes one output directory and would otherwise drop
   generated android/ and ios/ trees into the desktop-only icons dir.

No visual change on macOS: IconServices normalizes app-icon artwork when
rendering, so padded and full-bleed sets come out pixel-identical there
(same bbox and art fraction at 48/128/512px).

Windows and Linux do consume the PNG/ICO directly, so the icon there does
change from padded to full-bleed -- consistent with the SVG, but a
visible difference worth eyeballing before release.

Verified: verify-app-icon passes at 16, 24, 32, 40, 48, 64, 72, 96, 128,
256, 512px; iconutil and `file` still decode the new .icns and .ico
(6 sizes); frontend tests pass (131 files / 1134 tests).
2026-09-28 18:03:22 +08:00
adrianbonpin 8b08ad0e46 chore(release): point the macOS fallback script and docs at GitHub (#39)
`scripts/release-mac.sh` still targeted the retired Gitea release flow: it
looked for `.gitea/workflows/release.yml`, keyed off GITEA_SERVER/REPO_OWNER,
and created/uploaded assets with `tea`. Releases are GitHub-only now, so the
script uploads with `gh release upload --clobber` (a locally signed DMG pair
supersedes the CI-built ones, which the next draft publish then ships).

Also corrects the release docs, which contradicted each other: AGENTS.md said
macOS is built manually and never in CI, while README said CI signs it. In
reality the `release.yml` macOS jobs sign + notarize when the APPLE_* secrets
are present and log a `notice` when they are not — so the checklist now says to
read the run output before publishing a draft (verified on v0.8.1: stapler
validate + spctl report `source=Notarized Developer ID`), lists the local
`scripts/release-mac.sh` as the fallback, and documents the cask bump that the
`release: published` event triggers.

The "keep versions in sync" checklist was also incomplete and had already been
the source of drift: it missed `desktop/src-tauri/Cargo.lock` (only the
`gridline` entry — the sqlx family shares 0.8.x), `www/public/landing.js`, the
site changelog, `bundle-config.test.ts`, the two README banners and the
MAINTENANCE comment.
2026-09-24 04:14:11 +08:00
adrianbonpin b17bf95b6d chore(mirror): keep the Gitea fallback current from local git (#38)
GitHub is the source of truth; Gitea (origin) is a just-in-case copy that had
drifted two commits behind. Rather than require a credential on GitHub's side
or convert the repo to a read-only Gitea mirror, wire it into the local
workflow:

- scripts/mirror-gitea.sh — pushes prod + all tags to the Gitea remote
  (--branches for every branch). Refuses to push to a GitHub remote, prints
  what it did, exits non-zero when the mirror could not be updated.
- scripts/hooks/pre-push — git has no post-push hook, so the mirror runs before
  a push that includes prod/main or a tag; skips other remotes, deletions and
  its own recursion.
- scripts/hooks/post-merge — refreshes the mirror after `git pull`/merge on
  prod, which is how a PR squash-merged on GitHub reaches the mirror.
- scripts/install-git-hooks.sh — one-time per clone; sets
  core.hooksPath=scripts/hooks so the hooks stay versioned with the repo.

Mirror failures are warnings: a Gitea outage never blocks a push to GitHub.
AGENTS.md documents the setup plus the GitHub-only release flow.

Verified: script + hooks exercised for the prod/tag case, the feature-branch
skip, the Gitea-remote skip and the recursion guard; the mirror is idempotent
(no-op when already in sync).
2026-09-24 03:56:27 +08:00
63 changed files with 5149 additions and 1050 deletions
+20 -9
View File
@@ -104,7 +104,7 @@ gridline/
├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml ├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml
├── docs/ # Project docs ├── docs/ # Project docs
├── screenshots/ # README marketing images ├── screenshots/ # README marketing images
├── scripts/ # Release tooling (render-cask.sh) ├── scripts/ # Release tooling (render-cask.sh, release-mac.sh) + Gitea mirror (mirror-gitea.sh, hooks/, install-git-hooks.sh)
└── AGENTS.md # This file └── AGENTS.md # This file
``` ```
@@ -177,16 +177,25 @@ cargo test # Rust tests (from desktop/sr
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate. Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
**The Gitea fallback keeps itself current — run `scripts/install-git-hooks.sh` once per clone.** It sets `core.hooksPath=scripts/hooks`, so:
- `pre-push` mirrors `prod` + all tags to `origin` (Gitea) whenever you push to the `github` remote,
- `post-merge` mirrors again after a `git pull`/merge while on `prod` (a PR squash-merged on GitHub only reaches the mirror when the local `prod` pulls it).
`scripts/mirror-gitea.sh` does the same by hand (`--branches` also pushes feature branches); it refuses to push to a GitHub remote and exits non-zero if the mirror could not be updated. The hooks downgrade that to a warning — **a Gitea outage never blocks a push to GitHub**.
### Releases ### Releases
**Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). Never push release tags to `origin`/Gitea; it exists only as a fallback copy of the code. **Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). The Gitea tag arrives automatically via the `pre-push` hook (or `scripts/mirror-gitea.sh`) — never `git push origin vN.M.N` expecting a build, and never publish a release on Gitea.
**Before tagging**, keep everything in sync: **Before tagging**, keep everything in sync:
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string) - Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml` (+ the `gridline` entry in `desktop/src-tauri/Cargo.lock` — run any `cargo metadata`/`cargo build` to sync it; do NOT blanket-replace `0.8.x` in the lock, the sqlx family shares that version), `desktop/src-tauri/tauri.conf.json`
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version - Landing page version strings: `www/src/pages/index.astro` and `www/public/landing.js` (both carry a `const version`), plus the new entry at the top of `www/src/data/changelog.ts`
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md). - Tests that assert the version: `desktop/src/lib/version.test.ts`, `desktop/src/lib/bundle-config.test.ts`, `desktop/src/lib/docs-coverage.test.ts` (this one also asserts the README's asset names and the landing `const version`)
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.8.3/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables, the two "current release (**vX.Y.Z**)" banners, and the MAINTENANCE comment on every release.
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. - **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
- **macOS is Developer-ID signed + notarized** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section). - **macOS is Developer-ID signed + notarized in CI** from the repository secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_CONTENT`, `KEYCHAIN_PASSWORD`) — the workflow logs a `notice` when they are absent and falls back to ad-hoc, so **check the run output before publishing a draft**. Verify a published DMG with `xcrun stapler validate <dmg>` plus `spctl --assess --type execute` on the `.app` inside (`source=Notarized Developer ID`). `scripts/release-mac.sh` is the **local fallback**: it auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env`, signs + notarizes + staples both architectures, verifies the result, then uploads to the **GitHub** release with `gh release upload --clobber`.
- **After publishing the draft**, the `release: published` event runs the cask bump (`HOMEBREW_TAP_DEPLOY_KEY` pushes `Casks/gridline.rb` to `AdrianBonpin/homebrew-gridline`). Verify the cask `version` + `sha256` match the published DMGs.
### Adding a Tauri Command ### Adding a Tauri Command
@@ -245,7 +254,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable | | Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer | | DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite | | DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly | | DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly. **All MySQL column types decode by their declared type** (`mysql_cell_kind` → `mysql_cell_to_json` in `commands/query.rs`, v0.8.2) — `int`/`bigint` (64-bit stringified so the value survives IPC), `decimal` (exact digits, never a float), `date`/`time`/`datetime`/`timestamp` (naive, never an instant — MySQL converts TIMESTAMP via the session time zone), `float`/`double`, `bit`, `year`, `json`, text and binary — where the previous blind `try_get` try-chain rendered every non-string type as NULL (issue #41: 21 of 30 types) |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) | | DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret | | Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) | | Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
@@ -375,9 +384,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive | | Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) | | Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution (v0.7.5). **Both engines now resolve through one headless `db/tool_resolver.rs`** (v0.8.3): trusted absolute dirs (Homebrew, MacPorts, Postgres.app, `/usr/local`) → inherited `PATH` → bundled, with the bundled tools read from the nested `resources/pg_tools` / `resources/mysql_tools` path Tauri actually produces (the pre-0.8.3 join dropped the `resources/` component, so bundled fallback never worked). Candidates must be regular files that run `--version` (1 s per probe, 3 s aggregate). `resources/mysql_tools` (mariadb-dump / mariadb) is required for MySQL **backup** and **DB Sync** only; MySQL restore is native since v0.8.2 |
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) | | SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) | | Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL **backup** via the resolved dump client — system `mariadb-dump`, then system `mysqldump` (this is what Homebrew `mysql-client` installs), then the bundled `mariadb-dump`; DB-to-DB sync extended beyond PostgreSQL (v0.7.8). A **non-blocking** client/server version warning is emitted at job start when the resolved dump client's major version is below the server's (MariaDB's legacy `5.5.5-<real>-MariaDB` prefix is unwrapped). **MySQL restore runs in-process over the sqlx driver** (v0.8.2) — `run_mysql_restore_on` splits the script (`DELIMITER`-aware) and executes it on one connection, **no `mariadb` client and no subprocess**. MySQL backup and DB Sync still shell out to the resolved dump client |
| MySQL `clean` restore semantics | ✅ | `Clean` drops **only the objects the dump recreates** (`CREATE TABLE`/`VIEW`/`PROCEDURE`/`FUNCTION`/`TRIGGER`/`EVENT`), matching `pg_restore --clean` — an unrelated table in the target survives (`db/mysql_clean.rs`, v0.8.2). Deliberately not `DROP DATABASE`. The drops run with `FOREIGN_KEY_CHECKS = 0` (restored right after) so ordering cannot fail the restore. **MySQL DDL auto-commits and cannot be rolled back**, so a failure stops at the failing statement (reported with its position) and the Restore form warns that a failed restore "may leave this database partially applied" |
| `.sql` file association (open with Gridline) | ✅ | `bundle.fileAssociations` in `tauri.conf.json` registers `.sql` as **SQL Script** (`role: Editor`, **`rank: Alternate`** — Gridline never hijacks the default handler, it only appears under *Open With*, v0.8.2). The OS path is validated (absolute, `.sql` extension, real file, ≤5 MB — `MAX_SQL_FILE_BYTES`, symlinks not followed) and queued in Rust, then drained by the **argument-free** `take_pending_sql_files` command, so the webview can never name a file to read. The text lands in a query tab named after the file and is **never executed** — Run still goes through the destructive-query guard. Re-opening the same file focuses the existing tab without discarding edits. With no connection open the file is stashed and Home shows a banner prompting for one (macOS `RunEvent::Opened`; Windows/Linux argv + single-instance forward) |
| Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) | | Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) |
| Table structure export (DDL) | ❌ | | | Table structure export (DDL) | ❌ | |
+20 -16
View File
@@ -30,16 +30,16 @@
## Download ## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.1**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.3**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) | | **macOS** | Intel | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) |
| **Windows** | x64 | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users): **macOS via Homebrew** (recommended for Mac users):
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.1 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.3
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -102,6 +102,10 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>View the full changelog</summary> <summary>View the full changelog</summary>
- **v0.8.3** — **Your installed database tools are found again** ([issue #44](https://github.com/AdrianBonpin/gridline/issues/44)): Gridline reported *"mysqldump not found"* even when the tools were installed, for three separate reasons — the bundled binaries were looked up one directory too high (`<resource_dir>/mysql_tools` instead of the `<resource_dir>/resources/mysql_tools` the installer actually bundles, which silently disabled the bundled fallback for PostgreSQL too), a macOS GUI app inherits launchd's minimal `PATH` rather than your shell's, and only `mariadb-dump`/`mariadb` were ever probed — never `mysqldump`/`mysql`, which is what `brew install mysql-client` installs. Both engines now resolve through one ordered chain (trusted absolute directories → your `PATH` → bundled), a candidate counts only if it is a real file that runs `--version`, and an explicit **Check again** re-detects on demand. **The bundled MySQL client now actually runs**: backup and sync were passing MySQL-only options (`--databases=<db>`, `--skip-column-statistics`, `--ssl-mode=…`) to the bundled MariaDB binary, so the job died at option parsing before connecting; options are now tailored per client. **Backup, Restore, DB Sync and Schema Diff say which tool they use and where it came from** (`Using system mysqldump`, `Using bundled mariadb-dump`), explain a bundled copy that is present but cannot run, offer one-click copy of the install command, warn non-blockingly when the dump client's major version is older than the server's, and share the Objects view's form layout. **The app icon is back to its previous rendering** — the macOS "legacy plate" strip from #40 was reverted at the maintainer's request.
- **v0.8.2** — **MySQL values actually show up**: every non-string MySQL column — ids and other integers, `DECIMAL`, `BIT`, `YEAR`, `DATE`/`TIME`/`DATETIME`/`TIMESTAMP`, `FLOAT`/`DOUBLE` — was rendering as `NULL`, so primary-key columns looked empty; all 30 column types now decode by their declared type, with 64-bit integers and `DECIMAL` carried as strings so no digits are lost and `DATETIME(6)` keeping its microseconds. **MySQL restore works against MySQL 8** — it no longer shells out to a bundled MariaDB client (which cannot authenticate with `caching_sha2_password`), running in-process over the same driver browsing uses, stopping at the first error with the failing statement's position, cancellable, and `DELIMITER`-aware so dumps with triggers and stored routines apply. **The `Clean` toggle is now real**: it drops only the objects the dump recreates, so an unrelated table in the target survives — and since MySQL DDL auto-commits, the Restore form warns a failed restore cannot be rolled back. **`.sql` files open with Gridline**: right-click → Open With loads the script into a query tab named after the file; it never runs on open (Run still confirms anything destructive), and with no connection open the file is held with a prompt until you pick one.
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump. - **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump. - **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
@@ -340,22 +344,22 @@ Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Action
When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization. When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
`scripts/release-mac.sh` is the **local fallback** path (used before GitHub Actions took over macOS builds, and still handy for a signed build without burning CI minutes). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only). `scripts/release-mac.sh` is the **local fallback** path (handy for a signed build without burning CI minutes, or when Actions is unavailable). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, verifies the result, then uploads the DMGs to the **GitHub** release for the tag (`gh release upload --clobber`, so a locally signed pair supersedes the CI-built ones — publish the draft afterwards). If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning. **Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.1**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.3**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.1_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.3_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.1-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.3-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
+1 -1
View File
@@ -134,7 +134,7 @@ Still deferred from 0.7.0, slated for this bucket:
## ✅ Shipped (0.7.8) ## ✅ Shipped (0.7.8)
- **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX - **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX
- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup/restore via `mysqldump` (system-first) and DB-to-DB sync beyond PostgreSQL - **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup via the resolved `mariadb-dump`/`mysqldump` client (system-first, bundled fallback) with a native in-process restore, plus DB-to-DB sync beyond PostgreSQL
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar - **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app - **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file) - **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.8.1", "version": "0.8.3",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+39 -1
View File
@@ -339,6 +339,19 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "bigdecimal"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
dependencies = [
"autocfg",
"libm",
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -1783,7 +1796,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.8.1" version = "0.8.3"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
@@ -1809,6 +1822,7 @@ dependencies = [
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-keyring-store", "tauri-plugin-keyring-store",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio", "tokio",
"tokio-postgres", "tokio-postgres",
"tokio-postgres-rustls", "tokio-postgres-rustls",
@@ -4425,8 +4439,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b"
dependencies = [ dependencies = [
"atoi", "atoi",
"bigdecimal",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@@ -4507,9 +4523,11 @@ checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bitflags 2.13.1", "bitflags 2.13.1",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest 0.10.7", "digest 0.10.7",
"dotenvy", "dotenvy",
@@ -4549,8 +4567,10 @@ checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bitflags 2.13.1", "bitflags 2.13.1",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@@ -4566,6 +4586,7 @@ dependencies = [
"log", "log",
"md-5 0.10.6", "md-5 0.10.6",
"memchr", "memchr",
"num-bigint",
"once_cell", "once_cell",
"rand 0.8.7", "rand 0.8.7",
"serde", "serde",
@@ -4586,6 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -5020,6 +5042,22 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.19",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
+11 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.8.1" version = "0.8.3"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -33,7 +33,9 @@ tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
bytes = "1" bytes = "1"
futures-util = "0.3" futures-util = "0.3"
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] } # chrono/bigdecimal: MySQL DECIMAL and DATE/TIME/DATETIME/TIMESTAMP have no
# decoder without them, so those columns silently rendered as NULL (#41).
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json", "chrono", "bigdecimal"] }
redis = { version = "0.27", features = ["tokio-comp"] } redis = { version = "0.27", features = ["tokio-comp"] }
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback # async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
# vendored-openssl: link OpenSSL statically so the release binary has NO # vendored-openssl: link OpenSSL statically so the release binary has NO
@@ -59,3 +61,10 @@ security-framework-sys = "2.17"
# (already a transitive dep of security-framework; pinned to the same 0.10 line) # (already a transitive dep of security-framework; pinned to the same 0.10 line)
core-foundation = "0.10" core-foundation = "0.10"
# Windows/Linux deliver an OS "open with" request as argv. Without this a
# second double-click would launch a second app instead of forwarding the path
# to the running one. macOS needs no equivalent: LaunchServices routes to the
# running app via RunEvent::Opened.
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
+57
View File
@@ -1,6 +1,7 @@
//! Per-connection cancellation handles, stored independently of the pool //! Per-connection cancellation handles, stored independently of the pool
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex. //! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rusqlite::InterruptHandle; use rusqlite::InterruptHandle;
@@ -59,6 +60,12 @@ pub enum CancelHandle {
#[derive(Default)] #[derive(Default)]
pub struct CancelRegistry { pub struct CancelRegistry {
map: Mutex<HashMap<String, CancelHandle>>, map: Mutex<HashMap<String, CancelHandle>>,
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
/// sets one for MySQL; long-running in-process operations (currently the
/// native restore engine) poll it between statements and clear it when
/// they finish. Kept in a separate map from the handles so it needs no
/// `Clone` on the registry and survives handle replacement.
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
} }
impl CancelRegistry { impl CancelRegistry {
@@ -66,6 +73,41 @@ impl CancelRegistry {
Self::default() Self::default()
} }
/// Get (or create) the cancellation flag for a connection. Polling callers
/// should clear it before starting and after finishing.
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
self.flags
.lock()
.unwrap()
.entry(id.to_string())
.or_default()
.clone()
}
/// Mark a connection as cancel-requested. Called by `cancel_query` before
/// it sends the driver-specific cancel so in-process consumers observe the
/// request even when the driver surfaces it as an unrecognized error.
pub fn mark_cancelled(&self, id: &str) {
self.cancel_flag(id).store(true, Ordering::SeqCst);
}
/// Whether a cancel has been requested for this connection.
pub fn is_cancelled(&self, id: &str) -> bool {
self.flags
.lock()
.unwrap()
.get(id)
.map(|f| f.load(Ordering::SeqCst))
.unwrap_or(false)
}
/// Clear the cancel-requested flag for this connection.
pub fn clear_cancelled(&self, id: &str) {
if let Some(f) = self.flags.lock().unwrap().get(id) {
f.store(false, Ordering::SeqCst);
}
}
pub fn set_pg(&self, id: &str, c: PgCancel) { pub fn set_pg(&self, id: &str, c: PgCancel) {
self.set(id, CancelHandle::Pg(c)); self.set(id, CancelHandle::Pg(c));
} }
@@ -157,6 +199,21 @@ mod tests {
} }
} }
#[test]
fn cancel_flag_is_tracked_per_connection() {
let reg = CancelRegistry::new();
assert!(!reg.is_cancelled("c1"));
reg.mark_cancelled("c1");
assert!(reg.is_cancelled("c1"));
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
reg.clear_cancelled("c1");
assert!(!reg.is_cancelled("c1"));
// The same Arc is shared between getter and setter.
let flag = reg.cancel_flag("c1");
reg.mark_cancelled("c1");
assert!(flag.load(Ordering::SeqCst));
}
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions { fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
sqlx::mysql::MySqlConnectOptions::new() sqlx::mysql::MySqlConnectOptions::new()
.host("127.0.0.1").port(1).username("u").password("p").database("d") .host("127.0.0.1").port(1).username("u").password("p").database("d")
+538 -108
View File
@@ -3,6 +3,7 @@ use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use crate::db::tool_resolver::{self, ToolRequest, ToolResolution};
use crate::models::backup::*; use crate::models::backup::*;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,74 +41,106 @@ impl PgConnParams {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn get_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String { fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s) crate::commands::test_connection::sanitize_error(s)
} }
fn bundled_bin_name(tool: &str) -> String { /// Tauri-facing one-shot resolution with the process-lifetime cache.
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() } /// `force` bypasses the cache for an explicit user re-check.
fn resolve_request(app: &AppHandle, request: &ToolRequest, force: bool) -> ToolResolution {
let key = request.cache_key();
if !force {
if let Some(hit) = tool_resolver::cache_get(&key) {
return hit;
}
} }
/// Pure resolution decision (unit-testable): system > bundled > bare name. let resource_dir = app.path().resource_dir().ok();
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) { let bundled = resource_dir
if system_ok { return (tool.to_string(), Some("system".to_string())); } .as_ref()
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); } .map(|rd| tool_resolver::bundled_tool_path(rd, request.subdir, request.bundled_name()));
(tool.to_string(), None) let trusted = tool_resolver::trusted_dirs();
let start = std::time::Instant::now();
let probe = |p: &std::path::Path| {
tool_resolver::probe_within(start, tool_resolver::AGGREGATE_DEADLINE, p)
};
let resolved = tool_resolver::resolve_with(
request,
&trusted,
&tool_resolver::path_lookup,
bundled.as_deref(),
probe,
);
let version = resolved
.as_ref()
.and_then(|r| tool_resolver::version_of(&r.program));
let bundled_available = bundled
.as_deref()
.map(tool_resolver::bundled_present)
.unwrap_or(false);
let out = ToolResolution {
resolved,
bundled_path: bundled,
bundled_available,
version,
};
tool_resolver::cache_put(&key, out.clone());
out
} }
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source). /// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) { pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok(); match tool_resolver::pg_request(tool) {
let bundled = app.path().resource_dir().ok() Some(request) => {
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool))) tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
.filter(|p| p.exists()) }
.map(|p| p.to_string_lossy().to_string()); None => (tool.to_string(), None),
pick_tool(system_ok, bundled.as_deref(), tool) }
} }
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths { pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump"); PgToolPaths {
let (r, _) = resolve_tool(app, "pg_restore"); pg_dump: resolve_tool(app, "pg_dump").0,
let (p, _) = resolve_tool(app, "psql"); pg_restore: resolve_tool(app, "pg_restore").0,
PgToolPaths { pg_dump: d, pg_restore: r, psql: p } psql: resolve_tool(app, "psql").0,
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// detect_pg_tools // detect_pg_tools
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Shapes the tool status from resolved tool paths + sources. Headless so the /// Shapes both PG statuses from resolved tools. Headless so the Tauri command
/// Tauri command stays thin and the status logic stays unit-testable. /// stays thin and the shaping logic stays unit-testable.
fn build_pg_tool_status( fn build_pg_tool_status(dump: &ToolResolution, restore: &ToolResolution) -> PgToolStatus {
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus { PgToolStatus {
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(), pg_dump_found: dump.resolved.is_some(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(), pg_restore_found: restore.resolved.is_some(),
pg_dump_version: get_version(dump), pg_dump_version: dump.version.clone(),
pg_restore_version: get_version(restore), pg_restore_version: restore.version.clone(),
pg_dump_source: dump_src, pg_dump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_restore_source: restore_src, pg_restore_source: restore.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_dump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
pg_restore_resolved_name: restore.resolved.as_ref().map(|r| r.name.clone()),
pg_dump_bundled_available: dump.bundled_available,
pg_restore_bundled_available: restore.bundled_available,
} }
} }
/// Detection runs off the UI thread and can be forced past the cache.
#[tauri::command] #[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus { pub async fn detect_pg_tools(force: Option<bool>, app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump"); let force = force.unwrap_or(false);
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore"); tauri::async_runtime::spawn_blocking(move || {
build_pg_tool_status(&dump, &restore, dump_src, restore_src) let dump = resolve_request(&app_handle, &tool_resolver::pg_dump_request(), force);
let restore = resolve_request(&app_handle, &tool_resolver::pg_restore_request(), force);
build_pg_tool_status(&dump, &restore)
})
.await
.unwrap_or_else(|_| {
build_pg_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -501,90 +534,127 @@ fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
] ]
} }
/// Whether a resolved client is MariaDB's. MariaDB's clients print a
/// `...-MariaDB...` banner from `--version`; MySQL's print the Oracle banner.
/// This matters because MariaDB's clients — including the `mysqldump`/`mysql`
/// compatibility names some MariaDB packages ship — reject several MySQL-only
/// options with "unknown option", aborting the dump before it connects.
/// Probed once per program and cached for the process.
fn client_is_mariadb(program: &str) -> bool {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, bool>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock() {
if let Some(hit) = map.get(program) {
return *hit;
}
}
let is_mariadb = Command::new(program)
.arg("--version")
.output()
.map(|o| {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
text.contains("MariaDB")
})
.unwrap_or(false);
if let Ok(mut map) = cache.lock() {
map.insert(program.to_string(), is_mariadb);
}
is_mariadb
}
/// TLS flags for a dump client. MySQL's clients take `--ssl-mode=<mode>`;
/// MariaDB's have no `--ssl-mode` and use the boolean `--ssl`, which is the
/// equivalent of the only mode this code path uses (`REQUIRED`).
pub fn tls_args_for_client(client_is_mariadb: bool, tls_mode: Option<&str>) -> Vec<String> {
match (client_is_mariadb, tls_mode) {
(true, Some("REQUIRED")) => vec!["--ssl".into()],
(true, _) => Vec::new(),
(false, Some(m)) => vec![format!("--ssl-mode={m}")],
(false, None) => Vec::new(),
}
}
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the /// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
/// command), NEVER --password (process-list visibility). /// command), NEVER --password (process-list visibility).
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> { ///
/// `client_is_mariadb` gates the MySQL-only options: MariaDB's dump rejects
/// `--skip-column-statistics`. `--databases` is always emitted as a boolean
/// flag followed by the database name — `--databases=<db>` is rejected by
/// clients as an invalid value.
pub fn build_mysql_dump_args(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
client_is_mariadb: bool,
) -> Vec<String> {
let mut a = base_mysql_args(conn); let mut a = base_mysql_args(conn);
if options.single_transaction { a.push("--single-transaction".into()); } if options.single_transaction { a.push("--single-transaction".into()); }
if options.no_data { a.push("--no-data".into()); } if options.no_data { a.push("--no-data".into()); }
if options.routines { a.push("--routines".into()); } if options.routines { a.push("--routines".into()); }
if options.triggers { a.push("--triggers".into()); } if options.triggers { a.push("--triggers".into()); }
if options.events { a.push("--events".into()); } if options.events { a.push("--events".into()); }
a.push(format!("--databases={}", options.database)); a.push("--databases".into());
a.push(options.database.clone());
a.push(format!("--result-file={}", options.file_path)); a.push(format!("--result-file={}", options.file_path));
if !client_is_mariadb {
a.push("--skip-column-statistics".into()); a.push("--skip-column-statistics".into());
}
a a
} }
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> { /// System-first mariadb-dump/mysqldump resolution (bundled fallback).
let mut a = base_mysql_args(conn);
a.push(format!("--database={}", options.database));
a
}
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) { pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok(); match tool_resolver::mysql_request(tool) {
let bundled = app.path().resource_dir().ok() Some(request) => {
.map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool))) tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
.filter(|p| p.exists()) }
.map(|p| p.to_string_lossy().to_string()); None => (tool.to_string(), None),
pick_tool(system_ok, bundled.as_deref(), tool) }
} }
pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths { pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
let (d, _) = resolve_mysql_tool(app, "mariadb-dump"); MySqlToolPaths {
let (m, _) = resolve_mysql_tool(app, "mariadb"); mysqldump: resolve_mysql_tool(app, "mariadb-dump").0,
MySqlToolPaths { mysqldump: d, mysql: m } mysql: resolve_mysql_tool(app, "mariadb").0,
}
} }
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`) /// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live /// is translated per client — `--ssl-mode=` on MySQL clients, `--ssl` on
/// behavior = #[ignore] integration test.) /// MariaDB's (see `tls_args_for_client`) when routing through an SSH tunnel.
/// (Live behavior = #[ignore] integration test.)
pub fn run_mysql_dump( pub fn run_mysql_dump(
conn: &MySqlConnParams, conn: &MySqlConnParams,
options: &MySqlBackupOptions, options: &MySqlBackupOptions,
tools: &MySqlToolPaths, tools: &MySqlToolPaths,
tls_mode: Option<&str>, tls_mode: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut args = build_mysql_dump_args(conn, options); let is_mariadb = client_is_mariadb(&tools.mysqldump);
if let Some(m) = tls_mode { let mut args = build_mysql_dump_args(conn, options, is_mariadb);
args.push(format!("--ssl-mode={m}")); args.extend(tls_args_for_client(is_mariadb, tls_mode));
}
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output() let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) } if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
} }
pub fn run_mysql_restore(
conn: &MySqlConnParams,
options: &MySqlRestoreOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_restore_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
.map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
}
pub fn run_mysql_sync( pub fn run_mysql_sync(
source: &MySqlConnParams, source: &MySqlConnParams,
target: &MySqlConnParams, target: &MySqlConnParams,
tools: &MySqlToolPaths, tools: &MySqlToolPaths,
tls_mode: Option<&str>, tls_mode: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let is_mariadb = client_is_mariadb(&tools.mysqldump);
let mut dump_args = base_mysql_args(source); let mut dump_args = base_mysql_args(source);
dump_args.push("--single-transaction".into()); dump_args.push("--single-transaction".into());
dump_args.push("--add-drop-table".into()); dump_args.push("--add-drop-table".into());
dump_args.push(format!("--databases={}", source.database)); dump_args.push("--databases".into());
if let Some(m) = tls_mode { dump_args.push(source.database.clone());
dump_args.push(format!("--ssl-mode={m}")); dump_args.extend(tls_args_for_client(is_mariadb, tls_mode));
}
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?; let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
let stdout = dump.stdout.take().unwrap(); let stdout = dump.stdout.take().unwrap();
let mut restore_args = base_mysql_args(target); let mut restore_args = base_mysql_args(target);
@@ -598,6 +668,147 @@ pub fn run_mysql_sync(
} }
} }
// ---------------------------------------------------------------------------
// Native MySQL restore engine (in-process via sqlx — no CLI, no subprocess)
// ---------------------------------------------------------------------------
/// Restore reads from disk, so it is capped at the existing CSV/JSON import
/// limit (100 MB) rather than the 5 MB *editor* cap — a file too large to open
/// in the editor must still be restorable (spec §2).
pub const MAX_RESTORE_FILE_BYTES: u64 = 100 * 1024 * 1024;
/// Enforce the restore size cap. Extracted from the command so the boundary is
/// testable; restore reads from disk, so it uses the 100 MB bulk cap rather
/// than the 5 MB *editor* cap (spec §2).
pub fn check_restore_file_size(bytes: u64, cap: u64) -> Result<(), String> {
if bytes <= cap {
return Ok(());
}
Err(format!(
"Restore file is {} MB; the limit is {} MB",
bytes.div_ceil(1024 * 1024),
cap / (1024 * 1024)
))
}
/// Inputs for one native restore run. A struct rather than a long argument
/// list so the headless core stays readable and directly testable.
pub struct MySqlRestoreRun<'a> {
pub database: &'a str,
pub statements: &'a [String],
pub clean: bool,
}
/// Progress units for a restore: every statement, plus one for the clean
/// drop phase so the fraction never jumps backwards.
pub fn restore_progress_total(statement_count: usize, clean: bool) -> usize {
statement_count + usize::from(clean)
}
/// The error a failed restore reports. Includes the position so the user can
/// find the statement in their file, and never echoes the statement text
/// (SQL must not be logged — project guardrail).
pub fn format_restore_statement_error(index_1based: usize, total: usize, detail: &str) -> String {
format!("Statement {index_1based} of {total} failed: {detail}")
}
/// Map a driver error for the restore path: a `KILL QUERY` cancellation must
/// read as a cancellation, not as a broken statement.
fn restore_statement_error(e: &sqlx::Error) -> String {
if crate::commands::query::is_mysql_cancel_error(e) {
"Query cancelled".to_string()
} else {
sanitize_error(&format!("{e}"))
}
}
/// Execute an already-split restore script over one live MySQL connection.
///
/// Stop-at-first-error by contract: MySQL DDL is non-transactional and
/// auto-commits, so there is nothing to roll back and continuing past a
/// failure would compound an already-partial restore (spec §6).
pub async fn run_mysql_restore_on(
conn: &mut sqlx::mysql::MySqlConnection,
run: MySqlRestoreRun<'_>,
on_progress: &mut (dyn FnMut(usize, usize) + Send),
is_cancelled: &(dyn Fn() -> bool + Sync),
) -> Result<(), String> {
crate::commands::query::check_statement_budget(
run.statements.len(),
crate::commands::query::MAX_RESTORE_STATEMENTS,
true,
)?;
// The form lets the user target a database other than the one this
// connection defaults to, so select it explicitly.
exec_restore_statement(
conn,
&format!("USE {}", crate::db::mysql::mysql_quote_ident(run.database)),
)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let total = restore_progress_total(run.statements.len(), run.clean);
let mut done = 0usize;
if run.clean {
let drops = crate::db::mysql_clean::plan_clean_drops(run.database, run.statements);
if !drops.is_empty() {
// Foreign keys off for the drop phase, so object ordering cannot
// fail the restore; restored on the same session right after.
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 0")
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
for drop_sql in &drops {
if is_cancelled() {
return Err("Query cancelled".to_string());
}
exec_restore_statement(conn, drop_sql)
.await
.map_err(|e| restore_statement_error(&e))?;
}
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 1")
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
}
done += 1;
on_progress(done, total);
}
for (idx, stmt) in run.statements.iter().enumerate() {
if is_cancelled() {
return Err("Query cancelled".to_string());
}
if let Err(e) = exec_restore_statement(conn, stmt).await {
if crate::commands::query::is_mysql_cancel_error(&e) {
return Err("Query cancelled".to_string());
}
return Err(format_restore_statement_error(
idx + 1,
run.statements.len(),
&sanitize_error(&format!("{e}")),
));
}
done += 1;
on_progress(done, total);
}
Ok(())
}
/// Run a single restore statement, preserving the driver error so the caller
/// can distinguish a cancellation from a genuine statement failure.
///
/// Uses the *simple* query protocol (a bare string through `Executor`), not the
/// prepared-statement protocol: MySQL rejects several restore statements
/// (`USE`, `SET`, some DDL) with error 1295 when prepared.
async fn exec_restore_statement(
conn: &mut sqlx::mysql::MySqlConnection,
sql: &str,
) -> Result<(), sqlx::Error> {
use sqlx::Executor;
conn.execute(sql).await.map(|_| ())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission) // Tauri commands (thin wrappers: store lookup + keychain + event emission)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -610,6 +821,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: Some(1.0), progress: Some(1.0),
output_line: None, output_line: None,
error: None, error: None,
warning: None,
}, },
Err(e) => BackupProgressEvent { Err(e) => BackupProgressEvent {
job_id: job_id.to_string(), job_id: job_id.to_string(),
@@ -617,6 +829,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: None, progress: None,
output_line: None, output_line: None,
error: Some(e), error: Some(e),
warning: None,
}, },
}; };
let _ = app_handle.emit("backup-progress", event); let _ = app_handle.emit("backup-progress", event);
@@ -805,20 +1018,40 @@ pub async fn db_sync(
// MySQL dump / restore / sync commands (v0.7.8) // MySQL dump / restore / sync commands (v0.7.8)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tauri::command] fn build_mysql_tool_status(dump: &ToolResolution, client: &ToolResolution) -> MySqlToolStatus {
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
MySqlToolStatus { MySqlToolStatus {
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(), mysqldump_found: dump.resolved.is_some(),
mysql_found: Command::new(&m).arg("--version").output().is_ok(), mysql_found: client.resolved.is_some(),
mysqldump_version: get_version(&d), mysqldump_version: dump.version.clone(),
mysql_version: get_version(&m), mysql_version: client.version.clone(),
mysqldump_source: ds, mysqldump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysql_source: ms, mysql_source: client.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysqldump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
mysql_resolved_name: client.resolved.as_ref().map(|r| r.name.clone()),
mysqldump_is_mariadb: dump
.resolved
.as_ref()
.map(|r| r.name.starts_with("mariadb"))
.unwrap_or(false),
mysqldump_bundled_available: dump.bundled_available,
mysql_bundled_available: client.bundled_available,
} }
} }
#[tauri::command]
pub async fn detect_mysql_tools(force: Option<bool>, app_handle: AppHandle) -> MySqlToolStatus {
let force = force.unwrap_or(false);
tauri::async_runtime::spawn_blocking(move || {
let dump = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), force);
let client = resolve_request(&app_handle, &tool_resolver::mysql_client_request(), force);
build_mysql_tool_status(&dump, &client)
})
.await
.unwrap_or_else(|_| {
build_mysql_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
}
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the /// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
/// SSH tunnel endpoint when present. /// SSH tunnel endpoint when present.
fn mysql_endpoint( fn mysql_endpoint(
@@ -833,6 +1066,68 @@ fn mysql_endpoint(
(conn.host.clone(), conn.port.unwrap_or(3306), false) (conn.host.clone(), conn.port.unwrap_or(3306), false)
} }
/// Advisory server version for the compatibility check. Fail-open by design:
/// any error (TLS, SSH tunnel, auth) returns `None`, and the dump proceeds.
async fn mysql_server_version(
host: &str,
port: i64,
via_tunnel: bool,
conn: &crate::models::Connection,
password: &str,
) -> Option<String> {
let database = conn.database.clone().unwrap_or_default();
let original_host = conn.host.clone();
let ssl_mode = conn.ssl_mode.clone();
let ssl_ca = conn.ssl_ca_path.clone();
let username = conn.username.clone().unwrap_or_else(|| "root".into());
let mut c = crate::commands::db_viewer::open_mysql_connection(
host,
port as u16,
&username,
password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
)
.await
.ok()?;
sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&mut c)
.await
.ok()
}
/// Emit the advisory warning when the resolved dump client is clearly older
/// than the server. Never returns an error — the job always proceeds.
async fn maybe_emit_version_warning(
app_handle: &AppHandle,
job_id: &str,
resolution: &ToolResolution,
server_version: Option<String>,
) {
let (Some(client_version), Some(tool)) = (&resolution.version, &resolution.resolved) else {
return;
};
let Some(server_version) = server_version else { return };
let Some(warning) = tool_resolver::warning_for(client_version, &server_version, &tool.name)
else {
return;
};
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id.to_string(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some(warning),
},
);
}
#[tauri::command] #[tauri::command]
pub async fn mysql_dump( pub async fn mysql_dump(
connection_id: String, connection_id: String,
@@ -857,6 +1152,15 @@ pub async fn mysql_dump(
.unwrap_or_default(); .unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn); let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(&host, port, via_tunnel, &conn, &password).await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let params = MySqlConnParams::new( let params = MySqlConnParams::new(
host, host,
port, port,
@@ -901,20 +1205,129 @@ pub async fn mysql_restore(
.unwrap_or_default(); .unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn); let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
let params = MySqlConnParams::new(
host, // Native restore: no tool resolution, no subprocess, no MYSQL_PWD in a
port, // child environment. The sqlx driver handles MySQL 8 auth and TLS.
conn.username.clone().unwrap_or_else(|| "root".into()), let statements = {
options.database.clone(), let bytes = std::fs::metadata(&options.file_path)
password, .map_err(|e| format!("Cannot read restore file: {e}"))?
.len();
check_restore_file_size(bytes, MAX_RESTORE_FILE_BYTES)?;
let text = std::fs::read_to_string(&options.file_path)
.map_err(|e| format!("Cannot read restore file: {e}"))?;
let statements = crate::db::sql_split::split_statements(&text);
crate::commands::query::check_statement_budget(
statements.len(),
crate::commands::query::MAX_RESTORE_STATEMENTS,
true,
)?;
statements
};
let database = options.database.clone();
let clean = options.clean;
let original_host = conn.host.clone();
let ssl_mode = conn.ssl_mode.clone();
let ssl_ca = conn.ssl_ca_path.clone();
let username = conn.username.clone().unwrap_or_else(|| "root".into());
// Real cancellation: a per-restore flag that the existing `cancel_query`
// command sets, plus the restore session's MySQL thread id registered so
// `KILL QUERY` aborts the in-flight statement. Cleared before each run so a
// stale request from a previous operation cannot abort this one.
let cancel_flag = state.cancel_registry.cancel_flag(&connection_id);
cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst);
let cancel_opts = crate::commands::db_viewer::mysql_connect_options(
&host,
port as u16,
&username,
&password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
); );
let tools = resolve_mysql_tool_paths(&app_handle);
let tls = if via_tunnel { Some("REQUIRED") } else { None };
let job_id_clone = job_id.clone(); let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || { let connection_id_for_cancel = connection_id.clone();
let result = run_mysql_restore(&params, &options, &tools, tls);
tokio::spawn(async move {
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_secs(1);
let mut emit_progress = |done: usize, total: usize| {
// Throttle: a 50k-statement dump must not flood the IPC channel.
if last_emit.elapsed() < std::time::Duration::from_millis(200) && done < total {
return;
}
last_emit = std::time::Instant::now();
let _ = app_handle_clone.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "running".into(),
progress: Some(done as f64 / total.max(1) as f64),
output_line: Some(format!("Statement {done} of {total}")),
error: None,
warning: None,
},
);
};
let is_cancelled = {
let flag = cancel_flag.clone();
move || flag.load(std::sync::atomic::Ordering::SeqCst)
};
let result = async {
let mut mconn = crate::commands::db_viewer::open_mysql_connection(
&host,
port as u16,
&username,
&password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
)
.await?;
// Register the restore session so the UI's `cancel_query` can
// `KILL QUERY` it (mirrors how browsing registers pooled conns).
// The registry is reached through the app handle because it is not
// `Clone`.
let registry = app_handle_clone.state::<crate::AppState>();
let restore_conn_id: i64 =
sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut mconn)
.await
.unwrap_or(-1);
registry.cancel_registry.set_mysql(
&connection_id_for_cancel,
crate::cancel::MySqlCancel {
conn_id: Some(restore_conn_id),
connect_options: cancel_opts,
},
);
let r = run_mysql_restore_on(
&mut mconn,
MySqlRestoreRun { database: &database, statements: &statements, clean },
&mut emit_progress,
&is_cancelled,
)
.await;
registry
.cancel_registry
.set_mysql_conn_id(&connection_id_for_cancel, None);
registry.cancel_registry.clear_cancelled(&connection_id_for_cancel);
r
}
.await;
emit_result(&app_handle_clone, &job_id_clone, result); emit_result(&app_handle_clone, &job_id_clone, result);
}); });
@@ -971,6 +1384,23 @@ pub async fn mysql_sync(
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn); let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn); let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(
&src_host,
src_port,
src_via_tunnel,
&source_conn,
&src_password,
)
.await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let source = MySqlConnParams::new( let source = MySqlConnParams::new(
src_host, src_host,
src_port, src_port,
+367 -34
View File
@@ -132,15 +132,44 @@ fn build_pg_restore_args_with_schema() {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
#[test] #[test]
fn detect_pg_tools_does_not_panic() { fn build_pg_tool_status_reflects_resolutions() {
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the let dump = ToolResolution {
// status-shaping logic testable without one. resolved: Some(crate::db::tool_resolver::Resolution {
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None); program: "/opt/homebrew/bin/mariadb-dump".into(),
// May or may not find tools, but the call itself must not panic source: crate::db::tool_resolver::ToolSource::TrustedDir,
let _ = status.pg_dump_found; name: "pg_dump".into(),
let _ = status.pg_restore_found; }),
let _ = status.pg_dump_version; bundled_path: Some(std::path::PathBuf::from("/app/resources/pg_tools/pg_dump")),
let _ = status.pg_restore_version; bundled_available: true,
version: Some("pg_dump (PostgreSQL) 16.4".into()),
};
let status = build_pg_tool_status(&dump, &ToolResolution::none());
assert!(status.pg_dump_found);
assert!(!status.pg_restore_found);
assert_eq!(status.pg_dump_source.as_deref(), Some("system"));
assert_eq!(status.pg_dump_resolved_name.as_deref(), Some("pg_dump"));
assert!(status.pg_dump_bundled_available);
assert!(!status.pg_restore_bundled_available);
}
#[test]
fn build_mysql_tool_status_reports_the_resolved_dump_name() {
let dump = ToolResolution {
resolved: Some(crate::db::tool_resolver::Resolution {
program: "/opt/homebrew/bin/mariadb-dump".into(),
source: crate::db::tool_resolver::ToolSource::TrustedDir,
name: "mariadb-dump".into(),
}),
bundled_path: Some(std::path::PathBuf::from("/app/resources/mysql_tools/mariadb-dump")),
bundled_available: true,
version: Some("mariadb-dump 11.4.5-MariaDB".into()),
};
let status = build_mysql_tool_status(&dump, &ToolResolution::none());
assert!(status.mysqldump_found);
assert!(!status.mysql_found);
assert_eq!(status.mysqldump_resolved_name.as_deref(), Some("mariadb-dump"));
assert!(status.mysqldump_is_mariadb);
assert!(!status.mysql_bundled_available);
} }
#[test] #[test]
@@ -152,6 +181,10 @@ fn pg_tool_status_serialization() {
pg_restore_version: None, pg_restore_version: None,
pg_dump_source: None, pg_dump_source: None,
pg_restore_source: None, pg_restore_source: None,
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false,
}; };
let json = serde_json::to_string(&status).unwrap(); let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found")); assert!(json.contains("pg_dump_found"));
@@ -171,6 +204,7 @@ fn backup_progress_event_completed() {
progress: Some(1.0), progress: Some(1.0),
output_line: None, output_line: None,
error: None, error: None,
warning: None,
}; };
let json = serde_json::to_string(&evt).unwrap(); let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"completed\"")); assert!(json.contains("\"completed\""));
@@ -185,12 +219,41 @@ fn backup_progress_event_failed() {
progress: None, progress: None,
output_line: None, output_line: None,
error: Some("connection refused".into()), error: Some("connection refused".into()),
warning: None,
}; };
let json = serde_json::to_string(&evt).unwrap(); let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"failed\"")); assert!(json.contains("\"failed\""));
assert!(json.contains("\"connection refused\"")); assert!(json.contains("\"connection refused\""));
} }
#[test]
fn backup_progress_event_serializes_a_non_fatal_warning() {
let evt = BackupProgressEvent {
job_id: "job-warn".into(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some("Client/server version mismatch: …".into()),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":\"Client/server version mismatch"), "{json}");
}
#[test]
fn backup_progress_event_serializes_a_null_warning_when_absent() {
let evt = BackupProgressEvent {
job_id: "job-quiet".into(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
warning: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":null"), "{json}");
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Integration tests (headless, against live DBs) // Integration tests (headless, against live DBs)
// //
@@ -406,19 +469,6 @@ fn integration_plain_dump_restore() {
// Tool resolution (Task 2.1: system-first, bundled-fallback) // Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2) // SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -507,28 +557,311 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
database: "shop".into(), file_path: "/tmp/d.sql".into(), database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false, single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
}; };
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts); let args = build_mysql_dump_args(
&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()),
&opts,
false,
);
assert!(args.iter().any(|a| a == "--single-transaction")); assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data")); assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines")); assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop")); // `--databases` is a BOOLEAN flag: the database name must be a separate
// positional argument. `--databases=shop` is rejected by clients as
// "invalid value 'shop'".
let i = args.iter().position(|a| a == "--databases").expect("--databases flag");
assert_eq!(args[i + 1], "shop");
assert!(!args.iter().any(|a| a.starts_with("--databases=")), "{args:?}");
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql")); assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env) // no --password on the command line (uses MYSQL_PWD env)
assert!(args.iter().all(|a| !a.starts_with("--password"))); assert!(args.iter().all(|a| !a.starts_with("--password")));
} }
#[test] #[test]
fn mysql_restore_args_no_clean_flags() { fn mysql_env_uses_mysql_pwd_not_password_arg() {
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false }; let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts); let args = build_mysql_dump_args(
assert!(args.iter().any(|a| a == "--database=shop")); &MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()),
assert!(args.iter().any(|a| a == "--host=h")); &opts,
assert!(args.iter().all(|a| a != "--force")); false,
);
assert!(args.iter().all(|a| !a.starts_with("--password")));
} }
#[test] #[test]
fn mysql_env_uses_mysql_pwd_not_password_arg() { fn mysql_dump_args_omit_column_statistics_for_mariadb_clients() {
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false }; // The bundled client is MariaDB 11.4.5 and rejects the MySQL-8-only option
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts); // with `unknown option '--skip-column-statistics'`, aborting the dump.
assert!(args.iter().all(|a| !a.starts_with("--password"))); let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
let conn = MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into());
let mariadb = build_mysql_dump_args(&conn, &opts, true);
assert!(!mariadb.iter().any(|a| a == "--skip-column-statistics"), "{mariadb:?}");
let mysql = build_mysql_dump_args(&conn, &opts, false);
assert!(mysql.iter().any(|a| a == "--skip-column-statistics"), "{mysql:?}");
}
#[test]
fn tls_args_map_ssl_mode_only_for_mysql_clients() {
// MySQL clients take `--ssl-mode=<mode>`; MariaDB clients have no such
// option and use the boolean `--ssl`.
assert_eq!(tls_args_for_client(false, Some("REQUIRED")), vec!["--ssl-mode=REQUIRED".to_string()]);
assert_eq!(tls_args_for_client(true, Some("REQUIRED")), vec!["--ssl".to_string()]);
assert_eq!(tls_args_for_client(false, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::<String>::new());
}
#[test]
fn client_is_mariadb_reads_the_version_banner() {
// A program that cannot be executed must not panic — it reports false
// (keep the MySQL-only flags), and the dump fails at spawn anyway.
assert!(!client_is_mariadb("/nonexistent/definitely-not-a-tool"));
}
/// Regression guard for the bundled fallback path: the args we generate must be
/// *accepted* by the real bundled MariaDB client. It spawns the staged binary
/// against a closed port and requires the failure to be a CONNECTION error —
/// never an option-parsing error. No live server needed.
///
/// The binaries are gitignored (the release workflow downloads them), so when
/// they are not staged this test logs loudly and returns instead of failing.
#[test]
fn bundled_mariadb_dump_accepts_our_generated_args() {
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
if !bundled.is_file() {
eprintln!(
"SKIPPED bundled_mariadb_dump_accepts_our_generated_args: {bundled:?} is not staged \
(run the release workflow's tool-staging step to enable this guard)"
);
return;
}
let conn = MySqlConnParams::new("127.0.0.1".into(), 1, "root".into(), "mysql".into(), "unused".into());
let opts = MySqlBackupOptions {
database: "mysql".into(),
file_path: std::env::temp_dir().join("gridline_args_guard.sql").to_string_lossy().to_string(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
assert!(client_is_mariadb(bundled.to_str().unwrap()), "the bundled dump tool must be MariaDB's");
let mut args = build_mysql_dump_args(&conn, &opts, true);
args.extend(tls_args_for_client(true, Some("REQUIRED")));
let out = std::process::Command::new(&bundled).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
// "unknown option" covers `--skip-column-statistics`; "unknown variable"
// covers `--ssl-mode` (MariaDB's own wording for the same class of mistake).
assert!(!err.contains("unknown option"), "bundled client rejected an option: {err}");
assert!(!err.contains("unknown variable"), "bundled client rejected an option: {err}");
assert!(!err.contains("invalid value"), "bundled client rejected an option value: {err}");
assert!(
err.contains("2002") || err.contains("can't connect") || err.contains("connection refused"),
"expected a connection failure (port 1 is closed), got: {err}"
);
}
// ── native MySQL restore: error reporting contract ──────────────────
#[test]
fn restore_failure_names_the_statement_position() {
// The user must be able to find the offending statement in their file.
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
}
#[test]
fn restore_progress_total_counts_the_clean_phase() {
// clean adds one progress unit (the drop phase) so the bar never resets.
assert_eq!(restore_progress_total(5, false), 5);
assert_eq!(restore_progress_total(5, true), 6);
}
#[tokio::test]
#[ignore]
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
use sqlx::ConnectOptions;
use sqlx::Connection;
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
.host(&host).port(port).username(&user).password(&pass).database(&db)
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
.disable_statement_logging()
.connect().await.expect("connect");
// Scratch table, removed again at the end, so the live DB is never harmed.
let table = "gl_restore_probe";
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
.execute(&mut conn).await.expect("pre-clean");
let script = format!(
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
INSERT INTO `{table}` VALUES (1, 'first');"
);
let statements = crate::db::sql_split::split_statements(&script);
let mut events: Vec<(usize, usize)> = Vec::new();
let mut on_progress = |done: usize, total: usize| events.push((done, total));
let never = || false;
run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
&mut on_progress,
&never,
).await.expect("first restore");
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
.fetch_one(&mut conn).await.expect("count");
assert_eq!(count, 1);
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
// Second run with clean: the table must be dropped and recreated, so the
// insert cannot collide with the existing primary key.
let mut events2: Vec<(usize, usize)> = Vec::new();
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
&mut on_progress2,
&never,
).await.expect("clean restore must not hit a duplicate key");
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
.fetch_one(&mut conn).await.expect("count2");
assert_eq!(count2, 1, "clean restore must replace, not append");
// A statement that cannot succeed must fail and name its position.
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
let mut noop = |_: usize, _: usize| {};
let err = run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
&mut noop,
&never,
).await.expect_err("must fail");
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
let _ = conn.close().await;
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
.host(&host).port(port).username(&user).password(&pass).database(&db)
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
.connect().await.expect("reconnect");
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
.execute(&mut cleanup).await.expect("cleanup");
let _ = cleanup.close().await;
}
// ── restore file-size cap ───────────────────────────────────────────
#[test]
fn restore_size_cap_accepts_a_file_at_the_limit() {
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
}
#[test]
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
.expect_err("must reject");
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
}
// ── live MySQL dump: system tool vs. bundled tool ───────────────────
//
// Issue #44: the app reported "mysqldump not found" even with MySQL
// client tools installed. These two #[ignore]d tests exercise the real
// `run_mysql_dump` against a live server — once via a tool the shared
// resolver found as a *system* client, once via the *bundled* MariaDB
// client — so both provenance paths are covered end to end.
//
// Run with:
//
// GRIDLINE_TEST_MYSQL_HOST=... GRIDLINE_TEST_MYSQL_PORT=... \
// GRIDLINE_TEST_MYSQL_USER=... GRIDLINE_TEST_MYSQL_DB=... \
// GRIDLINE_TEST_MYSQL_PASSWORD=... \
// cargo test --lib live_mysql_dump -- --ignored --nocapture
fn mysql_conn_from_env(prefix: &str) -> MySqlConnParams {
MySqlConnParams::new(
env(&format!("{prefix}_HOST")),
env(&format!("{prefix}_PORT")).parse().unwrap(),
env(&format!("{prefix}_USER")),
env(&format!("{prefix}_DB")),
env(&format!("{prefix}_PASSWORD")),
)
}
fn live_mysql_dump_opts(conn: &MySqlConnParams, file_name: &str) -> MySqlBackupOptions {
MySqlBackupOptions {
database: conn.database.clone(),
file_path: std::env::temp_dir().join(file_name).to_string_lossy().to_string(),
single_transaction: true,
no_data: false,
routines: true,
triggers: true,
events: false,
}
}
/// Requires GRIDLINE_TEST_MYSQL_{HOST,PORT,USER,DB,PASSWORD} and a system
/// mariadb-dump/mysqldump on this machine.
#[test]
#[ignore]
fn live_mysql_dump_with_a_system_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_system.sql");
let req = crate::db::tool_resolver::mysql_dump_request();
let trusted = crate::db::tool_resolver::trusted_dirs();
let resolved = crate::db::tool_resolver::resolve_with(
&req,
&trusted,
&crate::db::tool_resolver::path_lookup,
None,
crate::db::tool_resolver::probe,
)
.expect("no system mariadb-dump/mysqldump resolved");
eprintln!("resolved: {} ({})", resolved.program, resolved.source.as_str());
let tools = MySqlToolPaths {
mysqldump: resolved.program.clone(),
mysql: resolved.program.clone(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
}
/// Requires GRIDLINE_TEST_MYSQL_* and a staged `resources/mysql_tools` tree.
#[test]
#[ignore]
fn live_mysql_dump_with_the_bundled_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_bundled.sql");
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
assert!(bundled.is_file(), "bundled mariadb-dump not staged at {bundled:?}");
assert!(crate::db::tool_resolver::probe(&bundled), "bundled mariadb-dump does not run");
let tools = MySqlToolPaths {
mysqldump: bundled.to_string_lossy().to_string(),
mysql: bundled.to_string_lossy().to_string(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("bundled dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "bundled dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
} }
+105 -25
View File
@@ -941,6 +941,102 @@ where
Ok((client, handle)) Ok((client, handle))
} }
/// Apply Gridline's MySQL TLS policy to connect options.
///
/// Extracted from `run_mysql_connect` so the native restore path connects
/// *exactly* as browsing does. That equivalence is the whole point of the fix:
/// the sqlx driver reaches MySQL 8 with `caching_sha2_password` and handles
/// TLS in-process, where the bundled MariaDB client cannot.
pub(crate) fn apply_mysql_tls(
mut opts: sqlx::mysql::MySqlConnectOptions,
original_host: &str,
ssl_mode: Option<&str>,
ssl_ca_path: Option<&str>,
via_tunnel: bool,
) -> sqlx::mysql::MySqlConnectOptions {
use sqlx::mysql::MySqlSslMode;
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision_for(original_host, ssl_mode),
via_tunnel,
);
match decision {
crate::db::tls::TlsDecision::Disable => {
opts = opts.ssl_mode(MySqlSslMode::Disabled);
}
crate::db::tls::TlsDecision::Require => {
opts = opts.ssl_mode(MySqlSslMode::Required);
}
crate::db::tls::TlsDecision::Verify => {
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
// verify-full -> VerifyIdentity (chain + hostname).
match ssl_mode {
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
}
if let Some(ca) = ssl_ca_path {
opts = opts.ssl_ca(ca);
}
}
}
opts
}
/// Build the final MySQL connect options (base params + TLS policy) for a
/// headless, non-pooled connection.
pub(crate) fn mysql_connect_options(
host: &str,
port: u16,
username: &str,
password: &str,
database: &str,
original_host: &str,
ssl_mode: Option<&str>,
ssl_ca_path: Option<&str>,
via_tunnel: bool,
) -> sqlx::mysql::MySqlConnectOptions {
let opts = sqlx::mysql::MySqlConnectOptions::new()
.host(host)
.port(port)
.username(username)
.password(password)
.database(database);
apply_mysql_tls(opts, original_host, ssl_mode, ssl_ca_path, via_tunnel)
}
/// Open a single standalone MySQL connection for a non-browsing operation
/// (currently: native restore). Deliberately not pooled — the restore owns the
/// session so `USE` and `FOREIGN_KEY_CHECKS` stay coherent for its whole run.
pub(crate) async fn open_mysql_connection(
host: &str,
port: u16,
username: &str,
password: &str,
database: &str,
original_host: &str,
ssl_mode: Option<&str>,
ssl_ca_path: Option<&str>,
via_tunnel: bool,
) -> Result<sqlx::mysql::MySqlConnection, String> {
use sqlx::ConnectOptions;
let opts = mysql_connect_options(
host,
port,
username,
password,
database,
original_host,
ssl_mode,
ssl_ca_path,
via_tunnel,
);
opts.connect()
.await
.map_err(|e| sanitize_error(&format!("MySQL connection failed: {e}")))
}
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when /// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a /// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host` /// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
@@ -952,7 +1048,7 @@ pub(crate) async fn run_mysql_connect(
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>, pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
cancel_registry: &crate::cancel::CancelRegistry, cancel_registry: &crate::cancel::CancelRegistry,
) -> Result<(), String> { ) -> Result<(), String> {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode}; use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
if config.host.trim().is_empty() { if config.host.trim().is_empty() {
return Err("host is required".to_string()); return Err("host is required".to_string());
} }
@@ -995,38 +1091,22 @@ pub(crate) async fn run_mysql_connect(
via_tunnel = false; via_tunnel = false;
} }
let mut opts = MySqlConnectOptions::new() let opts = MySqlConnectOptions::new()
.host(&target_host) .host(&target_host)
.port(target_port) .port(target_port)
.username(config.username.as_deref().unwrap_or("root")) .username(config.username.as_deref().unwrap_or("root"))
.password(config.password.as_deref().unwrap_or("")) .password(config.password.as_deref().unwrap_or(""))
.database(config.database.as_deref().unwrap_or("mysql")); .database(config.database.as_deref().unwrap_or("mysql"));
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: shared with the native restore path so browsing and restore make
// degrade to encrypt-only `require`. Direct connections honor the mode. // identical TLS decisions.
let decision = crate::commands::ssh::effective_tls_decision( let opts = apply_mysql_tls(
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()), opts,
&config.host,
config.ssl_mode.as_deref(),
config.ssl_ca_path.as_deref(),
via_tunnel, via_tunnel,
); );
match decision {
crate::db::tls::TlsDecision::Disable => {
opts = opts.ssl_mode(MySqlSslMode::Disabled);
}
crate::db::tls::TlsDecision::Require => {
opts = opts.ssl_mode(MySqlSslMode::Required);
}
crate::db::tls::TlsDecision::Verify => {
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
// verify-full -> VerifyIdentity (chain + hostname).
match config.ssl_mode.as_deref() {
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
}
if let Some(ca) = config.ssl_ca_path.as_deref() {
opts = opts.ssl_ca(ca);
}
}
}
// Clone the (already final) options before `connect_with` consumes them so // Clone the (already final) options before `connect_with` consumes them so
// `cancel_query` can open its own connection to run `KILL QUERY ?`. // `cancel_query` can open its own connection to run `KILL QUERY ?`.
+1
View File
@@ -7,6 +7,7 @@ pub mod import_export;
pub mod keychain; pub mod keychain;
pub mod maintenance; pub mod maintenance;
pub mod objects; pub mod objects;
pub mod open_files;
pub mod query; pub mod query;
pub mod query_export; pub mod query_export;
pub mod schema_diff; pub mod schema_diff;
@@ -0,0 +1,121 @@
//! OS-delivered "open this .sql file with Gridline" requests.
//!
//! Every platform funnels into [`accept_path`], which validates strictly and
//! queues the path. The frontend only ever calls [`take_pending_sql_files`],
//! which takes **no path argument** and drains what was queued — so the webview
//! cannot name a file to read. That is the boundary that keeps this feature
//! from becoming an arbitrary-filesystem-read primitive and defeating the
//! deliberate `fs` scope (spec §2).
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::State;
/// Editor-load cap. A larger script is routed to Tools → Restore instead of
/// being handed to Monaco (spec §2).
pub const MAX_SQL_FILE_BYTES: u64 = 5 * 1024 * 1024;
/// A validated open request, delivered to the frontend with its contents.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingSqlFile {
pub path: String,
pub name: String,
pub content: String,
}
/// Process-wide FIFO of accepted paths. Lives in Rust (not the webview) so a
/// window reload cannot lose an open request.
#[derive(Default)]
pub struct PendingOpenFiles(Mutex<Vec<PathBuf>>);
/// Strict validation. Fail closed: anything we are not certain about is
/// rejected rather than opened (spec §6).
pub fn validate_sql_path_with_cap(raw: &str, cap: u64) -> Option<PathBuf> {
let path = Path::new(raw);
// Absolute only — the OS always hands us absolute paths, and accepting a
// relative one would resolve against the process CWD. Note the app is
// often launched with CWD `/` (see the app-data-dir note in lib.rs).
if !path.is_absolute() {
return None;
}
let ext = path.extension()?.to_str()?;
if !ext.eq_ignore_ascii_case("sql") {
return None;
}
// symlink_metadata, not metadata: a symlink must not be followed into an
// arbitrary target (and `file_type().is_file()` is false for one).
let meta = std::fs::symlink_metadata(path).ok()?;
if !meta.file_type().is_file() {
return None;
}
if meta.len() > cap {
return None;
}
Some(path.to_path_buf())
}
/// Validate against the production cap.
pub fn validate_sql_path(raw: &str) -> Option<PathBuf> {
validate_sql_path_with_cap(raw, MAX_SQL_FILE_BYTES)
}
/// Queue an OS-supplied path. Returns `true` when it was newly queued, so
/// callers can decide whether a nudge event is worth emitting.
pub fn accept_path(buffer: &PendingOpenFiles, raw: &str) -> bool {
let Some(path) = validate_sql_path(raw) else {
return false;
};
let mut queue = buffer.0.lock().unwrap();
if queue.iter().any(|p| p == &path) {
return false;
}
queue.push(path);
true
}
/// Read and remove everything queued. Separated from the command so it is
/// testable without a Tauri `State`.
pub fn take_from_buffer(buffer: &PendingOpenFiles) -> Result<Vec<PendingSqlFile>, String> {
let drained: Vec<PathBuf> = {
let mut queue = buffer.0.lock().unwrap();
std::mem::take(&mut *queue)
};
drained
.into_iter()
.map(|path| {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
// Fail closed on undecodable bytes rather than guessing an
// encoding for the user's SQL.
let bytes =
std::fs::read(&path).map_err(|e| format!("Cannot read {}: {e}", name))?;
let content =
String::from_utf8(bytes).map_err(|_| format!("{name} is not valid UTF-8"))?;
Ok(PendingSqlFile {
path: path.to_string_lossy().into_owned(),
name,
content,
})
})
.collect()
}
/// Drain every queued open request. The only file-reading command the webview
/// can call — deliberately argument-free (see the module docs).
#[tauri::command]
pub fn take_pending_sql_files(
state: State<'_, PendingOpenFiles>,
) -> Result<Vec<PendingSqlFile>, String> {
take_from_buffer(&state)
}
#[cfg(test)]
#[path = "open_files.test.rs"]
mod open_files_tests;
@@ -0,0 +1,107 @@
use super::*;
fn tmp_sql(name: &str, repeats: usize) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("gridline-open-{name}"));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("sample.sql");
std::fs::write(&file, "SELECT 1;".repeat(repeats.max(1))).unwrap();
file
}
#[test]
fn rejects_a_relative_path() {
assert!(validate_sql_path_with_cap("student_db.sql", 1024).is_none());
}
#[test]
fn rejects_a_non_sql_extension() {
let f = tmp_sql("ext", 1);
let renamed = f.with_extension("txt");
std::fs::rename(&f, &renamed).unwrap();
assert!(validate_sql_path_with_cap(&renamed.to_string_lossy(), 1024).is_none());
}
#[test]
fn rejects_a_directory() {
let dir = std::env::temp_dir().join("gridline-open-dir");
std::fs::create_dir_all(dir.join("not-a-file.sql")).unwrap();
assert!(
validate_sql_path_with_cap(&dir.join("not-a-file.sql").to_string_lossy(), 1024).is_none()
);
}
#[test]
fn rejects_a_missing_file() {
let p = std::env::temp_dir().join("gridline-definitely-missing-xyz.sql");
assert!(validate_sql_path_with_cap(&p.to_string_lossy(), 1024).is_none());
}
#[test]
fn rejects_a_file_over_the_cap() {
let f = tmp_sql("big", 200);
assert!(validate_sql_path_with_cap(&f.to_string_lossy(), 16).is_none());
}
#[test]
fn rejects_a_symlink() {
#[cfg(unix)]
{
let f = tmp_sql("sym", 1);
let link = f.with_file_name("link.sql");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&f, &link).unwrap();
assert!(
validate_sql_path_with_cap(&link.to_string_lossy(), 1024).is_none(),
"a symlink must not be accepted (spec §6)"
);
}
}
#[test]
fn accepts_a_valid_sql_file_with_an_uppercase_extension() {
let f = tmp_sql("ok", 1);
let upper = f.with_file_name("SAMPLE.SQL");
std::fs::rename(&f, &upper).unwrap();
assert!(validate_sql_path_with_cap(&upper.to_string_lossy(), 1024).is_some());
}
#[test]
fn accept_path_dedupes_the_same_path() {
let buffer = PendingOpenFiles::default();
let f = tmp_sql("dedupe", 1);
let s = f.to_string_lossy().to_string();
assert!(accept_path(&buffer, &s));
assert!(!accept_path(&buffer, &s), "second accept must be deduped");
}
#[test]
fn accept_path_ignores_an_invalid_path() {
let buffer = PendingOpenFiles::default();
assert!(!accept_path(&buffer, "relative.sql"));
}
#[test]
fn take_returns_contents_then_drains() {
let buffer = PendingOpenFiles::default();
let f = tmp_sql("drain", 1);
assert!(accept_path(&buffer, &f.to_string_lossy()));
let first = take_from_buffer(&buffer).expect("first drain");
assert_eq!(first.len(), 1);
assert_eq!(first[0].name, "sample.sql");
assert_eq!(first[0].content, "SELECT 1;");
let second = take_from_buffer(&buffer).expect("second drain");
assert!(second.is_empty(), "buffer must be drained, not reread");
}
#[test]
fn rejects_a_non_utf8_file() {
let dir = std::env::temp_dir().join("gridline-open-binary");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("binary.sql");
std::fs::write(&f, [0xff, 0xfe, 0x00]).unwrap();
let buffer = PendingOpenFiles::default();
assert!(accept_path(&buffer, &f.to_string_lossy()));
assert!(take_from_buffer(&buffer).is_err(), "non-UTF8 must fail closed");
}
+213 -19
View File
@@ -34,7 +34,7 @@ fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED) e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
} }
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool { pub(crate) fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
match e.as_database_error().and_then(|d| d.code()) { match e.as_database_error().and_then(|d| d.code()) {
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY) Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
_ => e.to_string().to_lowercase().contains("interrupted"), _ => e.to_string().to_lowercase().contains("interrupted"),
@@ -565,25 +565,194 @@ pub(crate) fn mysql_wrap_count(query: &str) -> String {
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim()) format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
} }
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature). /// How a MySQL column value must be decoded, decided from the column's
/// declared type name.
///
/// Why type-directed rather than a blind try-chain: sqlx's MySQL integer
/// compatibility is broad, so `try_get::<i16>` succeeds for a `BIGINT`
/// holding a small value but fails once the value grows — the same column
/// would emit different JSON types for different rows. `MySqlTypeInfo`
/// exposes only `name()` publicly, which is sufficient because it already
/// encodes signedness (`INT UNSIGNED`) and boolean-ness (`BOOLEAN`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MysqlCellKind {
/// Width in bits and signedness; drives the number-vs-string policy.
Int { bits: u8, signed: bool },
/// `BIT(M)` — a bitmask, rendered as a number (spec §4.1).
Bit,
Float,
Double,
Decimal,
Date,
Time,
DateTime,
Json,
Text,
Bytes,
Null,
/// Anything unrecognised (including `GEOMETRY`, which no sqlx decoder
/// accepts even with `chrono`/`bigdecimal` enabled).
Unknown,
}
/// Map a `sqlx` MySQL type name to its decode strategy.
pub(crate) fn mysql_cell_kind(type_name: &str) -> MysqlCellKind {
use MysqlCellKind::*;
let t = type_name.trim().to_ascii_uppercase();
match t.as_str() {
"BOOLEAN" | "TINYINT" => Int { bits: 8, signed: true },
"TINYINT UNSIGNED" => Int { bits: 8, signed: false },
"SMALLINT" => Int { bits: 16, signed: true },
"SMALLINT UNSIGNED" => Int { bits: 16, signed: false },
"MEDIUMINT" => Int { bits: 24, signed: true },
"MEDIUMINT UNSIGNED" => Int { bits: 24, signed: false },
"INT" => Int { bits: 32, signed: true },
"INT UNSIGNED" => Int { bits: 32, signed: false },
"BIGINT" => Int { bits: 64, signed: true },
"BIGINT UNSIGNED" => Int { bits: 64, signed: false },
// YEAR is an unsigned 16-bit value; it needs the unsigned rung, not
// the signed one (int_compatible excludes UNSIGNED columns).
"YEAR" => Int { bits: 16, signed: false },
"BIT" => Bit,
"FLOAT" => Float,
"DOUBLE" => Double,
"DECIMAL" => Decimal,
"DATE" => Date,
"TIME" => Time,
// TIMESTAMP shares the Datetime wire type. Decoded naive on purpose:
// MySQL converts TIMESTAMP to/from the *session* time zone, so
// claiming UTC would assert something the value does not support.
"DATETIME" | "TIMESTAMP" => DateTime,
"JSON" => Json,
"CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT"
| "ENUM" | "SET" => Text,
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => Bytes,
"NULL" => Null,
_ => Unknown,
}
}
/// Convert a sqlx MySql row cell to serde_json::Value, driven by the column's
/// **declared** type (see [`mysql_cell_kind`]).
///
/// Type-directed rather than a blind try-chain because sqlx's MySQL integer
/// compatibility is broad: `try_get::<i16>` succeeds for a `BIGINT` holding
/// a small value and fails once the value grows, so a value-driven chain
/// would emit different JSON types for different rows of one column.
/// Shared with the DB-viewer commands (pub(crate)). /// Shared with the DB-viewer commands (pub(crate)).
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value { pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) { use sqlx::{Row, TypeInfo, ValueRef};
return v;
// `MySqlTypeInfo.r#type`/`.flags` are `pub(crate)`, so the declared type is
// read through the public `name()`, which already distinguishes
// `INT UNSIGNED`, `BIGINT UNSIGNED` and `BOOLEAN` (TINYINT(1)).
let type_name = match row.try_get_raw(i) {
Ok(v) if !v.is_null() => v.type_info().name().to_string(),
// Genuine SQL NULL, or a column we cannot even describe.
_ => return serde_json::Value::Null,
};
match mysql_cell_kind(&type_name) {
MysqlCellKind::Int { bits, signed } => {
if signed {
match row.try_get::<i64, _>(i) {
// <=32-bit fits a JS Number exactly; 64-bit is stringified
// so the value survives the IPC boundary (matches PG).
Ok(v) if bits <= 32 => serde_json::json!(v),
Ok(v) => crate::commands::db_viewer::i64_to_json(v),
Err(_) => serde_json::Value::Null,
} }
if let Ok(s) = row.try_get::<Option<String>, _>(i) { } else {
return s match row.try_get::<u64, _>(i) {
.map(|s| serde_json::Value::String(s)) Ok(v) if bits <= 32 => serde_json::json!(v),
.unwrap_or(serde_json::Value::Null); Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
} }
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) { }
return b }
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())) MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
.unwrap_or(serde_json::Value::Null); Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Float => match row.try_get::<f32, _>(i) {
Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Double => match row.try_get::<f64, _>(i) {
Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Decimal => match row.try_get::<sqlx::types::BigDecimal, _>(i) {
// Stringified for the same reason as 64-bit ints: exact digits.
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
// Temporal values are emitted naive, never as an instant: MySQL
// converts TIMESTAMP to and from the *session* time zone, so an
// explicit `Z` would assert something the value does not support.
MysqlCellKind::Date => match row.try_get::<chrono::NaiveDate, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Time => match row.try_get::<chrono::NaiveTime, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::DateTime => {
// `try_get_unchecked` (not `try_get`) because sqlx's `NaiveDateTime`
// `Type`/`Compatible` impl matches `ColumnType::Datetime` only, so the
// checked path rejects a TIMESTAMP column even though both share the
// exact same wire format and `Decode` impl. Only the type-compat gate
// is bypassed here; the decode is the same one DATETIME uses.
match row.try_get_unchecked::<chrono::NaiveDateTime, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
}
}
MysqlCellKind::Json => row
.try_get::<serde_json::Value, _>(i)
.unwrap_or(serde_json::Value::Null),
MysqlCellKind::Text => mysql_text_to_json(row, i),
MysqlCellKind::Bytes => mysql_bytes_to_json(row, i),
MysqlCellKind::Null => serde_json::Value::Null,
MysqlCellKind::Unknown => mysql_unknown_to_json(row, i),
}
}
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
/// decode for the odd VARBINARY-shaped metadata column.
fn mysql_text_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(v) = row.try_get::<String, _>(i) {
return serde_json::Value::String(v);
}
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
} }
serde_json::Value::Null serde_json::Value::Null
} }
/// Binary/blob columns. Preserves the pre-existing lossy-UTF-8 rendering;
/// changing binary presentation is explicitly out of scope (spec §3).
fn mysql_bytes_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
}
mysql_text_to_json(row, i)
}
/// Conservative fallback for a type we do not recognise — exactly the three
/// decoders that were safe before this change, so an unknown type can never
/// be worse than before (`GEOMETRY` lands here and stays null).
fn mysql_unknown_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(v) = row.try_get::<serde_json::Value, _>(i) {
return v;
}
mysql_text_to_json(row, i)
}
async fn execute_mysql_query_on( async fn execute_mysql_query_on(
conn: &mut sqlx::mysql::MySqlConnection, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
@@ -716,7 +885,33 @@ async fn execute_mysql_raw(
// deliberately omitted because re-running a script would re-execute DML); // deliberately omitted because re-running a script would re-execute DML);
// DML/DDL produce notices; the first error stops the run, prior sets survive. // DML/DDL produce notices; the first error stops the run, prior sets survive.
const MAX_STATEMENTS: usize = 50; /// Hard safety cap for an interactive multi-statement Run. Raised from 50
/// because opening a `.sql` file and pressing Run is now a supported flow, and
/// 50 rejected anything but toy scripts.
pub(crate) const MAX_STATEMENTS: usize = 10_000;
/// Cap for the deliberate bulk operation (Tools → Restore). Far higher than
/// the interactive cap because it is an explicit, confirmed, cancellable
/// operation bounded by the 100 MB file cap rather than by a keystroke.
pub(crate) const MAX_RESTORE_STATEMENTS: usize = 1_000_000;
/// Enforce a statement budget. `bulk` selects the restore wording: the
/// interactive path tells the user where large scripts *should* run, while the
/// bulk path must not point at a tool the user is already using.
pub(crate) fn check_statement_budget(count: usize, cap: usize, bulk: bool) -> Result<(), String> {
if count <= cap {
return Ok(());
}
if bulk {
Err(format!(
"Script has {count} statements, exceeding the {cap} statement limit for a restore"
))
} else {
Err(format!(
"Too many statements: {count} (max {cap}). For large scripts use Tools → Restore."
))
}
}
pub(crate) enum StatementOutcome { pub(crate) enum StatementOutcome {
Set(QueryResult), Set(QueryResult),
@@ -1135,12 +1330,7 @@ pub async fn execute_query_multi(
if statements.is_empty() { if statements.is_empty() {
return Err("Query cannot be empty".to_string()); return Err("Query cannot be empty".to_string());
} }
if statements.len() > MAX_STATEMENTS { check_statement_budget(statements.len(), MAX_STATEMENTS, false)?;
return Err(format!(
"Too many statements: {} (max {MAX_STATEMENTS})",
statements.len()
));
}
let mut result_sets: Vec<QueryResult> = Vec::new(); let mut result_sets: Vec<QueryResult> = Vec::new();
let mut notices: Vec<StatementNotice> = Vec::new(); let mut notices: Vec<StatementNotice> = Vec::new();
@@ -1346,6 +1536,10 @@ pub async fn cancel_query(
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}"))) .map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
} }
Some(crate::cancel::CancelHandle::MySql(m)) => { Some(crate::cancel::CancelHandle::MySql(m)) => {
// Record the request before the KILL lands: the in-process restore
// engine polls this flag between statements, and the killed
// statement's error is the only other signal.
state.cancel_registry.mark_cancelled(&connection_id);
let id = m let id = m
.conn_id .conn_id
.ok_or_else(|| "No active query on this connection".to_string())?; .ok_or_else(|| "No active query on this connection".to_string())?;
@@ -132,3 +132,238 @@ async fn pg_multi_statement_live() {
let _ = handle; let _ = handle;
} }
// ── sqlx type-feature coverage ──────────────────────────────────────
/// Connect to the optional live MySQL test server. Returns `None` when the
/// env vars are absent so the whole suite stays green without a server
/// (matching the existing `#[ignore]`d integration-test convention).
pub(crate) async fn mysql_test_connection() -> Option<sqlx::mysql::MySqlConnection> {
use sqlx::ConnectOptions;
let host = std::env::var("GRIDLINE_TEST_MYSQL_HOST").ok()?;
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.ok()?
.parse()
.ok()?;
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").ok()?;
sqlx::mysql::MySqlConnectOptions::new()
.host(&host)
.port(port)
.username(&user)
.password(&pass)
.database(&db)
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
.disable_statement_logging()
.connect()
.await
.ok()
}
#[tokio::test]
#[ignore]
async fn mysql_decimal_and_date_decode_with_features_enabled() {
use sqlx::Row;
let Some(mut conn) = mysql_test_connection().await else { return };
sqlx::query(
"CREATE TEMPORARY TABLE gl_feature_probe (
c_decimal DECIMAL(20,4), c_date DATE, c_datetime DATETIME(6) )",
)
.execute(&mut conn)
.await
.expect("create temporary table");
sqlx::query(
"INSERT INTO gl_feature_probe VALUES (
12345678901234.5678, '2024-01-15', '2024-01-15 10:30:00.123456' )",
)
.execute(&mut conn)
.await
.expect("insert");
let row = sqlx::query("SELECT * FROM gl_feature_probe")
.fetch_one(&mut conn)
.await
.expect("select");
let dec: sqlx::types::BigDecimal = row.try_get(0).expect("DECIMAL must decode");
assert_eq!(dec.to_string(), "12345678901234.5678");
let date: chrono::NaiveDate = row.try_get(1).expect("DATE must decode");
assert_eq!(date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
let dt: chrono::NaiveDateTime = row.try_get(2).expect("DATETIME(6) must decode");
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
}
// ── mysql_cell_kind (pure) ──────────────────────────────────────────
#[test]
fn mysql_cell_kind_maps_every_type_name_sqlx_can_report() {
use MysqlCellKind::*;
// Every string here was observed from a live MySQL 8 server during the
// architect phase (ColumnType::name(flags, max_size)).
let cases: &[(&str, MysqlCellKind)] = &[
("BOOLEAN", Int { bits: 8, signed: true }),
("TINYINT", Int { bits: 8, signed: true }),
("TINYINT UNSIGNED", Int { bits: 8, signed: false }),
("SMALLINT", Int { bits: 16, signed: true }),
("SMALLINT UNSIGNED", Int { bits: 16, signed: false }),
("MEDIUMINT", Int { bits: 24, signed: true }),
("MEDIUMINT UNSIGNED", Int { bits: 24, signed: false }),
("INT", Int { bits: 32, signed: true }),
("INT UNSIGNED", Int { bits: 32, signed: false }),
("BIGINT", Int { bits: 64, signed: true }),
("BIGINT UNSIGNED", Int { bits: 64, signed: false }),
("YEAR", Int { bits: 16, signed: false }),
("BIT", Bit),
("FLOAT", Float),
("DOUBLE", Double),
("DECIMAL", Decimal),
("DATE", Date),
("TIME", Time),
("DATETIME", DateTime),
("TIMESTAMP", DateTime),
("JSON", Json),
("CHAR", Text),
("VARCHAR", Text),
("TEXT", Text),
("TINYTEXT", Text),
("MEDIUMTEXT", Text),
("LONGTEXT", Text),
("ENUM", Text),
("SET", Text),
("BINARY", Bytes),
("VARBINARY", Bytes),
("BLOB", Bytes),
("TINYBLOB", Bytes),
("MEDIUMBLOB", Bytes),
("LONGBLOB", Bytes),
("NULL", Null),
("GEOMETRY", Unknown),
("SOMETHING_NEW", Unknown),
];
for (name, expected) in cases {
assert_eq!(&mysql_cell_kind(name), expected, "type name {name}");
}
}
#[test]
fn mysql_cell_kind_is_case_insensitive_and_trims() {
assert_eq!(mysql_cell_kind(" bigint unsigned "), MysqlCellKind::Int { bits: 64, signed: false });
assert_eq!(mysql_cell_kind("Datetime"), MysqlCellKind::DateTime);
}
// ── mysql_cell_to_json: full type coverage (regression for #41) ─────
#[tokio::test]
#[ignore]
async fn mysql_cell_to_json_decodes_every_type() {
use sqlx::Row;
let Some(mut conn) = mysql_test_connection().await else { return };
sqlx::query(
"CREATE TEMPORARY TABLE gl_decode_probe (
c_tiny TINYINT, c_tiny_u TINYINT UNSIGNED, c_bool BOOLEAN,
c_small SMALLINT, c_small_u SMALLINT UNSIGNED, c_medium MEDIUMINT,
c_int INT, c_int_u INT UNSIGNED, c_big BIGINT, c_big_u BIGINT UNSIGNED,
c_float FLOAT, c_double DOUBLE, c_decimal DECIMAL(10,2), c_bit BIT(8),
c_year YEAR, c_date DATE, c_time TIME, c_datetime DATETIME,
c_datetime6 DATETIME(6), c_timestamp TIMESTAMP NULL,
c_char CHAR(4), c_varchar VARCHAR(20), c_text TEXT,
c_binary BINARY(4), c_varbinary VARBINARY(8), c_blob BLOB,
c_json JSON, c_enum ENUM('a','b'), c_set SET('x','y'),
c_geometry GEOMETRY )",
)
.execute(&mut conn)
.await
.expect("create temporary table");
// c_int is 70000 so a 16-bit rung cannot hold it; c_big is small so a
// value-dependent chain would wrongly emit a number instead of a string.
sqlx::query(
"INSERT INTO gl_decode_probe VALUES (
1, 2, 1, 5, 6, 7, 70000, 9, 300, 4294967296,
11.5, 12.25, 13.75, b'10101010', 2024,
'2024-01-15', '10:30:00', '2024-01-15 10:30:00',
'2024-01-15 10:30:00.123456', '2024-01-15 10:30:00',
'abcd', 'hello', 'text value', 'WXYZ', 0x0102, 0x01020304,
JSON_OBJECT('k','v'), 'a', 'x,y', ST_GeomFromText('POINT(1 2)') )",
)
.execute(&mut conn)
.await
.expect("insert");
let row = sqlx::query("SELECT * FROM gl_decode_probe")
.fetch_one(&mut conn)
.await
.expect("select");
use serde_json::{json, Value};
let expected: Vec<(&str, Value)> = vec![
("c_tiny", json!(1)),
("c_tiny_u", json!(2)),
("c_bool", json!(1)),
("c_small", json!(5)),
("c_small_u", json!(6)),
("c_medium", json!(7)),
("c_int", json!(70000)),
("c_int_u", json!(9)),
("c_big", Value::String("300".into())),
("c_big_u", Value::String("4294967296".into())),
("c_float", json!(11.5)),
("c_double", json!(12.25)),
("c_decimal", Value::String("13.75".into())),
("c_bit", json!(170)),
("c_year", json!(2024)),
("c_date", Value::String("2024-01-15".into())),
("c_time", Value::String("10:30:00".into())),
("c_datetime", Value::String("2024-01-15 10:30:00".into())),
("c_datetime6", Value::String("2024-01-15 10:30:00.123456".into())),
("c_timestamp", Value::String("2024-01-15 10:30:00".into())),
("c_char", Value::String("abcd".into())),
("c_varchar", Value::String("hello".into())),
("c_text", Value::String("text value".into())),
("c_binary", Value::String("WXYZ".into())),
("c_varbinary", Value::String("\u{1}\u{2}".into())),
("c_blob", Value::String("\u{1}\u{2}\u{3}\u{4}".into())),
("c_json", json!({"k": "v"})),
("c_enum", Value::String("a".into())),
("c_set", Value::String("x,y".into())),
// GEOMETRY has no sqlx decoder even with chrono/bigdecimal enabled;
// staying null is the documented limitation (spec §9).
("c_geometry", Value::Null),
];
assert_eq!(row.len(), expected.len(), "column count drifted from the fixture");
for (i, (name, want)) in expected.iter().enumerate() {
assert_eq!(row.columns()[i].name(), *name, "column order drifted");
assert_eq!(&mysql_cell_to_json(&row, i), want, "column {name} decoded wrong");
}
}
// ── statement-count policy ──────────────────────────────────────────
#[test]
fn statement_budget_allows_a_count_at_the_cap() {
assert!(check_statement_budget(MAX_STATEMENTS, MAX_STATEMENTS, false).is_ok());
assert!(check_statement_budget(MAX_RESTORE_STATEMENTS, MAX_RESTORE_STATEMENTS, true).is_ok());
}
#[test]
fn interactive_over_budget_error_points_at_restore() {
let err = check_statement_budget(MAX_STATEMENTS + 1, MAX_STATEMENTS, false)
.expect_err("must reject");
assert!(err.contains("Tools"), "error must route to Tools → Restore; got {err:?}");
assert!(err.contains(&(MAX_STATEMENTS + 1).to_string()), "error must state the count; got {err:?}");
}
#[test]
fn bulk_over_budget_error_states_the_limit_without_the_tool_hint() {
let err = check_statement_budget(MAX_RESTORE_STATEMENTS + 1, MAX_RESTORE_STATEMENTS, true)
.expect_err("must reject");
assert!(!err.contains("Tools"), "bulk path is already in Tools; got {err:?}");
assert!(err.contains(&MAX_RESTORE_STATEMENTS.to_string()), "got {err:?}");
}
+2
View File
@@ -1,10 +1,12 @@
pub mod introspection; pub mod introspection;
pub mod mysql; pub mod mysql;
pub mod mysql_clean;
pub mod sql_split; pub mod sql_split;
pub mod object_crud; pub mod object_crud;
pub mod object_ddl; pub mod object_ddl;
pub mod pool; pub mod pool;
pub mod tls; pub mod tls;
pub mod tool_resolver;
pub mod schema_diff; pub mod schema_diff;
#[allow(unused_imports)] #[allow(unused_imports)]
+220
View File
@@ -0,0 +1,220 @@
//! Planning for a MySQL "clean" restore.
//!
//! `clean` drops only the objects the dump recreates, matching the UI label
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
//! the target database that the dump does not mention survive. It is
//! deliberately *not* `DROP DATABASE` (spec §4.3).
//!
//! Pure by design: takes already-split statements, returns the DROP script.
use crate::db::mysql::mysql_quote_ident;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObjectKind {
Table,
View,
Procedure,
Function,
Trigger,
Event,
}
impl ObjectKind {
fn keyword(self) -> &'static str {
match self {
ObjectKind::Table => "TABLE",
ObjectKind::View => "VIEW",
ObjectKind::Procedure => "PROCEDURE",
ObjectKind::Function => "FUNCTION",
ObjectKind::Trigger => "TRIGGER",
ObjectKind::Event => "EVENT",
}
}
/// Dependents first, tables last, so the drop phase reads predictably.
/// Foreign-key ordering is handled by the caller disabling
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
fn order(self) -> u8 {
match self {
ObjectKind::Trigger => 0,
ObjectKind::Event => 1,
ObjectKind::Procedure => 2,
ObjectKind::Function => 3,
ObjectKind::View => 4,
ObjectKind::Table => 5,
}
}
}
/// Tokens that may appear between the object keyword and its name.
const MODIFIERS: &[&str] = &[
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
"=",
];
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
/// Returns an empty vector when no object can be identified (fail closed).
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
let mut found: Vec<(ObjectKind, String)> = Vec::new();
for stmt in statements {
if let Some((kind, name)) = parse_create_target(stmt) {
let qualified = qualify(default_schema, &name);
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
found.push((kind, qualified));
}
}
}
found.sort_by_key(|(k, _)| k.order());
found
.into_iter()
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
.collect()
}
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
let tokens = unwrap_version_comments(stmt);
let tokens = tokenize(&tokens);
let mut it = tokens.iter().peekable();
while let Some(tok) = it.next() {
if !tok.eq_ignore_ascii_case("CREATE") {
continue;
}
// Walk forward until an object keyword is found.
while let Some(t) = it.next() {
let upper = t.to_ascii_uppercase();
let kind = match upper.as_str() {
"TABLE" => ObjectKind::Table,
"VIEW" => ObjectKind::View,
"PROCEDURE" => ObjectKind::Procedure,
"FUNCTION" => ObjectKind::Function,
"TRIGGER" => ObjectKind::Trigger,
"EVENT" => ObjectKind::Event,
_ => continue,
};
// The first following token that is neither a modifier nor an
// `x=y` clause is the object name.
for cand in it.by_ref() {
let up = cand.to_ascii_uppercase();
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
continue;
}
// A routine's name is followed by its argument list with no
// space (`p`()`, hence a single token); strip that trailing
// `()` so the DROP names the routine, not its signature. This
// must be a *string* suffix — `trim_end_matches('(')` (a char
// pattern) strips nothing from `p`()`.
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
if name.is_empty() {
return None;
}
// Fail closed: a token that is not a plausible identifier means
// we cannot name the object, so plan no DROP rather than emit a
// bogus one. `CREATE TABLE (id int)` tokenizes as
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
// definition fragment, not a table name.
if !is_plausible_identifier(&name) {
return None;
}
return Some((kind, name));
}
return None;
}
}
None
}
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
/// outside a backtick-quoted region means the token is part of an argument
/// list, a column definition, or a statement terminator — not an identifier.
fn is_plausible_identifier(cand: &str) -> bool {
if cand.is_empty() {
return false;
}
let mut in_backtick = false;
for ch in cand.chars() {
match ch {
'`' => in_backtick = !in_backtick,
'(' | ')' | ',' | ';' if !in_backtick => return false,
_ => {}
}
}
true
}
/// Replace `/*!50003 … */` executable comments with their inner text — the
/// shape mysqldump uses to wrap routine and trigger definitions.
fn unwrap_version_comments(stmt: &str) -> String {
let mut out = String::with_capacity(stmt.len());
let chars: Vec<char> = stmt.chars().collect();
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
// skip `/*!` and any leading digits
let mut j = i + 3;
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
out.push(' ');
while j < chars.len()
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
{
out.push(chars[j]);
j += 1;
}
out.push(' ');
i = (j + 2).min(chars.len());
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
/// Split on whitespace, treating backtick-quoted regions as part of one token.
fn tokenize(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
let mut in_backtick = false;
for ch in s.chars() {
match ch {
'`' => {
in_backtick = !in_backtick;
cur.push(ch);
}
c if c.is_whitespace() && !in_backtick => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
fn qualify(default_schema: &str, name: &str) -> String {
let parts: Vec<String> = name
.split('.')
.map(|p| mysql_quote_ident(strip_backticks(p)))
.collect();
if parts.len() == 1 {
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
} else {
parts.join(".")
}
}
fn strip_backticks(s: &str) -> &str {
s.trim_matches('`')
}
#[cfg(test)]
#[path = "mysql_clean.test.rs"]
mod mysql_clean_tests;
@@ -0,0 +1,78 @@
use super::*;
fn plan(sql: &str) -> Vec<String> {
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
}
#[test]
fn plans_a_drop_for_each_recreated_table() {
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
assert_eq!(
got,
vec![
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
]
);
}
#[test]
fn qualifies_unqualified_names_with_the_target_schema() {
assert_eq!(
plan("CREATE TABLE orders (id int);"),
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
);
}
#[test]
fn keeps_an_explicit_schema_qualifier() {
assert_eq!(
plan("CREATE TABLE `other`.`orders` (id int);"),
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
);
}
#[test]
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
// This is the shape mysqldump actually emits for routines/triggers.
let got = plan(
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
);
assert_eq!(
got,
vec![
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
]
);
}
#[test]
fn deduplicates_repeated_objects() {
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
}
#[test]
fn orders_dependents_before_tables() {
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
let kinds: Vec<&str> = got
.iter()
.map(|s| s.split_whitespace().nth(1).unwrap())
.collect();
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
}
#[test]
fn ignores_non_create_statements() {
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
assert!(got.is_empty(), "got {got:?}");
}
#[test]
fn skips_a_create_whose_name_cannot_be_determined() {
// Fail closed: if we cannot name the object we must not guess at a DROP.
let got = plan("CREATE TABLE (id int);");
assert!(got.is_empty(), "got {got:?}");
}
+79 -7
View File
@@ -19,9 +19,24 @@ pub fn split_statements(sql: &str) -> Vec<String> {
let mut stmts: Vec<String> = Vec::new(); let mut stmts: Vec<String> = Vec::new();
let mut current = String::new(); let mut current = String::new();
let mut i = 0usize; let mut i = 0usize;
// MySQL's client-side `DELIMITER` directive. Default matches the SQL
// standard terminator so behaviour is unchanged when no directive appears.
let mut delimiter: Vec<char> = vec![';'];
while i < chars.len() { while i < chars.len() {
// A `DELIMITER` line is a client directive, not SQL. Only recognised
// at the start of a line: quoted regions, block comments and line
// comments are copied atomically below, so a directive inside any of
// them can never reach this check.
if i == 0 || chars[i - 1] == '\n' {
if let Some((end, token)) = match_delimiter_directive(&chars, i) {
delimiter = token.chars().collect();
i = end;
continue;
}
}
let c = chars[i]; let c = chars[i];
let at_delimiter = starts_with(&chars, i, &delimiter);
match c { match c {
'\'' | '"' | '`' => { '\'' | '"' | '`' => {
copy_quoted(&chars, &mut i, &mut current, c); copy_quoted(&chars, &mut i, &mut current, c);
@@ -32,6 +47,13 @@ pub fn split_statements(sql: &str) -> Vec<String> {
'/' if peek(&chars, i + 1) == Some('*') => { '/' if peek(&chars, i + 1) == Some('*') => {
copy_block_comment(&chars, &mut i, &mut current); copy_block_comment(&chars, &mut i, &mut current);
} }
_ if at_delimiter => {
if has_executable_text(&current) {
stmts.push(current.trim().to_string());
}
current.clear();
i += delimiter.len();
}
'$' => { '$' => {
if let Some(end) = dollar_quote_end(&chars, i) { if let Some(end) = dollar_quote_end(&chars, i) {
for _ in i..end { for _ in i..end {
@@ -45,13 +67,6 @@ pub fn split_statements(sql: &str) -> Vec<String> {
i += 1; i += 1;
} }
} }
';' => {
if has_executable_text(&current) {
stmts.push(current.trim().to_string());
}
current.clear();
i += 1;
}
_ => { _ => {
current.push(c); current.push(c);
i += 1; i += 1;
@@ -177,6 +192,15 @@ pub fn has_executable_text(stmt: &str) -> bool {
skip_until(&chars, &mut i, |ch| ch == '\n'); skip_until(&chars, &mut i, |ch| ch == '\n');
} }
'/' if peek(&chars, i + 1) == Some('*') => { '/' if peek(&chars, i + 1) == Some('*') => {
// MySQL executable comments (`/*! … */`, optionally
// version-gated like `/*!50003 … */`) are executed by the
// server, so a fragment that is only such a comment is still
// a statement. mysqldump wraps routine and trigger definitions
// in them with no surrounding SQL. Ordinary block comments
// remain skipped.
if peek(&chars, i + 2) == Some('!') {
return true;
}
skip_block_comment(&chars, &mut i); skip_block_comment(&chars, &mut i);
} }
c if c.is_whitespace() => i += 1, c if c.is_whitespace() => i += 1,
@@ -207,6 +231,54 @@ fn skip_block_comment(chars: &[char], i: &mut usize) {
} }
} }
/// True when `chars[i..]` begins with `pat`.
fn starts_with(chars: &[char], i: usize, pat: &[char]) -> bool {
!pat.is_empty() && i + pat.len() <= chars.len() && chars[i..i + pat.len()] == *pat
}
/// Match a MySQL client `DELIMITER <token>` directive that occupies a whole
/// line. Returns the index just past the directive's newline and the new
/// delimiter token. Conservative: anything other than
/// `^[ \t]*DELIMITER[ \t]+<token>[ \t]*$` is left as ordinary text.
fn match_delimiter_directive(chars: &[char], i: usize) -> Option<(usize, String)> {
let mut j = i;
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
j += 1;
}
for kw in ['D', 'E', 'L', 'I', 'M', 'I', 'T', 'E', 'R'] {
if j >= chars.len() || chars[j].to_ascii_uppercase() != kw {
return None;
}
j += 1;
}
if j >= chars.len() || !matches!(chars[j], ' ' | '\t') {
return None; // `DELIMITERx` is not a directive
}
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
j += 1;
}
let start = j;
while j < chars.len() && !matches!(chars[j], ' ' | '\t' | '\r' | '\n') {
j += 1;
}
if start == j {
return None; // no token
}
let token: String = chars[start..j].iter().collect();
// The rest of the line must be blank, otherwise this is not a directive.
let mut k = j;
while k < chars.len() && matches!(chars[k], ' ' | '\t') {
k += 1;
}
if k < chars.len() && !matches!(chars[k], '\r' | '\n') {
return None;
}
while k < chars.len() && matches!(chars[k], '\r' | '\n') {
k += 1;
}
Some((k, token))
}
#[cfg(test)] #[cfg(test)]
#[path = "sql_split.test.rs"] #[path = "sql_split.test.rs"]
mod sql_split_tests; mod sql_split_tests;
@@ -84,3 +84,58 @@ fn empty_and_whitespace_input() {
assert_eq!(split_statements(""), Vec::<String>::new()); assert_eq!(split_statements(""), Vec::<String>::new());
assert_eq!(split_statements(" ; ; "), Vec::<String>::new()); assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
} }
// ── MySQL client DELIMITER directive ────────────────────────────────
//
// NOTE: the splitter strips the active statement terminator, exactly as the
// pre-existing tests above assert (`splits_two_statements` -> ["SELECT 1",
// "SELECT 2"]). The expectations below therefore never contain a trailing
// `;`/delimiter, matching that established contract.
#[test]
fn delimiter_directive_changes_statement_terminator() {
let sql = "DELIMITER //\nCREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND//\nDELIMITER ;\nSELECT 2;";
let stmts = split_statements(sql);
assert_eq!(stmts.len(), 2, "got {stmts:?}");
assert!(stmts[0].starts_with("CREATE PROCEDURE p()"), "got {:?}", stmts[0]);
// The inner `;` must NOT have split the body.
assert!(stmts[0].contains("SELECT 1;"), "body was split: {:?}", stmts[0]);
assert!(!stmts[0].contains("DELIMITER"), "directive leaked into statement: {:?}", stmts[0]);
assert_eq!(stmts[1], "SELECT 2");
}
#[test]
fn delimiter_directive_is_case_and_space_tolerant() {
let sql = " delimiter $$\n SELECT 1$$\n Delimiter ;\nSELECT 2;";
let stmts = split_statements(sql);
assert_eq!(stmts, vec!["SELECT 1".to_string(), "SELECT 2".to_string()]);
}
#[test]
fn delimiter_text_inside_a_string_is_not_a_directive() {
let stmts = split_statements("SELECT 'DELIMITER //';\nSELECT 2;");
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "SELECT 'DELIMITER //'");
}
#[test]
fn delimiter_text_inside_a_line_comment_is_not_a_directive() {
let stmts = split_statements("-- DELIMITER //\nSELECT 1;");
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("SELECT 1"));
}
#[test]
fn delimiter_with_trailing_junk_is_ordinary_text() {
let stmts = split_statements("DELIMITER // extra\nSELECT 1;");
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("DELIMITER // extra"), "got {:?}", stmts[0]);
}
#[test]
fn a_single_semicolon_is_ordinary_text_under_a_custom_delimiter() {
let stmts = split_statements("DELIMITER //\nSELECT 1; SELECT 2//\nDELIMITER ;\nSELECT 3;");
assert_eq!(stmts.len(), 2);
assert!(stmts[0].contains("SELECT 1; SELECT 2"), "got {:?}", stmts[0]);
assert_eq!(stmts[1], "SELECT 3");
}
+415
View File
@@ -0,0 +1,415 @@
//! Unified, headless external-client-tool resolver shared by the PostgreSQL
//! and MySQL backup/restore/sync paths.
//!
//! Fixes issue #44: bundled tools live under `resource_dir/resources/<subdir>/`
//! (Tauri preserves the `resources/` component from `tauri.conf.json`), not
//! directly under `resource_dir/`. System lookup is ordered — trusted absolute
//! directories first, then the inherited PATH, then bundled — and probes both
//! `mariadb-dump`/`mysqldump` style names.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// The nested directory component Tauri injects when copying
/// `bundle.resources`. Pinned by `bundled_prefix_matches_tauri_config_resources`.
pub const BUNDLED_RESOURCE_PREFIX: &str = "resources";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
/// Found in a fixed, trusted absolute directory.
TrustedDir,
/// Found on the inherited `PATH`.
Path,
/// Found in the app's bundled resource directory.
Bundled,
}
impl ToolSource {
/// The value surfaced to the UI (matches the pre-existing
/// `"system" | "bundled"` strings).
pub fn as_str(self) -> &'static str {
match self {
ToolSource::TrustedDir | ToolSource::Path => "system",
ToolSource::Bundled => "bundled",
}
}
}
/// A successfully resolved tool: the runnable program plus its provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub program: String,
pub source: ToolSource,
/// The binary name that resolved (`mariadb-dump`, `mysqldump`, …).
pub name: String,
}
/// One engine + tool role, e.g. the MySQL dump tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolRequest {
/// Resource subdirectory name, e.g. `mysql_tools` / `pg_tools`.
pub subdir: &'static str,
/// Candidate binary names, in preference order.
pub names: Vec<&'static str>,
}
impl ToolRequest {
pub fn new(subdir: &'static str, names: &[&'static str]) -> Self {
Self { subdir, names: names.to_vec() }
}
/// The bundled binary that ships for this role.
pub fn bundled_name(&self) -> &'static str {
self.names[0]
}
/// Stable cache key for this role.
pub fn cache_key(&self) -> String {
format!("{}::{}", self.subdir, self.names.join(","))
}
}
/// Platform executable suffix.
pub fn exe_suffix() -> &'static str {
if cfg!(windows) { ".exe" } else { "" }
}
/// `pg_dump` -> `pg_dump` (POSIX) / `pg_dump.exe` (Windows).
pub fn bundled_bin_name(bin: &str) -> String {
format!("{bin}{}", exe_suffix())
}
/// Fixed, trusted absolute directories probed before the inherited `PATH`.
/// Windows deliberately returns an empty list (spec §9 Q5): PATH + bundled only.
pub fn trusted_dirs() -> Vec<PathBuf> {
let raw: &[&str] = if cfg!(target_os = "macos") {
&[
"/opt/homebrew/bin",
"/opt/homebrew/opt/mysql-client/bin",
"/opt/homebrew/opt/mariadb/bin",
"/opt/homebrew/opt/libpq/bin",
"/usr/local/bin",
"/usr/local/opt/mysql-client/bin",
"/usr/local/opt/mariadb/bin",
"/usr/local/opt/libpq/bin",
"/opt/local/bin",
"/Applications/Postgres.app/Contents/Versions/latest/bin",
]
} else if cfg!(target_os = "linux") {
&["/usr/bin", "/usr/local/bin", "/usr/local/mysql/bin", "/opt/mysql/bin"]
} else {
&[]
};
raw.iter().map(PathBuf::from).collect()
}
pub fn pg_dump_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_dump"]) }
pub fn pg_restore_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_restore"]) }
pub fn psql_request() -> ToolRequest { ToolRequest::new("pg_tools", &["psql"]) }
/// Maps a PostgreSQL tool name to its request; `None` for unknown names.
pub fn pg_request(tool: &str) -> Option<ToolRequest> {
match tool {
"pg_dump" => Some(pg_dump_request()),
"pg_restore" => Some(pg_restore_request()),
"psql" => Some(psql_request()),
_ => None,
}
}
/// MySQL dump role: system `mariadb-dump` -> system `mysqldump` -> bundled.
pub fn mysql_dump_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb-dump", "mysqldump"])
}
/// MySQL client role (used by sync as the restore target).
pub fn mysql_client_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb", "mysql"])
}
/// Maps a MySQL tool name to its request; `None` for unknown names.
pub fn mysql_request(tool: &str) -> Option<ToolRequest> {
match tool {
"mariadb-dump" | "mysqldump" => Some(mysql_dump_request()),
"mariadb" | "mysql" => Some(mysql_client_request()),
_ => None,
}
}
/// The bundled binary path for a role. Note the nested
/// `BUNDLED_RESOURCE_PREFIX` component — this is the issue #44 fix.
pub fn bundled_tool_path(resource_dir: &Path, subdir: &str, bin: &str) -> PathBuf {
resource_dir
.join(BUNDLED_RESOURCE_PREFIX)
.join(subdir)
.join(bundled_bin_name(bin))
}
/// Locate `name` on a `PATH`-shaped value without invoking a shell.
pub fn path_lookup_in(name: &str, path_var: &OsStr) -> Option<PathBuf> {
let file = bundled_bin_name(name);
for dir in std::env::split_paths(path_var) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
/// Locate `name` on the inherited `PATH`.
pub fn path_lookup(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
path_lookup_in(name, &path_var)
}
/// Per-probe timeout. `--version` returns in milliseconds; this only guards a
/// pathological binary.
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
/// Aggregate deadline for one detection pass across all candidates.
pub const AGGREGATE_DEADLINE: Duration = Duration::from_secs(3);
/// Run `<program> --version` with a bounded wait. The child's stdio is
/// discarded — output is read separately by `version_of`.
pub fn run_version(program: &Path, timeout: Duration) -> bool {
let mut cmd = Command::new(program);
cmd.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else {
return false;
};
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => {
let _ = child.kill();
return false;
}
}
}
}
/// A tool candidate counts only when it is a regular file AND runs.
pub fn probe(program: &Path) -> bool {
program.is_file() && run_version(program, PROBE_TIMEOUT)
}
/// A probe that stops running anything once the aggregate deadline is crossed.
pub fn probe_within(start: Instant, deadline: Duration, program: &Path) -> bool {
if start.elapsed() >= deadline {
return false;
}
probe(program)
}
/// Ordered, injectable resolution.
///
/// 1. every trusted absolute directory, for each candidate name in order;
/// 2. the inherited `PATH`, for each candidate name in order;
/// 3. the bundled binary.
///
/// `probe_fn` decides whether a candidate is runnable, so tests never touch
/// the real filesystem. The aggregate deadline is enforced by the caller's
/// `probe_fn` (see `probe_within`).
pub fn resolve_with<F>(
request: &ToolRequest,
trusted: &[PathBuf],
path_lookup: &dyn Fn(&str) -> Option<PathBuf>,
bundled: Option<&Path>,
mut probe_fn: F,
) -> Option<Resolution>
where
F: FnMut(&Path) -> bool,
{
for dir in trusted {
for name in &request.names {
let candidate = dir.join(bundled_bin_name(name));
if probe_fn(&candidate) {
return Some(Resolution {
program: candidate.to_string_lossy().to_string(),
source: ToolSource::TrustedDir,
name: (*name).to_string(),
});
}
}
}
for name in &request.names {
if let Some(found) = path_lookup(name) {
if probe_fn(&found) {
return Some(Resolution {
program: found.to_string_lossy().to_string(),
source: ToolSource::Path,
name: (*name).to_string(),
});
}
}
}
if let Some(bundled) = bundled {
if probe_fn(bundled) {
let name = bundled
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| request.bundled_name().to_string());
return Some(Resolution {
program: bundled.to_string_lossy().to_string(),
source: ToolSource::Bundled,
name,
});
}
}
None
}
/// Read the first non-empty line of `<program> --version` (stdout, else stderr).
pub fn version_of(program: &str) -> Option<String> {
let out = Command::new(program).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
return Some(stdout);
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if stderr.is_empty() { None } else { Some(stderr) }
}
/// Lenient major-version extraction. Scans for numeric groups, unwraps the
/// legacy MariaDB `5.5.5-<real>-MariaDB` compatibility prefix, and returns
/// `None` when there is no number at all.
pub fn parse_major(version: &str) -> Option<u32> {
let mut nums: Vec<u32> = Vec::new();
let mut cur = String::new();
for ch in version.chars() {
if ch.is_ascii_digit() {
cur.push(ch);
continue;
}
if !cur.is_empty() {
nums.push(cur.parse::<u32>().ok()?);
cur.clear();
// `6` is the only magic number here: it caps how many groups an
// adversarial/advertising version string can make us collect
// (we only ever read the first four).
if nums.len() == 6 {
break;
}
}
}
if !cur.is_empty() {
if let Ok(n) = cur.parse::<u32>() {
nums.push(n);
}
}
if nums.is_empty() {
return None;
}
if nums.len() >= 4 && nums[0] == 5 && nums[1] == 5 && nums[2] == 5 {
return Some(nums[3]);
}
Some(nums[0])
}
/// Client/server version relationship. Advisory only — never an error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compat {
Ok,
/// The client's major version is below the server's.
ClientOlder,
/// Either version could not be parsed.
Unknown,
}
pub fn compat(client: Option<u32>, server: Option<u32>) -> Compat {
match (client, server) {
(Some(c), Some(s)) if s > c => Compat::ClientOlder,
(Some(_), Some(_)) => Compat::Ok,
_ => Compat::Unknown,
}
}
/// Non-blocking warning text, or `None` when there is nothing to say.
pub fn warning_for(client_version: &str, server_version: &str, client_name: &str) -> Option<String> {
match compat(parse_major(client_version), parse_major(server_version)) {
Compat::ClientOlder => Some(format!(
"Client/server version mismatch: the resolved dump tool `{client_name}` reports {client_version}, \
but the server is {server_version}. The backup may fail or omit data — installing a matching MySQL client is recommended."
)),
Compat::Ok | Compat::Unknown => None,
}
}
/// Everything one detection pass knows about a tool role.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolResolution {
pub resolved: Option<Resolution>,
pub bundled_path: Option<PathBuf>,
/// Whether the bundled file exists — regardless of whether it runs. Lets
/// the UI say "ships with the app but could not run" instead of "absent".
pub bundled_available: bool,
pub version: Option<String>,
}
impl ToolResolution {
pub fn none() -> Self {
Self { resolved: None, bundled_path: None, bundled_available: false, version: None }
}
}
/// Whether a bundled tool file exists at `path`.
pub fn bundled_present(path: &Path) -> bool {
path.is_file()
}
/// Shape the existing resolver call sites expect: `(command, source)`.
pub fn adapt_resolution(res: &ToolResolution, fallback_name: &str) -> (String, Option<String>) {
match &res.resolved {
Some(r) => (r.program.clone(), Some(r.source.as_str().to_string())),
None => (fallback_name.to_string(), None),
}
}
// Cache is keyed by `ToolRequest::cache_key()` and holds no secrets.
static CACHE: OnceLock<Mutex<HashMap<String, ToolResolution>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, ToolResolution>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn cache_get(key: &str) -> Option<ToolResolution> {
cache().lock().ok()?.get(key).cloned()
}
pub fn cache_put(key: &str, value: ToolResolution) {
if let Ok(mut map) = cache().lock() {
map.insert(key.to_string(), value);
}
}
pub fn cache_clear() {
if let Ok(mut map) = cache().lock() {
map.clear();
}
}
#[cfg(test)]
#[path = "tool_resolver.test.rs"]
mod tests;
@@ -0,0 +1,403 @@
use super::*;
#[test]
fn bundled_bin_name_appends_exe_only_on_windows() {
let expected = if cfg!(windows) { "pg_dump.exe" } else { "pg_dump" };
assert_eq!(bundled_bin_name("pg_dump"), expected);
}
#[test]
fn pg_request_maps_known_tool_names() {
assert_eq!(pg_request("pg_dump").unwrap(), ToolRequest::new("pg_tools", &["pg_dump"]));
assert_eq!(pg_request("pg_restore").unwrap(), ToolRequest::new("pg_tools", &["pg_restore"]));
assert_eq!(pg_request("psql").unwrap(), ToolRequest::new("pg_tools", &["psql"]));
assert!(pg_request("not_a_tool").is_none());
}
#[test]
fn mysql_dump_request_prefers_mariadb_dump_then_mysqldump() {
let req = mysql_dump_request();
assert_eq!(req.subdir, "mysql_tools");
assert_eq!(req.names, vec!["mariadb-dump", "mysqldump"]);
assert_eq!(req.bundled_name(), "mariadb-dump");
}
#[test]
fn mysql_client_request_prefers_mariadb_then_mysql() {
let req = mysql_client_request();
assert_eq!(req.names, vec!["mariadb", "mysql"]);
assert_eq!(req.bundled_name(), "mariadb");
}
#[test]
fn cache_key_is_stable_and_name_ordered() {
assert_eq!(mysql_dump_request().cache_key(), "mysql_tools::mariadb-dump,mysqldump");
assert_eq!(pg_dump_request().cache_key(), "pg_tools::pg_dump");
}
#[test]
fn tool_source_maps_to_ui_string() {
assert_eq!(ToolSource::TrustedDir.as_str(), "system");
assert_eq!(ToolSource::Path.as_str(), "system");
assert_eq!(ToolSource::Bundled.as_str(), "bundled");
}
#[cfg(target_os = "macos")]
#[test]
fn trusted_dirs_include_homebrew_and_postgres_app_on_macos() {
let dirs = trusted_dirs();
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/opt/mysql-client/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/usr/local/opt/libpq/bin")), "{dirs:?}");
assert!(dirs.iter().any(|d| d.ends_with("Postgres.app/Contents/Versions/latest/bin")), "{dirs:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn trusted_dirs_include_usr_bin_on_linux() {
assert!(trusted_dirs().contains(&PathBuf::from("/usr/bin")));
}
#[cfg(windows)]
#[test]
fn trusted_dirs_are_empty_on_windows() {
assert!(trusted_dirs().is_empty());
}
#[test]
fn bundled_tool_path_includes_the_nested_resources_prefix() {
// THE BUG FROM ISSUE #44: the shipped app stores the tool at
// <resource_dir>/resources/mysql_tools/mariadb-dump, never at
// <resource_dir>/mysql_tools/mariadb-dump.
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "mysql_tools", "mariadb-dump");
assert_eq!(
p,
PathBuf::from("/app/Contents/Resources/resources/mysql_tools").join(bundled_bin_name("mariadb-dump"))
);
assert!(p.to_string_lossy().ends_with("resources/mysql_tools/mariadb-dump"));
}
#[test]
fn bundled_tool_path_works_for_postgres_tools_too() {
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "pg_tools", "pg_dump");
assert!(p.to_string_lossy().ends_with("resources/pg_tools/pg_dump"));
}
#[test]
fn bundled_prefix_matches_tauri_config_resources() {
let conf = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json"))
.expect("tauri.conf.json must be readable");
let json: serde_json::Value = serde_json::from_str(&conf).unwrap();
let resources = json["bundle"]["resources"]
.as_array()
.expect("bundle.resources must be an array");
assert!(!resources.is_empty(), "bundle.resources must not be empty");
for entry in resources {
let glob = entry.as_str().unwrap();
let top = Path::new(glob).components().next().unwrap();
assert_eq!(
top.as_os_str().to_str().unwrap(),
BUNDLED_RESOURCE_PREFIX,
"resource glob `{glob}` must start with `{BUNDLED_RESOURCE_PREFIX}/`"
);
}
}
#[test]
fn path_lookup_finds_an_executable_by_name() {
let dir = unique_dir();
let exe = dir.join(bundled_bin_name("mysqldump"));
std::fs::write(&exe, b"").unwrap();
let path_var = std::env::join_paths([&dir]).unwrap();
assert_eq!(path_lookup_in("mysqldump", &path_var), Some(exe));
}
#[test]
fn path_lookup_skips_empty_entries_and_missing_names() {
let dir = unique_dir();
let path_var = std::env::join_paths([PathBuf::from(""), dir.clone()]).unwrap();
assert_eq!(path_lookup_in("definitely-absent-tool", &path_var), None);
}
#[test]
fn probe_rejects_a_missing_file_and_a_directory() {
let dir = unique_dir();
assert!(!probe(&dir.join("absent-tool")));
assert!(!probe(&dir), "a directory must never be reported as a runnable tool");
}
#[cfg(unix)]
#[test]
fn run_version_is_true_for_zero_exit_and_false_for_nonzero() {
let ok = unique_dir().join("ok_tool");
write_exe(&ok, "#!/bin/sh\nexit 0\n");
assert!(run_version(&ok, Duration::from_secs(1)));
let bad = unique_dir().join("bad_tool");
write_exe(&bad, "#!/bin/sh\nexit 3\n");
assert!(!run_version(&bad, Duration::from_secs(1)));
}
#[cfg(unix)]
#[test]
fn run_version_kills_a_hung_process_at_the_timeout() {
let slow = unique_dir().join("slow_tool");
write_exe(&slow, "#!/bin/sh\nsleep 5\n");
assert!(!run_version(&slow, Duration::from_millis(50)));
}
#[cfg(unix)]
#[test]
fn probe_accepts_a_runnable_script() {
let tool = unique_dir().join(bundled_bin_name("mariadb-dump"));
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\nexit 0\n");
assert!(probe(&tool));
}
#[cfg(unix)]
#[test]
fn version_of_reads_the_version_from_stdout() {
let tool = unique_dir().join("versioned_tool");
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\n");
let v = version_of(tool.to_str().unwrap()).unwrap();
assert!(v.contains("11.4.5-MariaDB"), "got {v}");
}
#[cfg(unix)]
#[test]
fn probe_within_returns_false_once_the_aggregate_deadline_passed() {
let tool = unique_dir().join("ok_tool");
write_exe(&tool, "#!/bin/sh\nexit 0\n");
let past = Instant::now() - Duration::from_secs(10);
assert!(!probe_within(past, Duration::from_secs(3), &tool));
assert!(probe_within(Instant::now(), Duration::from_secs(3), &tool));
}
#[test]
fn resolve_prefers_a_trusted_dir_and_the_first_candidate_name() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, |_p: &Path| true).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, format!("/trusted/{}", bundled_bin_name("mariadb-dump")));
}
#[test]
fn resolve_falls_through_to_the_inherited_path() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |name: &str| -> Option<PathBuf> {
if name == "mysqldump" { Some(PathBuf::from("/usr/bin/mysqldump")) } else { None }
};
let probe = |p: &Path| p == Path::new("/usr/bin/mysqldump");
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.source, ToolSource::Path);
assert_eq!(r.name, "mysqldump");
assert_eq!(r.program, "/usr/bin/mysqldump");
}
#[test]
fn resolve_prefers_any_system_tool_over_the_bundled_one() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let probe = |p: &Path| p != bundled; // only the trusted candidate runs
let r = resolve_with(&req, &trusted, &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_uses_the_bundled_binary_when_no_system_tool_runs() {
let req = mysql_dump_request();
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let probe = |p: &Path| p == bundled;
let r = resolve_with(&req, &[], &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::Bundled);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, "/rd/resources/mysql_tools/mariadb-dump");
}
#[test]
fn resolve_skips_a_candidate_that_fails_the_probe() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let target = PathBuf::from("/trusted").join(bundled_bin_name("mysqldump"));
let probe = move |p: &Path| p == target.as_path();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.name, "mysqldump");
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_returns_none_when_nothing_runs() {
let req = mysql_dump_request();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &[PathBuf::from("/trusted")], &path_lookup, Some(Path::new("/rd/x")), |_p: &Path| false);
assert!(r.is_none());
}
#[cfg(unix)]
fn write_exe(path: &Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, body).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn unique_dir() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir()
.join(format!("gridline_tool_resolver_{}_{}", std::process::id(), nanos));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_major_handles_common_version_strings() {
assert_eq!(parse_major("8.0.36"), Some(8));
assert_eq!(parse_major("8.4.0"), Some(8));
assert_eq!(parse_major("11.4.5-MariaDB"), Some(11));
assert_eq!(parse_major("5.7.44-log"), Some(5));
assert_eq!(parse_major("26.7.0"), Some(26));
assert_eq!(parse_major("mariadb-dump from 11.4.5-MariaDB, client 10.19"), Some(11));
}
#[test]
fn parse_major_unwraps_the_legacy_mariadb_compat_prefix() {
// MariaDB historically reported 5.5.5-<real version>-MariaDB for compat
// with MySQL-era clients; the real major is the 4th group.
assert_eq!(parse_major("5.5.5-10.4.11-MariaDB"), Some(10));
assert_eq!(parse_major("5.5.5-10.11.6-MariaDB-log"), Some(10));
// A genuine 5.5.5 has only three groups and must stay 5.
assert_eq!(parse_major("5.5.5"), Some(5));
}
#[test]
fn parse_major_returns_none_for_garbage() {
assert_eq!(parse_major("no digits here"), None);
assert_eq!(parse_major(""), None);
}
#[test]
fn compat_flags_only_a_clearly_older_client() {
assert_eq!(compat(Some(8), Some(26)), Compat::ClientOlder);
assert_eq!(compat(Some(11), Some(8)), Compat::Ok);
assert_eq!(compat(Some(8), Some(8)), Compat::Ok);
assert_eq!(compat(None, Some(8)), Compat::Unknown);
assert_eq!(compat(Some(8), None), Compat::Unknown);
}
#[test]
fn warning_for_is_silent_unless_the_client_is_older() {
assert!(warning_for("mariadb-dump 11.4.5-MariaDB", "8.0.36", "mariadb-dump").is_none());
assert!(warning_for("unknown", "8.0.36", "mariadb-dump").is_none());
let w = warning_for("mariadb-dump 11.4.5-MariaDB", "26.7.0", "mariadb-dump").unwrap();
assert!(w.contains("11.4.5-MariaDB"), "{w}");
assert!(w.contains("26.7.0"), "{w}");
assert!(w.contains("mariadb-dump"), "{w}");
// The `\` continuation in the format literal must not leave stray leading
// indentation, so assert the exact single-line render (no newline, no run
// of consecutive spaces).
assert!(!w.contains('\n'), "warning must be one line: {w:?}");
assert!(!w.contains(" "), "warning must not contain double spaces: {w:?}");
assert_eq!(
w,
concat!(
"Client/server version mismatch: the resolved dump tool `mariadb-dump` reports ",
"mariadb-dump 11.4.5-MariaDB, but the server is 26.7.0. ",
"The backup may fail or omit data — installing a matching MySQL client is recommended."
)
);
}
#[test]
fn parse_major_never_panics_on_hostile_input() {
// A digit group that overflows u32 yields None, never a panic.
assert_eq!(parse_major("99999999999999999999.1.1"), None);
assert_eq!(parse_major(&"9".repeat(4096)), None);
// Non-ASCII digits, unicode and control characters are simply skipped.
assert_eq!(parse_major("\u{fc}nicode ٨.٠.٣٦"), None);
assert_eq!(parse_major("\u{1F600}"), None);
assert_eq!(parse_major("\u{0}\u{1}\u{2}"), None);
assert_eq!(parse_major("....."), None);
// Long adversarial input: the group collector is capped at six, and only
// the first group decides the major.
assert_eq!(parse_major(&"1.2.3.4.".repeat(1000)), Some(1));
// An overflowing *trailing* group is skipped by the final flush, so a
// genuine-looking `5.5.5` prefix still yields 5 rather than panicking.
// Harmless: no real tool prints such a version, and the result is advisory.
assert_eq!(parse_major("5.5.5-99999999999999999999"), Some(5));
}
#[test]
fn adapt_resolution_returns_program_and_source() {
let res = ToolResolution {
resolved: Some(Resolution {
program: "/x/mariadb-dump".into(),
source: ToolSource::Bundled,
name: "mariadb-dump".into(),
}),
bundled_path: Some(PathBuf::from("/x/mariadb-dump")),
bundled_available: true,
version: Some("11.4.5".into()),
};
assert_eq!(
adapt_resolution(&res, "mariadb-dump"),
("/x/mariadb-dump".to_string(), Some("bundled".to_string()))
);
}
#[test]
fn adapt_resolution_falls_back_to_the_requested_name() {
assert_eq!(
adapt_resolution(&ToolResolution::none(), "pg_dump"),
("pg_dump".to_string(), None)
);
}
#[test]
fn cache_round_trips_and_clears() {
cache_clear();
let res = ToolResolution {
resolved: Some(Resolution {
program: "/c/tool".into(),
source: ToolSource::Path,
name: "tool".into(),
}),
bundled_path: None,
bundled_available: false,
version: None,
};
assert!(cache_get("k_task6_unique").is_none());
cache_put("k_task6_unique", res.clone());
assert_eq!(cache_get("k_task6_unique"), Some(res));
cache_clear();
assert!(cache_get("k_task6_unique").is_none());
}
#[test]
fn bundled_present_is_true_only_for_a_regular_file() {
let dir = unique_dir();
assert!(!bundled_present(&dir), "a directory is not a present bundled tool");
let file = dir.join("mariadb-dump");
std::fs::write(&file, b"x").unwrap();
assert!(bundled_present(&file));
}
#[test]
fn mysql_request_maps_known_tool_names() {
assert_eq!(mysql_request("mariadb-dump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mysqldump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mariadb").unwrap(), mysql_client_request());
assert_eq!(mysql_request("mysql").unwrap(), mysql_client_request());
assert!(mysql_request("mysqladmin").is_none());
}
+55 -7
View File
@@ -13,7 +13,7 @@ use commands::ssh::{Ssh2Backend, SshTunnelManager};
use db::pool::ConnectionPoolManager; use db::pool::ConnectionPoolManager;
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use store::Store; use store::Store;
use tauri::Manager; use tauri::{Emitter, Manager};
pub struct AppState { pub struct AppState {
pub db_store: StdMutex<Store>, pub db_store: StdMutex<Store>,
@@ -39,11 +39,32 @@ pub fn run() {
// another provider is already installed). // another provider is already installed).
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_keyring_store::init()) .plugin(tauri_plugin_keyring_store::init())
// OS "open with" paths are queued here before the webview exists.
.manage(crate::commands::open_files::PendingOpenFiles::default());
// Shadowed, not `mut`: on macOS this cfg block is compiled out, so a
// mutable binding would only earn an `unused_mut` warning. The plugin
// itself stays a Windows/Linux-only, target-scoped dependency.
#[cfg(any(target_os = "windows", target_os = "linux"))]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
let mut accepted = false;
for arg in argv.iter().skip(1) {
if crate::commands::open_files::accept_path(&pending, arg) {
accepted = true;
}
}
if accepted {
let _ = app.emit("sql-file-opened", ());
}
}));
builder
.setup(move |app| { .setup(move |app| {
// Open the local store under the OS app-data directory. When the // Open the local store under the OS app-data directory. When the
// app is launched from Finder/LaunchServices the working directory // app is launched from Finder/LaunchServices the working directory
@@ -59,6 +80,14 @@ pub fn run() {
let store = let store =
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db"); Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
// Cold-start "open with": the path arrived as a process argument
// before the webview existed, so queue it here. macOS uses
// RunEvent::Opened instead.
for arg in std::env::args().skip(1) {
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
let _ = crate::commands::open_files::accept_path(&pending, &arg);
}
app.manage(AppState { app.manage(AppState {
db_store: StdMutex::new(store), db_store: StdMutex::new(store),
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()), pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
@@ -178,6 +207,7 @@ pub fn run() {
query::execute_query_multi, query::execute_query_multi,
query_export::export_query_to_file, query_export::export_query_to_file,
query::cancel_query, query::cancel_query,
crate::commands::open_files::take_pending_sql_files,
query::get_query_history, query::get_query_history,
query::clear_query_history, query::clear_query_history,
query::set_history_favorite, query::set_history_favorite,
@@ -188,16 +218,34 @@ pub fn run() {
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|app_handle, event| { .run(|app_handle, event| match event {
// macOS: Finder / LaunchServices "open with". File URLs only.
#[cfg(target_os = "macos")]
tauri::RunEvent::Opened { urls } => {
let pending = app_handle
.state::<crate::commands::open_files::PendingOpenFiles>();
let mut accepted = false;
for url in urls {
if let Ok(path) = url.to_file_path() {
if crate::commands::open_files::accept_path(
&pending,
&path.to_string_lossy(),
) {
accepted = true;
}
}
}
if accepted {
let _ = app_handle.emit("sql-file-opened", ());
}
}
// Close all SSH tunnels on exit: ExitRequested fires before the // Close all SSH tunnels on exit: ExitRequested fires before the
// event loop ends, Exit fires after it has. // event loop ends, Exit fires after it has.
if matches!( tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
event,
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
) {
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() { if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
mgr.close_all(); mgr.close_all();
} }
} }
_ => {}
}); });
} }
+25 -1
View File
@@ -42,6 +42,12 @@ pub struct PgToolStatus {
pub pg_restore_version: Option<String>, pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>, pub pg_restore_source: Option<String>,
/// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …).
pub pg_dump_resolved_name: Option<String>,
pub pg_restore_resolved_name: Option<String>,
/// Whether a bundled file exists at the expected path (runnable or not).
pub pg_dump_bundled_available: bool,
pub pg_restore_bundled_available: bool,
} }
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback). /// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
@@ -111,6 +117,12 @@ pub struct MySqlToolStatus {
pub mysql_version: Option<String>, pub mysql_version: Option<String>,
pub mysqldump_source: Option<String>, pub mysqldump_source: Option<String>,
pub mysql_source: Option<String>, pub mysql_source: Option<String>,
pub mysqldump_resolved_name: Option<String>,
pub mysql_resolved_name: Option<String>,
/// True when the dump tool that resolved is MariaDB's `mariadb-dump`.
pub mysqldump_is_mariadb: bool,
pub mysqldump_bundled_available: bool,
pub mysql_bundled_available: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -141,6 +153,9 @@ pub struct BackupProgressEvent {
pub progress: Option<f64>, pub progress: Option<f64>,
pub output_line: Option<String>, pub output_line: Option<String>,
pub error: Option<String>, pub error: Option<String>,
/// Non-fatal advisory message (e.g. a client/server version mismatch).
/// `None` for ordinary progress events.
pub warning: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -185,7 +200,11 @@ mod tests {
fn pg_tool_status_reports_source() { fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true, let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()), pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) }; pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()),
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false };
let json = serde_json::to_string(&s).unwrap(); let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\"")); assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\"")); assert!(json.contains("\"pg_restore_source\":\"bundled\""));
@@ -225,6 +244,11 @@ mod tests {
mysql_version: Some("mariadb 10.6".into()), mysql_version: Some("mariadb 10.6".into()),
mysqldump_source: Some("bundled".into()), mysqldump_source: Some("bundled".into()),
mysql_source: Some("system".into()), mysql_source: Some("system".into()),
mysqldump_resolved_name: Some("mariadb-dump".into()),
mysql_resolved_name: Some("mariadb".into()),
mysqldump_is_mariadb: true,
mysqldump_bundled_available: true,
mysql_bundled_available: true,
}; };
let json = serde_json::to_string(&s).unwrap(); let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"mysqldumpSource\":\"bundled\"")); assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
+11 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.8.1", "version": "0.8.3",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -27,6 +27,16 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"], "resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"fileAssociations": [
{
"ext": ["sql"],
"name": "SQL Script",
"description": "SQL script",
"mimeType": "application/sql",
"role": "Editor",
"rank": "Alternate"
}
],
"macOS": { "macOS": {
"signingIdentity": "-", "signingIdentity": "-",
"hardenedRuntime": true, "hardenedRuntime": true,
+4
View File
@@ -9,7 +9,9 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree
import { ErrorBanner } from "./components/ui/ErrorBanner"; import { ErrorBanner } from "./components/ui/ErrorBanner";
import { ToastContainer } from "./components/ui/Toast"; import { ToastContainer } from "./components/ui/Toast";
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen"; import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
import { PendingSqlFileBanner } from "./components/layout/PendingSqlFileBanner";
import { useAppearance } from "./hooks/useAppearance"; import { useAppearance } from "./hooks/useAppearance";
import { useSqlFileOpen } from "./hooks/useSqlFileOpen";
import { isMacOS } from "./lib/platform"; import { isMacOS } from "./lib/platform";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -36,6 +38,7 @@ export default function App() {
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString); const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB"); useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
useSqlFileOpen();
useEffect(() => { useEffect(() => {
loadConnections(); loadConnections();
@@ -90,6 +93,7 @@ export default function App() {
className="h-7 shrink-0 bg-canvas select-none" className="h-7 shrink-0 bg-canvas select-none"
/> />
)} )}
<PendingSqlFileBanner />
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{connectionError && ( {connectionError && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal"; import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { CopyButton } from "../ui/CopyButton";
import { Select } from "../ui/Select"; import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
@@ -108,6 +109,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]); }, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found; const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const installInstructions = getPlatformInstructions();
return ( return (
<AnimatedModal open={open} onClose={onClose}> <AnimatedModal open={open} onClose={onClose}>
@@ -124,10 +126,19 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
<p className="text-amber-200/80 text-xs"> <p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using: The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p> </p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()} {installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { BackupPage } from "./BackupPage"; import { BackupPage } from "./BackupPage";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -84,3 +84,104 @@ describe("BackupPage DB-aware", () => {
expect(screen.getByText("Custom Archive")).toBeTruthy(); expect(screen.getByText("Custom Archive")).toBeTruthy();
}); });
}); });
describe("BackupPage tool provenance and re-check", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
});
it("shows the resolved tool and its source", async () => {
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "mariadb-dump 11.4.5-MariaDB",
mysqlVersion: "mariadb 11.4.5-MariaDB",
mysqldumpSource: "bundled",
mysqlSource: "bundled",
mysqldumpResolvedName: "mariadb-dump",
mysqlResolvedName: "mariadb",
mysqldumpBundledAvailable: true,
mysqlBundledAvailable: true,
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText(/Using bundled mariadb-dump/)).toBeTruthy());
});
it("re-checks with force when Check again is clicked", async () => {
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
});
render(<BackupPage connectionId="c2" />);
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
});
it("explains when the bundled tool is present but could not run", async () => {
mockConnections.push({ id: "c3", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: true,
mysqlBundledAvailable: false,
});
render(<BackupPage connectionId="c3" />);
await waitFor(() =>
expect(screen.getByText(/bundled copy is present but could not run/i)).toBeTruthy(),
);
});
it("shows install instructions when nothing resolves at all", async () => {
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: false,
});
render(<BackupPage connectionId="c4" />);
await waitFor(() => expect(screen.getByText(/mysql-client/)).toBeTruthy());
});
it("offers a copy button for the install command", async () => {
mockConnections.push({ id: "c-copy", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: false,
});
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn() },
configurable: true,
writable: true,
});
render(<BackupPage connectionId="c-copy" />);
const btn = await screen.findByTitle("Copy to clipboard");
fireEvent.click(btn);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("mysql-client"),
);
});
});
+173 -156
View File
@@ -1,8 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Download, FolderOpen, HardDrive } from "lucide-react"; import { Download, FolderOpen, HardDrive } from "lucide-react";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button"; import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
inputClass,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
@@ -98,16 +104,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
} }
}, [activeJob, notify]); }, [activeJob, notify]);
useEffect(() => { const checkTools = useCallback(
async (force: boolean) => {
setCheckingTools(true); setCheckingTools(true);
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null); setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) { if (isPg) {
detectPgTools() try {
.then((status) => setPgToolStatus(status)) setPgToolStatus(await detectPgTools(force));
.catch(() => } catch {
setPgToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
@@ -115,17 +121,14 @@ export function BackupPage({ connectionId }: BackupPageProps) {
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null, pg_dump_source: null,
pg_restore_source: null, pg_restore_source: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) { } else if (isMysql) {
detectMysqlTools() try {
.then((status) => setMysqlToolStatus(status)) setMysqlToolStatus(await detectMysqlTools(force));
.catch(() => } catch {
setMysqlToolStatus({ setMysqlToolStatus({
mysqldumpFound: false, mysqldumpFound: false,
mysqlFound: false, mysqlFound: false,
@@ -133,17 +136,26 @@ export function BackupPage({ connectionId }: BackupPageProps) {
mysqlVersion: null, mysqlVersion: null,
mysqldumpSource: null, mysqldumpSource: null,
mysqlSource: null, mysqlSource: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else { } else {
setCheckingTools(false); setCheckingTools(false);
} }
}, [connectionId, isPg, isMysql]); },
[isPg, isMysql],
);
useEffect(() => {
setAvailableSchemas([]);
void checkTools(false);
if (isPg || isMysql) {
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}
}, [connectionId, isPg, isMysql, checkTools]);
const handlePickFile = useCallback(async () => { const handlePickFile = useCallback(async () => {
let defaultPath = "backup"; let defaultPath = "backup";
@@ -262,6 +274,23 @@ export function BackupPage({ connectionId }: BackupPageProps) {
? mysqlToolStatus?.mysqldumpSource === "bundled" ? mysqlToolStatus?.mysqldumpSource === "bundled"
: false; : false;
const dumpToolFound = isPg
? pgToolStatus?.pg_dump_found
: mysqlToolStatus?.mysqldumpFound;
const dumpSource = isPg
? pgToolStatus?.pg_dump_source
: mysqlToolStatus?.mysqldumpSource;
const dumpResolvedName = isPg
? pgToolStatus?.pg_dump_resolved_name
: mysqlToolStatus?.mysqldumpResolvedName;
const bundledPresent = isPg
? pgToolStatus?.pg_dump_bundled_available
: mysqlToolStatus?.mysqldumpBundledAvailable;
const dumpFallbackName = isPg ? "pg_dump" : "mysqldump";
const installInstructions = getPlatformInstructions(
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
);
const checkingMessage = isPg const checkingMessage = isPg
? "Checking for pg_dump..." ? "Checking for pg_dump..."
: isMysql : isMysql
@@ -275,128 +304,141 @@ export function BackupPage({ connectionId }: BackupPageProps) {
: "Create a database backup"; : "Create a database backup";
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<HardDrive size={14} className="text-accent" /> <HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span> <span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
{headerDescription} {headerDescription}
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{(isPg || isMysql) && (
<button
type="button"
onClick={() => void checkTools(true)}
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
>
Check again
</button>
)}
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartBackup()}
disabled={isRunning || !filePath}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_dump not found" : "mysqldump not found"} {isPg ? "pg_dump not found" : "mysqldump not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client
backup/restore operations. Install them using: tools. This build could not locate or run them, so a manual
install is needed:
</p>
{bundledPresent && (
<p className="text-amber-200/80 text-xs leading-relaxed">
A bundled copy is present but could not run — reinstall
Gridline, or install the tools below.
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)} )}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
{installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} {dumpToolFound && (
<div className="p-5 space-y-5"> <FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
{dumpResolvedName ?? dumpFallbackName}
</span>
</FormRow>
)}
{/* Format */} {/* Format */}
{isPg ? ( {isPg ? (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select <select
value={format} value={format}
onChange={(e) => onChange={(e) =>
setFormat( setFormat(e.target.value as BackupFormat)
e.target.value as BackupFormat,
)
} }
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" className={`${controlClass} mr-3`}
> >
<option value="custom"> <option value="custom">Custom Archive</option>
Custom Archive
</option>
<option value="plain">Plain SQL</option> <option value="plain">Plain SQL</option>
<option value="tar">Tarball</option> <option value="tar">Tarball</option>
<option value="directory"> <option value="directory">Directory</option>
Directory
</option>
</select> </select>
</div> </FormRow>
) : isMysql ? ( ) : isMysql ? (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <span className="px-3 font-heading text-xs text-text">
Format
</label>
<div className="text-sm text-text py-2">
Plain SQL Plain SQL
</div> </span>
</div> </FormRow>
) : null} ) : null}
{/* Output file */} {/* Output file */}
<div className="space-y-1 w-full"> <FormRow label="Output file">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Output File
</label>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={filePath} value={filePath}
onChange={(e) => onChange={(e) => setFilePath(e.target.value)}
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump" placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" className={inputClass}
/> />
<button <button
type="button" type="button"
onClick={handlePickFile} onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file" aria-label="Browse for file"
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
> >
<FolderOpen size={15} /> <FolderOpen size={13} />
</button> </button>
</div> </FormRow>
</div>
{/* Schema (optional) */} {/* Schema */}
{(isPg || isMysql) && ( {(isPg || isMysql) && (
<div className="space-y-1"> <FormRow label="Schema">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select <select
value={schema} value={schema}
onChange={(e) => onChange={(e) => setSchema(e.target.value)}
setSchema(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="">All schemas</option> <option value="">All schemas</option>
{availableSchemas.map((s) => ( {availableSchemas.map((s) => (
@@ -405,119 +447,106 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
)}
{(isPg || isMysql) && (
<FormSectionHeader label="Options" />
)} )}
{/* PostgreSQL: no-owner toggle */} {/* PostgreSQL: no-owner toggle */}
{isPg && ( {isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group"> <FormRow label="No owner">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={noOwner} checked={noOwner}
onChange={(e) => onChange={(e) => setNoOwner(e.target.checked)}
setNoOwner(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner --no-owner
</code> </code>
</span>
</label> </label>
</FormRow>
)} )}
{/* MySQL: option toggles */} {/* MySQL: option toggles */}
{isMysql && ( {isMysql && (
<div className="space-y-2"> <>
<label className="flex items-center gap-2.5 cursor-pointer group"> <FormRow label="Single transaction">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={singleTransaction} checked={singleTransaction}
onChange={(e) => onChange={(e) =>
setSingleTransaction(e.target.checked) setSingleTransaction(e.target.checked)
} }
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer" className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--single-transaction --single-transaction
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="No data">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={noData} checked={noData}
onChange={(e) => onChange={(e) => setNoData(e.target.checked)}
setNoData(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
No Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-data --no-data
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Routines">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={routines} checked={routines}
onChange={(e) => onChange={(e) => setRoutines(e.target.checked)}
setRoutines(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--routines --routines
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Triggers">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={triggers} checked={triggers}
onChange={(e) => onChange={(e) => setTriggers(e.target.checked)}
setTriggers(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--triggers --triggers
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Events">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={events} checked={events}
onChange={(e) => onChange={(e) => setEvents(e.target.checked)}
setEvents(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--events --events
</code> </code>
</span>
</label> </label>
</div> </FormRow>
</>
)} )}
</div>
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump" jobType="dump"
@@ -526,21 +555,9 @@ export function BackupPage({ connectionId }: BackupPageProps) {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartBackup}
disabled={isRunning || !filePath}
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal"; import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -94,6 +95,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
const toolsMissing = toolStatus && !toolStatus.pg_restore_found; const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !running; const canStart = filePath && confirmed && !running;
const installInstructions = getPlatformInstructions();
return ( return (
<AnimatedModal open={open} onClose={onClose}> <AnimatedModal open={open} onClose={onClose}>
@@ -110,10 +112,19 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
<p className="text-amber-200/80 text-xs"> <p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using: The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p> </p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()} {installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
@@ -64,4 +64,41 @@ describe("RestorePage DB-aware", () => {
expect(screen.queryByText(/pg_restore/i)).toBeNull(); expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull(); expect(screen.queryByText(/mysqldump/i)).toBeNull();
}); });
it("renders the MySQL restore form even when no MySQL client is installed", async () => {
// Restore is in-process since Task 7, so a missing mariadb/mysql client must
// no longer hide the form.
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
});
render(<RestorePage connectionId="c4" />);
// The tools check is async; wait until the configuration card is actually up.
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/no MySQL client installation required/i)).toBeTruthy();
});
it("explains what Clean does for MySQL", async () => {
mockConnections.push({ id: "c5", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c5" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/only the objects this file defines/i)).toBeTruthy();
});
it("warns that a failed MySQL restore cannot be rolled back", async () => {
mockConnections.push({ id: "c6", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c6" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/can't be rolled back/i)).toBeTruthy();
});
}); });
+134 -123
View File
@@ -1,8 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react"; import { FolderOpen, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button"; import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
inputClass,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
@@ -10,11 +16,10 @@ import {
detectPgTools, detectPgTools,
pgRestore, pgRestore,
getSchemas, getSchemas,
detectMysqlTools,
mysqlRestore, mysqlRestore,
sqliteRestore, sqliteRestore,
} from "../../lib/commands"; } from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types"; import type { PgToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps { interface RestorePageProps {
connectionId: string; connectionId: string;
@@ -60,7 +65,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
const [schema, setSchema] = useState(""); const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null); const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true); const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]); const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -93,7 +97,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
setCheckingTools(true); setCheckingTools(true);
setConfirmed(false); setConfirmed(false);
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]); setAvailableSchemas([]);
if (isPg) { if (isPg) {
@@ -115,19 +118,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([])); .catch(() => setAvailableSchemas([]));
} else if (isMysql) { } else if (isMysql) {
detectMysqlTools() // MySQL restore runs in-process over the sqlx driver connection, so
.then((status) => setMysqlToolStatus(status)) // there is no client to probe for or wait on here (Backup/Sync
.catch(() => // still probe via detectMysqlTools for mysqldump).
setMysqlToolStatus({ setCheckingTools(false);
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId) getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
@@ -214,17 +208,19 @@ export function RestorePage({ connectionId }: RestorePageProps) {
runWithProgress, runWithProgress,
]); ]);
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
const toolsMissing = isPg const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found ? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false; : false;
const toolsBundled = isPg const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled" ? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false; : false;
const canStart = filePath && confirmed && !isRunning; const canStart = filePath && confirmed && !isRunning;
const installInstructions = getPlatformInstructions(
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
);
const checkingMessage = isPg const checkingMessage = isPg
? "Checking for pg_restore..." ? "Checking for pg_restore..."
@@ -238,118 +234,144 @@ export function RestorePage({ connectionId }: RestorePageProps) {
? "Restore a database from a SQL dump" ? "Restore a database from a SQL dump"
: "Restore a database from a backup file"; : "Restore a database from a backup file";
const cleanDisabled = isPg && format === "plain";
const restoreToolResolvedName =
pgToolStatus?.pg_restore_resolved_name ?? "pg_restore";
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<Upload size={14} className="text-accent" /> <Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span> <span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
{headerDescription} {headerDescription}
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartRestore()}
disabled={!canStart}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<Upload size={14} className="mr-1.5" />
{isRunning ? "Restoring..." : "Start Restore"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_restore not found" : "mysql client not found"} {isPg ? "pg_restore not found" : "mysql client not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
backup/restore operations. Install them using: client tools. This build could not locate or run them, so
a manual install is needed:
</p>
{pgToolStatus?.pg_restore_bundled_available && (
<p className="text-amber-200/80 text-xs leading-relaxed">
A bundled copy is present but could not run — reinstall
Gridline, or install the tools below.
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)} )}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">
Install
</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
{installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} {/* Restore tool */}
<div className="p-5 space-y-5"> {isPg && pgToolStatus?.pg_restore_found && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using{" "}
{pgToolStatus.pg_restore_source === "bundled"
? "bundled"
: "system"}{" "}
{restoreToolResolvedName}
</span>
</FormRow>
)}
{isMysql && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Restores run in-process over the driver connection —
no MySQL client installation required
</span>
</FormRow>
)}
{/* Format (PostgreSQL only) */} {/* Format (PostgreSQL only) */}
{isPg && ( {isPg && (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select <select
value={format} value={format}
onChange={(e) => onChange={(e) => setFormat(e.target.value)}
setFormat(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="custom"> <option value="custom">Custom Archive</option>
Custom Archive
</option>
<option value="plain">Plain SQL</option> <option value="plain">Plain SQL</option>
<option value="tar">Tarball</option> <option value="tar">Tarball</option>
<option value="directory"> <option value="directory">Directory</option>
Directory
</option>
</select> </select>
</div> </FormRow>
)} )}
{/* Backup file */} {/* Backup file */}
<div className="space-y-1 w-full"> <FormRow label="Backup file">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={filePath} value={filePath}
onChange={(e) => onChange={(e) => setFilePath(e.target.value)}
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump" placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" className={inputClass}
/> />
<button <button
type="button" type="button"
onClick={handlePickFile} onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file" aria-label="Browse for file"
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
> >
<FileSearch size={15} /> <FolderOpen size={13} />
</button> </button>
</div> </FormRow>
</div>
{/* Schema (optional) */} {/* Schema (optional) */}
{(isPg || isMysql) && ( {(isPg || isMysql) && (
<div className="space-y-1"> <FormRow label="Schema">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select <select
value={schema} value={schema}
onChange={(e) => onChange={(e) => setSchema(e.target.value)}
setSchema(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="">All schemas</option> <option value="">All schemas</option>
{availableSchemas.map((s) => ( {availableSchemas.map((s) => (
@@ -358,14 +380,16 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
)} )}
<FormSectionHeader label="Options" />
{/* Clean toggle */} {/* Clean toggle */}
{(isPg || isMysql || isSqlite) && ( <FormRow label="Clean">
<label <label
className={`flex items-center gap-2.5 cursor-pointer group ${ className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
isPg && format === "plain" cleanDisabled
? "opacity-40 pointer-events-none" ? "opacity-40 pointer-events-none"
: "" : ""
}`} }`}
@@ -373,52 +397,53 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<input <input
type="checkbox" type="checkbox"
checked={clean} checked={clean}
onChange={(e) => onChange={(e) => setClean(e.target.checked)}
setClean(e.target.checked) disabled={cleanDisabled}
} className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Clean{" "} {isMysql
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5"> ? "drops only the objects this file defines"
DROP before CREATE : "DROP before CREATE"}
</code> </code>
</span>
</label> </label>
)} </FormRow>
{isPg && format === "plain" && ( {isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3"> <div className="border-b border-border px-4 py-2 text-[11px] text-text-muted/70">
Plain SQL restores run via psql and don't Plain SQL restores run via psql and don't support
support DROP-before-CREATE. Use Custom DROP-before-CREATE. Use Custom Archive for clean
Archive for clean restores. restores.
</p>
)}
</div> </div>
)}
{/* Destructive confirmation */} {/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3"> <div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={confirmed} checked={confirmed}
onChange={(e) => onChange={(e) => setConfirmed(e.target.checked)}
setConfirmed(e.target.checked) className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox" data-testid="restore-confirm-checkbox"
/> />
<span className="text-sm text-red-300/90 leading-relaxed"> <span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on I understand this will overwrite data on the target
the target database. This action cannot database. This action cannot be undone.
be undone.
</span> </span>
</label> </label>
{isMysql && (
<p className="text-[11px] text-red-400/80">
MySQL DDL commits as it runs and can't be rolled
back — a failed restore may leave this database
partially applied.
</p>
)}
</div> </div>
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore" jobType="restore"
@@ -427,23 +452,9 @@ export function RestorePage({ connectionId }: RestorePageProps) {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react"; import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands"; import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands";
import { controlClass } from "./objects/formRow";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DiffItem, DiffReport } from "../../lib/types"; import type { DiffItem, DiffReport } from "../../lib/types";
@@ -25,9 +26,9 @@ function familyOf(dbType: string): string {
function SchemaDiffItemCard({ item }: { item: DiffItem }) { function SchemaDiffItemCard({ item }: { item: DiffItem }) {
return ( return (
<div className="rounded-lg border border-border bg-surface p-2 text-xs"> <div className="border border-border bg-surface p-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`rounded border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span> <span className={`border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
<span className="text-text">{item.name}</span> <span className="text-text">{item.name}</span>
{item.destructive && ( {item.destructive && (
<span className="flex items-center gap-1 text-text-muted"> <span className="flex items-center gap-1 text-text-muted">
@@ -43,7 +44,7 @@ function SchemaDiffItemCard({ item }: { item: DiffItem }) {
{item.sync_sql.length > 0 && ( {item.sync_sql.length > 0 && (
<details className="mt-1.5"> <details className="mt-1.5">
<summary className="cursor-pointer text-text-muted">SQL</summary> <summary className="cursor-pointer text-text-muted">SQL</summary>
<pre className="mt-1 rounded bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre> <pre className="mt-1 bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
<button <button
type="button" type="button"
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))} onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
@@ -165,8 +166,8 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
: 0; : 0;
return ( return (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden"> <div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0 space-y-2"> <div className="border-b border-border px-4 py-2 shrink-0 space-y-2">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<GitCompare size={14} className="text-text-muted" /> <GitCompare size={14} className="text-text-muted" />
<span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span> <span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span>
@@ -175,7 +176,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
<select <select
value={sourceId} value={sourceId}
onChange={(e) => void pickSource(e.target.value)} onChange={(e) => void pickSource(e.target.value)}
className="rounded-lg bg-surface-raised border border-border px-2 py-1" className={`${controlClass} max-w-56`}
aria-label="Source connection" aria-label="Source connection"
> >
<option value="">Source connection…</option> <option value="">Source connection…</option>
@@ -187,7 +188,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
value={sourceSchema} value={sourceSchema}
onChange={(e) => setSourceSchema(e.target.value)} onChange={(e) => setSourceSchema(e.target.value)}
disabled={!sourceId} disabled={!sourceId}
className="rounded-lg bg-surface-raised border border-border px-2 py-1 disabled:opacity-50" className={`${controlClass} max-w-56 disabled:opacity-50 disabled:cursor-not-allowed`}
aria-label="Source schema" aria-label="Source schema"
> >
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)} {sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
@@ -196,7 +197,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
<select <select
value={targetSchema} value={targetSchema}
onChange={(e) => setTargetSchema(e.target.value)} onChange={(e) => setTargetSchema(e.target.value)}
className="rounded-lg bg-surface-raised border border-border px-2 py-1" className={`${controlClass} max-w-56`}
aria-label="Target schema" aria-label="Target schema"
> >
<option value={targetSchema}>{targetSchema}</option> <option value={targetSchema}>{targetSchema}</option>
@@ -205,7 +206,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
type="button" type="button"
onClick={() => void runDiff()} onClick={() => void runDiff()}
disabled={running || !sourceId || !sourceSchema} disabled={running || !sourceId || !sourceSchema}
className="flex items-center gap-1 rounded-lg bg-accent px-2.5 py-1 text-accent-foreground disabled:opacity-50 cursor-pointer" className="inline-flex items-center gap-1 rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-accent-foreground hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
> >
<Play size={13} /> {running ? "Diffing…" : "Run diff"} <Play size={13} /> {running ? "Diffing…" : "Run diff"}
</button> </button>
@@ -213,7 +214,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
<button <button
type="button" type="button"
onClick={stageAll} onClick={stageAll}
className="rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer" className="bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
> >
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"} Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
</button> </button>
@@ -222,7 +223,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
<button <button
type="button" type="button"
onClick={copyAll} onClick={copyAll}
className="flex items-center gap-1 rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer" className="inline-flex items-center gap-1 bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
> >
<Copy size={13} /> Copy all SQL <Copy size={13} /> Copy all SQL
</button> </button>
@@ -236,7 +237,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
{error && <div className="text-xs text-red-400" role="alert">{error}</div>} {error && <div className="text-xs text-red-400" role="alert">{error}</div>}
</div> </div>
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4"> <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-4">
{report && report.items.length === 0 && ( {report && report.items.length === 0 && (
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div> <div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
)} )}
@@ -60,3 +60,49 @@ describe("SyncPage DB-aware", () => {
expect(options).not.toContain("sq1"); expect(options).not.toContain("sq1");
}); });
}); });
describe("SyncPage tool re-check", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
});
it("re-checks MySQL tools with force when Check again is clicked", async () => {
mockConnections.push(
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
render(<SyncPage />);
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
});
it("lists the specific missing MySQL role", async () => {
mockConnections.push(
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: true,
mysqldumpVersion: null,
mysqlVersion: "8.0",
mysqldumpSource: null,
mysqlSource: "system",
});
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
render(<SyncPage />);
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
await waitFor(() => expect(screen.getByText(/mysqldump is missing/i)).toBeTruthy());
});
});
+114 -125
View File
@@ -1,7 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowLeftRight, Database } from "lucide-react"; import { ArrowLeftRight } from "lucide-react";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -59,16 +63,15 @@ export function SyncPage() {
}, [activeJob, notify]); }, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type // Tool detection: depends on the selected source connection's DB type
useEffect(() => { const checkTools = useCallback(
async (force: boolean) => {
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null); setMysqlToolStatus(null);
setConfirmed(false);
if (isPg) { if (isPg) {
setCheckingTools(true); setCheckingTools(true);
detectPgTools() try {
.then((status) => setPgToolStatus(status)) setPgToolStatus(await detectPgTools(force));
.catch(() => } catch {
setPgToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
@@ -76,14 +79,15 @@ export function SyncPage() {
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null, pg_dump_source: null,
pg_restore_source: null, pg_restore_source: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
} else if (isMysql) { } else if (isMysql) {
setCheckingTools(true); setCheckingTools(true);
detectMysqlTools() try {
.then((status) => setMysqlToolStatus(status)) setMysqlToolStatus(await detectMysqlTools(force));
.catch(() => } catch {
setMysqlToolStatus({ setMysqlToolStatus({
mysqldumpFound: false, mysqldumpFound: false,
mysqlFound: false, mysqlFound: false,
@@ -91,11 +95,19 @@ export function SyncPage() {
mysqlVersion: null, mysqlVersion: null,
mysqldumpSource: null, mysqldumpSource: null,
mysqlSource: null, mysqlSource: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
} }
}, [isPg, isMysql]); }
},
[isPg, isMysql],
);
useEffect(() => {
setConfirmed(false);
void checkTools(false);
}, [checkTools]);
// Fetch schemas from the source connection when it changes // Fetch schemas from the source connection when it changes
useEffect(() => { useEffect(() => {
@@ -195,31 +207,59 @@ export function SyncPage() {
? connections.filter((c) => c.db_type === dbType) ? connections.filter((c) => c.db_type === dbType)
: []; : [];
const sourceName =
connections.find((c) => c.id === sourceConnectionId)?.name ??
sourceConnectionId;
const targetName =
connections.find((c) => c.id === targetConnectionId)?.name ??
targetConnectionId;
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<ArrowLeftRight size={14} className="text-accent" /> <ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span> <span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
Transfer data between databases via pipe Transfer data between databases via pipe
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{(isPg || isMysql) && (
<button
type="button"
onClick={() => void checkTools(true)}
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
>
Check again
</button>
)}
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartSync()}
disabled={!canStart}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<ArrowLeftRight size={14} className="mr-1.5" />
{isRunning ? "Syncing..." : "Start Sync"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg {isPg
? "PostgreSQL tools not found" ? "PostgreSQL tools not found"
@@ -242,58 +282,45 @@ export function SyncPage() {
{isMysql && !mysqlToolStatus?.mysqlFound && ( {isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li> <li>mysql is missing.</li>
)} )}
{isMysql && mysqlToolStatus?.mysqlResolvedName && (
<li className="text-amber-200/50">
Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName}
</li>
)}
</ul> </ul>
</div> </div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} <FormSectionHeader label="Connections" />
<div className="p-5 space-y-5">
{/* Source & Target connection pickers */} {/* Source connection */}
<div className="grid grid-cols-2 gap-4"> <FormRow label="Source">
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Source
</label>
<select <select
value={sourceConnectionId} value={sourceConnectionId}
onChange={(e) => { onChange={(e) => {
setSourceConnectionId( setSourceConnectionId(e.target.value);
e.target.value,
);
setTargetConnectionId(""); setTargetConnectionId("");
}} }}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" className={`${controlClass} mr-3`}
> >
<option value=""> <option value="">Select source...</option>
Select source...
</option>
{connections.map((c) => ( {connections.map((c) => (
<option <option key={c.id} value={c.id}>
key={c.id}
value={c.id}
>
{c.name} {c.name}
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1"> {/* Target connection — filtered to the source's db type */}
<Database size={11} /> <FormRow label="Target">
Target
</label>
<select <select
value={targetConnectionId} value={targetConnectionId}
onChange={(e) => onChange={(e) => setTargetConnectionId(e.target.value)}
setTargetConnectionId(
e.target.value,
)
}
disabled={!sourceConnectionId} disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
> >
<option value=""> <option value="">
{sourceConnectionId {sourceConnectionId
@@ -304,81 +331,58 @@ export function SyncPage() {
<option <option
key={c.id} key={c.id}
value={c.id} value={c.id}
disabled={ disabled={c.id === sourceConnectionId}
c.id === sourceConnectionId
}
> >
{c.name} {c.name}
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Flow indicator */} {/* Flow indicator */}
{sourceConnectionId && targetConnectionId && ( {sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted"> <FormRow label="Flow">
<div className="flex items-center gap-3 px-3 py-2 text-[11px] text-text-muted">
<span className="font-medium text-text"> <span className="font-medium text-text">
{connections.find( {sourceName}
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
</span> </span>
<ArrowLeftRight <ArrowLeftRight
size={12} size={12}
className="text-accent shrink-0" className="text-accent shrink-0"
/> />
<span className="font-medium text-text"> <span className="font-medium text-text">
{connections.find( {targetName}
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
</span> </span>
</div> </div>
</FormRow>
)}
{/* Schema (optional) */}
{(isPg || isMysql) && (
<FormRow label="Schema">
<select
value={schema}
onChange={(e) => setSchema(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</FormRow>
)} )}
</div>
{/* Destructive confirmation */} {/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3"> <div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={confirmed} checked={confirmed}
onChange={(e) => onChange={(e) => setConfirmed(e.target.checked)}
setConfirmed(e.target.checked) className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="sync-confirm-checkbox" data-testid="sync-confirm-checkbox"
/> />
<span className="text-sm text-red-300/90 leading-relaxed"> <span className="text-sm text-red-300/90 leading-relaxed">
@@ -391,7 +395,7 @@ export function SyncPage() {
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync" jobType="sync"
@@ -400,24 +404,9 @@ export function SyncPage() {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartSync}
disabled={!canStart}
>
<ArrowLeftRight
size={14}
className="mr-1.5"
/>
{isRunning ? "Syncing..." : "Start Sync"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
+1 -1
View File
@@ -251,7 +251,7 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2"> <div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
<button <button
type="button" type="button"
onClick={openQueryTab} onClick={() => openQueryTab()}
aria-label="New query tab" aria-label="New query tab"
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer" className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
> >
@@ -0,0 +1,24 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { PendingSqlFileBanner } from "./PendingSqlFileBanner";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => {
useUiStore.setState({ pendingSqlFile: null });
});
describe("PendingSqlFileBanner", () => {
it("renders nothing when there is no pending file", () => {
const { container } = render(<PendingSqlFileBanner />);
expect(container).toBeEmptyDOMElement();
});
it("names the file and tells the user to open a connection", () => {
useUiStore.setState({
pendingSqlFile: { path: "/tmp/a.sql", name: "student_db.sql", content: "SELECT 1;" },
});
render(<PendingSqlFileBanner />);
expect(screen.getByText(/student_db\.sql/)).toBeInTheDocument();
expect(screen.getByText(/open a connection/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,34 @@
import { FileCode2, X } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
/**
* Shown when a `.sql` file was opened from the OS but no database connection is
* active. The file is already loaded in memory; picking a connection in the
* Home screen delivers it (see `useSqlFileOpen`).
*/
export function PendingSqlFileBanner() {
const pending = useUiStore((s) => s.pendingSqlFile);
const clear = useUiStore((s) => s.clearPendingSqlFile);
if (!pending) return null;
return (
<div
role="status"
className="flex items-center gap-2 border-b border-border bg-surface-raised px-3 py-1.5 text-xs text-text-muted"
>
<FileCode2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<span className="font-medium text-text">{pending.name}</span> is ready — open a connection
to use it.
</span>
<button
type="button"
onClick={clear}
aria-label="Dismiss pending SQL file"
className="ml-auto flex items-center rounded px-1 py-0.5 hover:bg-surface hover:text-text transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { CopyButton } from "./CopyButton";
function mockClipboard(writeText: (text: string) => Promise<void>) {
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
writable: true,
});
}
/**
* Capture Node-level unhandled rejections. jsdom does not dispatch
* `window.unhandledrejection` for rejections originating in the environment, so
* the process event is the only reliable signal that a failed clipboard write
* escaped the click handler.
*
* `match` narrows the capture to this test's own error. It is required, not a
* convenience: the listener is process-global, and Vitest runs several test
* files in the same worker process — without a filter another file's rejection
* would be recorded here and fail this test. Filtering keeps the assertion
* precise ("did *our* rejection escape?") instead of order-dependent.
*/
interface UnhandledRejectionEmitter {
on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
}
const nodeProcess = (
globalThis as unknown as { process?: UnhandledRejectionEmitter }
).process;
function captureUnhandledRejections(match: string) {
const seen: unknown[] = [];
const listener = (reason: unknown) => {
if (String(reason).includes(match)) seen.push(reason);
};
nodeProcess?.on("unhandledRejection", listener);
return {
seen,
stop: () => nodeProcess?.off("unhandledRejection", listener),
};
}
/** Let queued microtasks and Node's unhandled-rejection check drain. */
async function drain() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
describe("CopyButton", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockClipboard(vi.fn().mockResolvedValue(undefined));
});
afterEach(() => {
vi.useRealTimers();
});
it("copies the given text to the clipboard", async () => {
render(<CopyButton text="brew install mysql-client" />);
fireEvent.click(screen.getByTitle("Copy to clipboard"));
await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"brew install mysql-client",
),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
});
it("shows a transient Copied confirmation", async () => {
render(<CopyButton text="brew install libpq" />);
const btn = screen.getByTitle("Copy to clipboard");
expect(btn.textContent).toBe("Copy");
fireEvent.click(btn);
await waitFor(() => expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied"));
});
it("reverts to Copy after the confirmation delay", async () => {
vi.useFakeTimers();
render(<CopyButton text="brew install libpq" />);
const btn = screen.getByTitle("Copy to clipboard");
fireEvent.click(btn);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied");
await act(async () => {
vi.advanceTimersByTime(1500);
});
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
});
it("stays quiet when the clipboard rejects", async () => {
const rejected = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
mockClipboard(rejected);
const unhandled = captureUnhandledRejections("NotAllowedError");
try {
render(<CopyButton text="brew install libpq" />);
fireEvent.click(screen.getByTitle("Copy to clipboard"));
await waitFor(() => expect(rejected).toHaveBeenCalled());
await drain();
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
expect(unhandled.seen).toEqual([]);
} finally {
unhandled.stop();
}
});
it("stays quiet when the clipboard API is unavailable", async () => {
Object.defineProperty(navigator, "clipboard", {
value: undefined,
configurable: true,
writable: true,
});
const unhandled = captureUnhandledRejections("writeText");
try {
render(<CopyButton text="brew install libpq" />);
expect(() =>
fireEvent.click(screen.getByTitle("Copy to clipboard")),
).not.toThrow();
await drain();
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
expect(unhandled.seen).toEqual([]);
} finally {
unhandled.stop();
}
});
it("uses a custom label for the accessible name", () => {
render(<CopyButton text="x" label="Copy install command" />);
expect(screen.getByTitle("Copy install command")).toBeTruthy();
expect(screen.getByLabelText("Copy install command")).toBeTruthy();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps {
/** Text written to the clipboard. */
text: string;
/** Accessible name; also the tooltip. */
label?: string;
className?: string;
}
/**
* Small inline copy control with a transient "Copied" confirmation.
*
* Clipboard writes are best-effort: `navigator.clipboard` is absent in some
* webview contexts and can reject on permission grounds, so a failure leaves
* the label unchanged instead of surfacing an error the user cannot act on.
*/
export function CopyButton({
text,
label = "Copy to clipboard",
className = "",
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
},
[],
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
return;
}
setCopied(true);
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
}, [text]);
return (
<button
type="button"
onClick={() => void handleCopy()}
aria-label={label}
title={label}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors cursor-pointer text-text-muted hover:text-text ${className}`}
>
{copied ? (
<>
<Check size={11} className="text-emerald-400" />
Copied
</>
) : (
<>
<Copy size={11} />
Copy
</>
)}
</button>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
const listeners: Record<string, (e: unknown) => void> = {};
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: unknown) => void) => {
listeners[name] = cb;
return () => {};
}),
}));
const takePendingSqlFiles = vi.hoisted(() => vi.fn());
vi.mock("../lib/commands", () => ({
takePendingSqlFiles: () => takePendingSqlFiles(),
}));
import { useSqlFileOpen } from "./useSqlFileOpen";
import { useUiStore } from "../stores/uiStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
const file = { path: "/tmp/student_db.sql", name: "student_db.sql", content: "SELECT 1;" };
beforeEach(() => {
takePendingSqlFiles.mockReset();
useUiStore.setState({ activeConnectionId: null, pendingSqlFile: null, activeView: "home" });
useDbViewerStore.setState({ tabs: [], activeTabId: null });
});
describe("useSqlFileOpen", () => {
it("opens a tab when a connection is active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => {
const tab = useDbViewerStore.getState().tabs.slice(-1)[0];
expect(tab?.query).toBe("SELECT 1;");
});
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.table).toBe("student_db.sql");
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
it("stashes the file and prompts when no connection is active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
renderHook(() => useSqlFileOpen());
await waitFor(() => {
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
});
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
expect(useUiStore.getState().activeView).toBe("home");
});
it("drains the buffer again when the nudge event fires", async () => {
takePendingSqlFiles.mockResolvedValue([]);
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => expect(takePendingSqlFiles).toHaveBeenCalledTimes(1));
takePendingSqlFiles.mockResolvedValue([file]);
listeners["sql-file-opened"]?.({});
await waitFor(() => {
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
});
});
it("opens the stashed file once a connection becomes active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
renderHook(() => useSqlFileOpen());
await waitFor(() => expect(useUiStore.getState().pendingSqlFile).toEqual(file));
useUiStore.setState({ activeConnectionId: "conn-2" });
await waitFor(() => {
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
});
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
it("surfaces a read failure as a notification instead of throwing", async () => {
takePendingSqlFiles.mockRejectedValue(new Error("a.sql is not valid UTF-8"));
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => {
expect(useNotificationStore.getState().notifications.slice(-1)[0]?.message).toContain("UTF-8");
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { takePendingSqlFiles } from "../lib/commands";
import { useUiStore } from "../stores/uiStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
import type { PendingSqlFile } from "../lib/types";
/**
* Turn OS "open this .sql file with Gridline" requests into editor tabs.
*
* Every platform path (macOS RunEvent::Opened, Windows/Linux argv, the
* single-instance forward) ends up in a Rust buffer; this hook only ever
* *drains* that buffer. That is deliberate: draining is idempotent, so a cold
* start, a warm start, and a webview reload all converge on the same code path
* and there is no cold-start race to handle.
*
* Opening never executes: the script lands in a query tab and runs only when
* the user presses Run, which passes through the destructive-query guard.
*/
export function useSqlFileOpen(): void {
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | undefined;
const deliver = async () => {
let files: PendingSqlFile[];
try {
files = await takePendingSqlFiles();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useNotificationStore.getState().notify(`Could not open SQL file: ${msg}`, "error");
return;
}
if (disposed) return;
for (const file of files) openOrStash(file);
};
void deliver();
void listen("sql-file-opened", () => {
void deliver();
})
.then((un) => {
unlisten = un;
})
.catch(() => {
// No Tauri event bridge (browser / tests): `listen` rejects there. The
// mount-time drain already ran, and the Rust buffer survives until a
// real webview asks for it, so there is nothing to recover here. The
// drain is idempotent, so a missed nudge is not a correctness problem —
// a warm webview still drains on mount and on the next event.
});
return () => {
disposed = true;
unlisten?.();
};
}, []);
// A stashed file has no target until the user picks a connection, so
// deliver it as soon as one becomes active.
const pending = useUiStore((s) => s.pendingSqlFile);
const activeConnectionId = useUiStore((s) => s.activeConnectionId);
useEffect(() => {
if (pending && activeConnectionId) {
openQueryTabFor(pending);
useUiStore.getState().clearPendingSqlFile();
}
}, [pending, activeConnectionId]);
}
function openOrStash(file: PendingSqlFile): void {
const { activeConnectionId } = useUiStore.getState();
if (!activeConnectionId) {
// Guide rather than error: a Finder double-click should not dead-end.
useUiStore.getState().setPendingSqlFile(file);
useUiStore.getState().setActiveView("home");
useNotificationStore
.getState()
.notify(`${file.name} is ready — open a connection to use it.`, "info");
return;
}
openQueryTabFor(file);
}
function openQueryTabFor(file: PendingSqlFile): void {
useDbViewerStore.getState().openQueryTab({
sql: file.content,
label: file.name,
filePath: file.path,
});
useUiStore.getState().setActiveView("db-viewer");
}
+13 -3
View File
@@ -1,11 +1,21 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.8.1)", () => { describe("tauri bundle config (v0.8.3)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.8.1", () => { it("version is 0.8.3", () => {
expect(tauriConf.version).toBe("0.8.1"); expect(tauriConf.version).toBe("0.8.3");
});
it("declares a .sql file association with alternate rank", () => {
const assoc = tauriConf.bundle.fileAssociations;
expect(Array.isArray(assoc)).toBe(true);
const sql = (assoc as { ext?: string[]; rank?: string; role?: string }[])
.find((a) => a.ext?.includes("sql"));
expect(sql, "a .sql association must be declared").toBeDefined();
// Alternate: appear under "Open With" without hijacking the default handler.
expect(sql!.rank).toBe("Alternate");
expect(sql!.role).toBe("Editor");
}); });
}); });
+11
View File
@@ -433,3 +433,14 @@ describe("v0.7.8 command wrappers exist", () => {
} }
}); });
}); });
describe("tool detection wrappers forward the force flag", () => {
it("passes force=true so an explicit re-check bypasses the Rust cache", async () => {
const { detectMysqlTools, detectPgTools } = await import("./commands");
vi.mocked(invoke).mockClear();
await detectMysqlTools(true);
expect(invoke).toHaveBeenCalledWith("detect_mysql_tools", { force: true });
await detectPgTools(true);
expect(invoke).toHaveBeenCalledWith("detect_pg_tools", { force: true });
});
});
+10 -5
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult, PendingSqlFile } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
import type { DiffReport } from "./types"; import type { DiffReport } from "./types";
@@ -147,8 +147,8 @@ export async function refreshConnection(connectionId: string): Promise<void> {
// ─── Backup / Restore / Sync ────────────────────────────────── // ─── Backup / Restore / Sync ──────────────────────────────────
export async function detectPgTools(): Promise<PgToolStatus> { export async function detectPgTools(force = false): Promise<PgToolStatus> {
return invoke<PgToolStatus>("detect_pg_tools"); return invoke<PgToolStatus>("detect_pg_tools", { force });
} }
export async function pgDump(connectionId: string, options: BackupOptions): Promise<string> { export async function pgDump(connectionId: string, options: BackupOptions): Promise<string> {
@@ -169,8 +169,8 @@ export async function cancelQuery(connectionId: string): Promise<void> {
return invoke<void>("cancel_query", { connectionId }); return invoke<void>("cancel_query", { connectionId });
} }
export async function detectMysqlTools(): Promise<MySqlToolStatus> { export async function detectMysqlTools(force = false): Promise<MySqlToolStatus> {
return invoke<MySqlToolStatus>("detect_mysql_tools"); return invoke<MySqlToolStatus>("detect_mysql_tools", { force });
} }
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> { export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
@@ -296,6 +296,11 @@ export async function executeQueryMulti(
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize }); return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
} }
/** Drain the OS-delivered `.sql` open requests buffered in the Rust backend. */
export async function takePendingSqlFiles(): Promise<PendingSqlFile[]> {
return invoke<PendingSqlFile[]>("take_pending_sql_files");
}
export async function exportQueryToFile( export async function exportQueryToFile(
connectionId: string, connectionId: string,
sql: string, sql: string,
+30 -9
View File
@@ -4,8 +4,9 @@ import { describe, it, expect } from "vitest";
import agents from "../../../AGENTS.md?raw"; import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
import landing from "../../../www/public/landing.js?raw"; import landing from "../../../www/public/landing.js?raw";
import roadmap from "../../../ROADMAP.md?raw";
describe("v0.8.1 docs coverage", () => { describe("v0.8.3 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -25,8 +26,8 @@ describe("v0.8.1 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.8.1", () => { it("README declares v0.8.3", () => {
expect(readme).toContain("0.8.1"); expect(readme).toContain("0.8.3");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -65,12 +66,32 @@ describe("v0.8.1 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/); expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/); expect(agents).toMatch(/Settings export\/import \| ✅/);
}); });
it("README links to v0.8.1 assets in both download tables", () => { it("README links to v0.8.3 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.8.1/"); expect(readme).toContain("releases/download/v0.8.3/");
expect(readme).toContain("Gridline_0.8.1_aarch64.dmg"); expect(readme).toContain("Gridline_0.8.3_aarch64.dmg");
expect(readme).toContain("Gridline-0.8.1-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.8.3-1.x86_64.rpm");
}); });
it("landing.js platform download links point to v0.8.1", () => { it("landing.js platform download links point to v0.8.3", () => {
expect(landing).toContain('const version = "0.8.1"'); expect(landing).toContain('const version = "0.8.3"');
});
it("AGENTS.md documents the native MySQL restore and the .sql association", () => {
// These claims would actively mislead the next contributor if they were
// missing, which is why they are asserted rather than trusted to review.
expect(agents).toMatch(/\.sql/);
expect(agents.toLowerCase()).toContain("in-process");
});
});
describe("v0.8.3 tool resolver docs", () => {
it("AGENTS.md documents the unified resolver and the nested resource path", () => {
expect(agents).toContain("tool_resolver");
expect(agents).toContain("resources/pg_tools");
expect(agents).toContain("resources/mysql_tools");
});
it("AGENTS.md documents that both engines share the resolver", () => {
expect(agents).toMatch(/mariadb-dump.*mysqldump|mysqldump.*mariadb-dump/s);
});
it("ROADMAP describes MySQL backup via mariadb-dump/mysqldump", () => {
expect(roadmap).toContain("mariadb-dump");
}); });
}); });
+16
View File
@@ -325,6 +325,10 @@ export interface PgToolStatus {
pg_restore_version: string | null; pg_restore_version: string | null;
pg_dump_source: string | null; pg_dump_source: string | null;
pg_restore_source: string | null; pg_restore_source: string | null;
pg_dump_resolved_name?: string | null;
pg_restore_resolved_name?: string | null;
pg_dump_bundled_available?: boolean;
pg_restore_bundled_available?: boolean;
} }
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ─────────── // ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
@@ -338,6 +342,11 @@ export interface MySqlToolStatus {
mysqlVersion: string | null; mysqlVersion: string | null;
mysqldumpSource: string | null; mysqldumpSource: string | null;
mysqlSource: string | null; mysqlSource: string | null;
mysqldumpResolvedName?: string | null;
mysqlResolvedName?: string | null;
mysqldumpIsMariaDb?: boolean;
mysqldumpBundledAvailable?: boolean;
mysqlBundledAvailable?: boolean;
} }
export interface MySqlBackupOptions { export interface MySqlBackupOptions {
@@ -528,3 +537,10 @@ export interface DiffReport {
target_label: string; target_label: string;
truncated: boolean; truncated: boolean;
} }
/** An OS-delivered `.sql` open request, already validated and read in Rust. */
export interface PendingSqlFile {
path: string;
name: string;
content: string;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.8.1 across the app shell", () => { it("declares v0.8.3 across the app shell", () => {
expect(pkg.version).toBe("0.8.1"); expect(pkg.version).toBe("0.8.3");
}); });
}); });
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const listeners = vi.hoisted(
() => ({} as Record<string, (e: { payload: unknown }) => void>),
);
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
listeners[name] = cb;
return () => {};
}),
}));
import { useBackupStore } from "./backupStore";
import { useNotificationStore } from "./notificationStore";
describe("backupStore advisory warning", () => {
beforeEach(() => {
useNotificationStore.setState({ notifications: [] });
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
});
it("surfaces a non-fatal warning as an info notification", async () => {
await useBackupStore.getState().initListener();
listeners["backup-progress"]({
payload: {
job_id: "j1",
status: "running",
progress: 0,
warning: "Client/server version mismatch: the resolved dump tool is old",
},
});
const notes = useNotificationStore.getState().notifications;
expect(notes.some((n) => n.message.includes("version mismatch"))).toBe(true);
expect(notes.some((n) => n.type === "info")).toBe(true);
});
});
+6 -1
View File
@@ -1,11 +1,13 @@
import { create } from "zustand"; import { create } from "zustand";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { BackupJob } from "../lib/types"; import type { BackupJob } from "../lib/types";
import { useNotificationStore } from "./notificationStore";
interface BackupJobEvent { interface BackupJobEvent {
job_id: string; job_id: string;
status: "running" | "completed" | "failed"; status: "running" | "completed" | "failed";
error?: string | null; error?: string | null;
warning?: string | null;
} }
interface BackupStore { interface BackupStore {
@@ -71,7 +73,10 @@ export const useBackupStore = create<BackupStore>((set, get) => ({
initListener: async () => { initListener: async () => {
await listen<BackupJobEvent>("backup-progress", (event) => { await listen<BackupJobEvent>("backup-progress", (event) => {
const { job_id, status, error } = event.payload; const { job_id, status, error, warning } = event.payload;
if (warning) {
useNotificationStore.getState().notify(warning, "info");
}
if (status === "completed") { if (status === "completed") {
get().completeJob(job_id); get().completeJob(job_id);
} else if (status === "failed") { } else if (status === "failed") {
+43
View File
@@ -638,3 +638,46 @@ describe("roles slice", () => {
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1); expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
}); });
}); });
describe("openQueryTab with an initial script", () => {
it("seeds the tab text and label from the payload", () => {
useDbViewerStore.setState({ tabs: [], activeTabId: null });
useDbViewerStore.getState().openQueryTab({
sql: "SELECT 1;",
label: "student_db.sql",
filePath: "/Users/me/student_db.sql",
});
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
expect(tab.tabType).toBe("query");
expect(tab.query).toBe("SELECT 1;");
expect(tab.table).toBe("student_db.sql");
expect(tab.filePath).toBe("/Users/me/student_db.sql");
});
it("focuses an existing tab for the same file instead of duplicating it", () => {
useDbViewerStore.setState({ tabs: [], activeTabId: null });
const payload = {
sql: "SELECT 1;",
label: "student_db.sql",
filePath: "/Users/me/student_db.sql",
};
useDbViewerStore.getState().openQueryTab(payload);
useDbViewerStore.getState().openQueryTab({ ...payload, sql: "SELECT 2;" });
const queryTabs = useDbViewerStore.getState().tabs.filter((t) => t.tabType === "query");
expect(queryTabs).toHaveLength(1);
// The already-open tab keeps its text: re-opening must not clobber edits.
expect(queryTabs[0].query).toBe("SELECT 1;");
});
it("still opens a plain empty tab when called with no payload", () => {
useDbViewerStore.setState({ tabs: [], activeTabId: null });
useDbViewerStore.getState().openQueryTab();
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
expect(tab.tabType).toBe("query");
expect(tab.query).toBe("");
expect(tab.filePath).toBeUndefined();
});
});
+18 -4
View File
@@ -66,6 +66,8 @@ export interface ViewerTab {
smartSortApplied: boolean; smartSortApplied: boolean;
tabType: "table" | "query" | "object" | "objectForm"; tabType: "table" | "query" | "object" | "objectForm";
query?: string; query?: string;
/** Set when the tab's text came from an OS-opened `.sql` file. */
filePath?: string;
objectType?: ObjectType | null; objectType?: ObjectType | null;
objectItem?: unknown; objectItem?: unknown;
form?: ViewerFormTabPayload; form?: ViewerFormTabPayload;
@@ -127,7 +129,7 @@ interface DbViewerState {
// Actions // Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void; openTab: (schema: string, table: string, forceNew?: boolean) => void;
openQueryTab: () => void; openQueryTab: (initial?: { sql?: string; label?: string; filePath?: string }) => void;
openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void; openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void;
openFormTab: (opts: { openFormTab: (opts: {
kind: ObjectKind; kind: ObjectKind;
@@ -262,13 +264,24 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
set({ tabs: [...tabs, tab], activeTabId: tab.id }); set({ tabs: [...tabs, tab], activeTabId: tab.id });
}, },
openQueryTab: () => { openQueryTab: (initial) => {
const { tabs, currentSchema } = get(); const { tabs, currentSchema } = get();
// Re-opening the same file focuses its tab instead of duplicating it, and
// deliberately does NOT overwrite the text — the user may have edited it.
if (initial?.filePath) {
const existing = tabs.find((t) => t.filePath === initial.filePath);
if (existing) {
set({ activeTabId: existing.id });
return;
}
}
const queryCount = tabs.filter((t) => t.tabType === "query").length; const queryCount = tabs.filter((t) => t.tabType === "query").length;
const tab: ViewerTab = { const tab: ViewerTab = {
id: `tab-${++tabCounter}`, id: `tab-${++tabCounter}`,
schema: currentSchema ?? "public", schema: currentSchema ?? "public",
table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`, table: initial?.label ?? (queryCount === 0 ? "Query" : `Query ${queryCount + 1}`),
page: 1, page: 1,
pageSize: get().defaultPageSize, pageSize: get().defaultPageSize,
loading: false, loading: false,
@@ -279,7 +292,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
hiddenColumns: [], hiddenColumns: [],
smartSortApplied: false, smartSortApplied: false,
tabType: "query", tabType: "query",
query: "", query: initial?.sql ?? "",
filePath: initial?.filePath,
}; };
set({ tabs: [...tabs, tab], activeTabId: tab.id }); set({ tabs: [...tabs, tab], activeTabId: tab.id });
}, },
+16 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { useUiStore } from "./uiStore"; import { useUiStore } from "./uiStore";
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null })); beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null, pendingSqlFile: null }));
describe("uiStore", () => { describe("uiStore", () => {
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home")); it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
@@ -95,3 +95,18 @@ describe("uiStore", () => {
expect(useUiStore.getState().settingsReturnView).toBeNull(); expect(useUiStore.getState().settingsReturnView).toBeNull();
}); });
}); });
describe("pending SQL file stash", () => {
it("stores and clears a file awaiting a connection", () => {
const file = { path: "/tmp/a.sql", name: "a.sql", content: "SELECT 1;" };
useUiStore.getState().setPendingSqlFile(file);
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
useUiStore.getState().clearPendingSqlFile();
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
it("defaults to no pending file", () => {
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
});
+8 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { ActiveView, DbType } from "../lib/types"; import type { ActiveView, DbType, PendingSqlFile } from "../lib/types";
interface UiState { interface UiState {
searchQuery: string; searchQuery: string;
@@ -27,10 +27,14 @@ interface UiState {
setPrefilledConnectionString: (value: string) => void; setPrefilledConnectionString: (value: string) => void;
clearPrefilledConnectionString: () => void; clearPrefilledConnectionString: () => void;
setActiveConnectionId: (id: string | null) => void; setActiveConnectionId: (id: string | null) => void;
/** A `.sql` file opened from the OS while no connection was active. */
pendingSqlFile: PendingSqlFile | null;
setPendingSqlFile: (file: PendingSqlFile | null) => void;
clearPendingSqlFile: () => void;
} }
export const useUiStore = create<UiState>((set) => ({ export const useUiStore = create<UiState>((set) => ({
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null, searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null, pendingSqlFile: null,
setActiveView: (view) => set({ activeView: view }), setActiveView: (view) => set({ activeView: view }),
openSettings: () => set((s) => ({ openSettings: () => set((s) => ({
settingsReturnView: settingsReturnView:
@@ -61,4 +65,6 @@ export const useUiStore = create<UiState>((set) => ({
setPrefilledConnectionString: (value) => set({ prefilledConnectionString: value }), setPrefilledConnectionString: (value) => set({ prefilledConnectionString: value }),
clearPrefilledConnectionString: () => set({ prefilledConnectionString: null }), clearPrefilledConnectionString: () => set({ prefilledConnectionString: null }),
setActiveConnectionId: (id) => set({ activeConnectionId: id }), setActiveConnectionId: (id) => set({ activeConnectionId: id }),
setPendingSqlFile: (file) => set({ pendingSqlFile: file }),
clearPendingSqlFile: () => set({ pendingSqlFile: null }),
})); }));
@@ -0,0 +1,63 @@
# Smoke checklist — native MySQL execution (0.8.2)
These flows cannot be automated on this machine: `RunEvent::Opened` only fires
for a bundled `.app` registered with LaunchServices, and a real MySQL 8 restore
needs a live server. Run every box before publishing a draft release.
Build first (both scripts exist — `build` in `desktop/package.json`, the Tauri
CLI via the `tauri` script in `desktop/package.json`):
bun run --filter gridline-desktop build
bun run --filter gridline-desktop tauri build
## A. Association and OS open
- [ ] The built `.app` appears under **Finder → right-click a `.sql` file → Open With**.
- [ ] The previous default handler for `.sql` is **unchanged** (rank is Alternate).
Refresh LaunchServices if it is stale:
`/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f /Applications/Gridline.app`
- [ ] With Gridline **closed**, double-click a `.sql` file → the app launches with a
query tab named after the file, containing the file's text.
- [ ] With Gridline **running**, open another `.sql` file → a second tab appears
(no second app instance, and no duplicate of the first tab).
- [ ] Open the **same** file twice → the existing tab is focused; edits in it are NOT reset.
- [ ] With **no connection open**, open a `.sql` file → Home is shown with a banner
naming the file; after connecting, the tab opens automatically and the banner clears.
- [ ] Open a file larger than 5 MB → nothing opens and no freeze occurs (a file over
`MAX_SQL_FILE_BYTES` is rejected by the buffer and must be routed to Tools → Restore,
whose own cap is 100 MB).
- [ ] Open a `.txt` file via Open With → Gridline ignores it (no tab, no error dialog).
- [ ] The notarized `Gridline_0.8.2_*.dmg` still carries the association and still launches.
## B. Cell decoding (issue #41)
- [ ] Connect to a MySQL database and open a table with `INT`/`BIGINT` columns:
the primary key renders its values, **not** NULL.
- [ ] Columns of type `DECIMAL`, `DATE`, `DATETIME`, `TIMESTAMP`, `FLOAT`, `TINYINT`,
`BIT` and `YEAR` all render values.
- [ ] Run the reporter's exact statement and confirm the ids are values:
`SELECT id, uuid, first_name FROM users ORDER BY id;`
- [ ] A `BIGINT` column renders as a string and a `DECIMAL` renders with its exact
digits (no float rounding).
- [ ] A `VARCHAR` column still renders as before (no regression on the types that worked).
## C. Native restore
- [ ] Tools → Restore against a MySQL 8 server whose user authenticates with
`caching_sha2_password` (the case the bundled MariaDB client cannot reach):
the restore completes.
- [ ] The MySQL restore form is usable even when no `mariadb`/`mysql` client is installed.
- [ ] `clean` ON into a **non-empty** target: objects the dump defines are replaced;
an unrelated table created beforehand **survives**.
- [ ] `clean` OFF into a non-empty target: fails with `table already exists`, and the
error names the statement position.
- [ ] A dump containing a trigger or stored routine (with `DELIMITER`) restores without
a syntax error.
- [ ] Cancel a long restore → it stops and reports cancellation rather than running to completion.
- [ ] The form states that a failed MySQL restore cannot be rolled back.
## D. Suite
- [ ] `cd desktop/src-tauri && cargo test --lib` → 0 failures.
- [ ] `cd desktop && bun run test` → 0 failures.
- [ ] `cd desktop && bunx tsc --noEmit` → clean.
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
#
# post-merge hook — after `git pull`/merge, refresh the Gitea fallback.
#
# Installed via `scripts/install-git-hooks.sh` (git core.hooksPath = scripts/hooks).
# GitHub merges (e.g. a squash-merged PR) happen server-side, so a local
# `git pull` on prod is the moment to bring the Gitea mirror along.
#
# args: $1 = 1 when the merge was a squash, otherwise 0
if [ "${GRIDLINE_MIRROR_RUNNING:-}" = "1" ]; then
exit 0
fi
branch="$(git symbolic-ref --short -q HEAD)" || exit 0
case "$branch" in
prod|main) ;;
*) exit 0 ;;
esac
root="$(git rev-parse --show-toplevel)" || exit 0
GRIDLINE_MIRROR_RUNNING=1 sh "$root/scripts/mirror-gitea.sh" ||
echo "post-merge: warning: Gitea mirror was not updated" >&2
exit 0
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
#
# pre-push hook — mirrors the trunk branch and tags to the Gitea fallback.
#
# Installed via `scripts/install-git-hooks.sh` (git core.hooksPath = scripts/hooks).
# Git has no client-side post-push hook, so the mirror runs here: the refs are
# already committed locally, so whether Gitea or GitHub receives them first makes
# no difference. A Gitea outage prints a warning and never blocks the push.
#
# stdin receives one line per ref being pushed:
# <local-ref> <local-sha> <remote-ref> <remote-sha>
#
# args: $1 = remote name, $2 = remote URL
remote_url="${2:-}"
# Only react to pushes of the canonical GitHub remote.
case "$remote_url" in
*github.com*) ;;
*) exit 0 ;;
esac
# Don't recurse when the mirror script itself is pushing to Gitea.
if [ "${GRIDLINE_MIRROR_RUNNING:-}" = "1" ]; then
exit 0
fi
mirror_needed=0
while read -r _local_ref local_sha remote_ref _remote_sha; do
# Ignore deletions (all-zero object id).
case "$local_sha" in
0000000000000000000000000000000000000000) continue ;;
esac
case "$remote_ref" in
refs/heads/prod|refs/heads/main|refs/tags/*) mirror_needed=1 ;;
esac
done
[ "$mirror_needed" = "1" ] || exit 0
root="$(git rev-parse --show-toplevel)" || exit 0
GRIDLINE_MIRROR_RUNNING=1 sh "$root/scripts/mirror-gitea.sh" ||
echo "pre-push: warning: Gitea mirror was not updated (continuing with the push)" >&2
# Never block the push to GitHub because of a mirror problem.
exit 0
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Install the repo's git hooks (once per clone).
#
# Points `core.hooksPath` at `scripts/hooks/` so the hook files are versioned
# with the repo instead of copied into `.git/hooks` (a stale copy can't drift
# out of date this way). Currently:
#
# pre-push — mirror prod + tags to the Gitea fallback (git.ranio.xyz)
# post-merge — same, after a `git pull`/merge while on prod
#
# The mirror itself lives in `scripts/mirror-gitea.sh`; run it by hand any time
# with `scripts/mirror-gitea.sh`. Gitea is a just-in-case copy: GitHub is the
# source of truth, and a Gitea outage never blocks a push.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "install-git-hooks: not inside a git repository" >&2
exit 1
}
cd "$repo_root"
if [ ! -d scripts/hooks ]; then
echo "install-git-hooks: scripts/hooks/ is missing — run this from the repo root" >&2
exit 1
fi
chmod +x scripts/hooks/* scripts/mirror-gitea.sh 2>/dev/null || true
git config core.hooksPath scripts/hooks
gitea_remote="${GRIDLINE_GITEA_REMOTE:-origin}"
echo "install-git-hooks: core.hooksPath -> scripts/hooks"
for hook in scripts/hooks/*; do
echo "install-git-hooks: $(basename "$hook")"
done
echo "install-git-hooks: prod + tags mirror to the '$gitea_remote' remote on push/pull"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Keep the Gitea "just in case" mirror (git.ranio.xyz) in step with GitHub.
#
# GitHub (`AdrianBonpin/gridline`, remote `github`) is the source of truth; the
# Gitea remote (`origin`) exists only as a fallback copy of the code, so this
# mirrors the trunk branch and every tag — not feature branches.
#
# Run it manually after a merge that happened on GitHub, or let the repo's git
# hooks call it (`scripts/install-git-hooks.sh` wires up `core.hooksPath`):
# * pre-push — before pushing prod/tags to GitHub
# * post-merge — after `git pull`/merge while on prod
#
# Exit status is non-zero when the mirror could not be updated, so callers can
# decide whether that is fatal. The hooks treat it as a warning: a Gitea outage
# must never block a push to GitHub.
#
# Usage:
# scripts/mirror-gitea.sh # mirror prod + all tags
# GRIDLINE_GITEA_REMOTE=mirror scripts/… # use another remote name
# scripts/mirror-gitea.sh --branches # also mirror every local branch
set -uo pipefail
GITEA_REMOTE="${GRIDLINE_GITEA_REMOTE:-origin}"
MIRROR_BRANCHES=0
TRUNK_BRANCH="${GRIDLINE_TRUNK_BRANCH:-prod}"
for arg in "$@"; do
case "$arg" in
--branches) MIRROR_BRANCHES=1 ;;
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "mirror-gitea: unknown argument '$arg' (try --help)" >&2; exit 2 ;;
esac
done
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "mirror-gitea: not inside a git repository" >&2
exit 1
}
cd "$repo_root" || exit 1
url="$(git remote get-url "$GITEA_REMOTE" 2>/dev/null || true)"
if [ -z "$url" ]; then
echo "mirror-gitea: remote '$GITEA_REMOTE' not found — nothing to mirror" >&2
exit 1
fi
# Safety: never push to GitHub through this script (it is the source, and a
# mis-pointed remote could otherwise rewrite the wrong repository).
case "$url" in
*github.com*)
echo "mirror-gitea: '$GITEA_REMOTE' points at GitHub ($url) — refusing to mirror" >&2
exit 1
;;
esac
status=0
note() { printf 'mirror-gitea: %s\n' "$1" >&2; }
# Trunk branch. Only mirror it when it exists locally; a fresh clone with just
# the feature branch checked out still mirrors the tags below.
if git show-ref --verify --quiet "refs/heads/$TRUNK_BRANCH"; then
if ! git push "$GITEA_REMOTE" "refs/heads/$TRUNK_BRANCH:refs/heads/$TRUNK_BRANCH"; then
status=1
note "could not update $TRUNK_BRANCH"
fi
fi
# Every local tag, so release tags appear on the mirror too (`--tags` is
# idempotent — already-present tags are skipped).
if [ -n "$(git tag --list)" ]; then
if ! git push "$GITEA_REMOTE" --tags; then
status=1
note "could not update tags"
fi
fi
# Optional: every local branch (off by default — feature branches are ephemeral).
if [ "$MIRROR_BRANCHES" = "1" ]; then
if ! git push "$GITEA_REMOTE" --all; then
status=1
note "could not update branches"
fi
fi
if [ "$status" = "0" ]; then
note "mirror up to date ($url)"
fi
exit "$status"
+28 -28
View File
@@ -1,25 +1,29 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Build Gridline for macOS on this machine and upload the DMGs to the Gitea # LOCAL FALLBACK: build Gridline for macOS on this machine and upload the signed
# release for a given tag. macOS is built manually (NOT auto-built on tag); # + notarized DMGs to the GitHub release for a given tag.
# the Linux and Windows installers are built by the Gitea Actions workflow #
# (.gitea/workflows/release.yml) and already uploaded to the same release. # macOS is normally built, Developer-ID signed and notarized by GitHub Actions
# (.github/workflows/release.yml) using the APPLE_* repository secrets, together
# with the Windows and Linux installers. Reach for this script when you want a
# signed build without spending CI minutes, or when Actions is unavailable.
# #
# Usage: # Usage:
# TAG=v0.7.11 ./scripts/release-mac.sh # TAG=v0.8.3 ./scripts/release-mac.sh
# #
# The release is keyed by tag. The Linux/Windows job creates it on the first # The release is keyed by tag: the Actions job creates the draft on a tag push,
# tag push; this script finds it (creating it only if it somehow doesn't exist) # and this script finds it (creating it only if it somehow doesn't exist) and
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg # attaches/replaces the two DMGs — Gridline_<ver>_aarch64.dmg and
# Gridline_<ver>_x64.dmg. Uploads use --clobber, so a locally signed DMG
# supersedes the CI one; publish the draft afterwards on GitHub.
# #
# Auth: uses the `tea` CLI (OAuth token, auto-refreshed). # Auth: the `gh` CLI (GitHub is the only release target; the Gitea mirror is
# code-only).
set -euo pipefail set -euo pipefail
# ---- config --------------------------------------------------------------- # ---- config ---------------------------------------------------------------
GITEA_SERVER="${GITEA_SERVER:-https://git.ranio.xyz}" GH_REPO="${GH_REPO:-AdrianBonpin/gridline}"
REPO_OWNER="${REPO_OWNER:-adrianbonpin}"
REPO_NAME="${REPO_NAME:-gridline}"
TAG="${TAG:-${1:-}}" TAG="${TAG:-${1:-}}"
if [ -z "$TAG" ]; then if [ -z "$TAG" ]; then
@@ -27,13 +31,10 @@ if [ -z "$TAG" ]; then
exit 1 exit 1
fi fi
# ---- resolve auth (tea CLI) ------------------------------------------------ # ---- resolve auth (gh CLI) -------------------------------------------------
# The `tea` CLI carries the OAuth token and refreshes it automatically, so the if ! command -v gh >/dev/null 2>&1; then
# release upload below uses `tea` rather than a raw curl + token. (The git echo "error: gh CLI not found (needed to upload release assets)" >&2
# credential store can hold a stale access token and 401.) echo " install it: https://cli.github.com" >&2
if ! command -v tea >/dev/null 2>&1; then
echo "error: tea CLI not found (needed to upload release assets)" >&2
echo " install it: https://gitea.com/gitea/tea" >&2
exit 1 exit 1
fi fi
@@ -229,21 +230,20 @@ else
fi fi
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- create/ensure release + upload DMGs -----------------------------------
# Use `tea` for release management — it handles OAuth token refresh, unlike a
# raw curl with a token from the git credential store (which can 401).
cd "${WORKSPACE}" cd "${WORKSPACE}"
echo ">> Ensuring release ${TAG} exists..." echo ">> Ensuring release ${TAG} exists on ${GH_REPO}..."
if ! tea release list -o json 2>/dev/null | grep -q "\"${TAG}\""; then if ! gh release view "${TAG}" --repo "${GH_REPO}" >/dev/null 2>&1; then
tea release create --tag "${TAG}" --title "Gridline ${VERSION}" \ gh release create "${TAG}" --repo "${GH_REPO}" --title "Gridline ${VERSION}" \
--note "Gridline ${VERSION} — macOS (built manually)." --draft --notes "Gridline ${VERSION} — macOS (built locally, signed + notarized)." --draft
else else
echo ">> Release ${TAG} already exists." echo ">> Release ${TAG} already exists."
fi fi
echo ">> Uploading macOS DMGs..." # --clobber replaces the CI-built (ad-hoc) DMGs with this signed + notarized pair.
tea releases assets create "${TAG}" \ echo ">> Uploading macOS DMGs (replacing any existing ones)..."
gh release upload "${TAG}" --repo "${GH_REPO}" --clobber \
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \ "${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg" "${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}" echo "Done. Review + publish the draft: https://github.com/${GH_REPO}/releases/tag/${TAG}"
+1 -1
View File
@@ -60,7 +60,7 @@
const keepLabel = downloadBtn.hasAttribute("data-download-label"); const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases"; const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
const version = "0.8.1"; const version = "0.8.3";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
+19
View File
@@ -5,6 +5,25 @@ export interface ChangelogEntry {
} }
export const changelogEntries: ChangelogEntry[] = [ export const changelogEntries: ChangelogEntry[] = [
{
version: "0.8.3",
date: "2026-09-30",
highlights: [
"External tools are found again, and this is the fix for issue #44 — Gridline reported “mysqldump not found” even with the tools installed, for three independent reasons. The bundled binaries were looked up one directory too high (`<resource_dir>/mysql_tools` instead of the `<resource_dir>/resources/mysql_tools` Tauri actually bundles), which silently disabled the bundled fallback for PostgreSQL as well as MySQL; a macOS GUI app inherits launchd's minimal `PATH` rather than your shell's, so Homebrew's `/opt/homebrew/bin` was invisible; and only `mariadb-dump`/`mariadb` were ever probed, never `mysqldump`/`mysql` — which is what `brew install mysql-client` actually installs. Both engines now resolve through one ordered chain: trusted absolute directories (Homebrew, MacPorts, Postgres.app, `/usr/local`), then your inherited `PATH`, then the bundled copy, where a candidate counts only if it is a real file that runs `--version`. Results are cached, and an explicit **Check again** re-detects.",
"The bundled MySQL client works too. The dump step was passing MySQL-only options to the bundled MariaDB binary (`--databases=<db>`, `--skip-column-statistics`, `--ssl-mode=…`), so the job aborted at option parsing before it ever connected. Options are now tailored per client — MariaDB clients get `--ssl` and a positional `--databases` — so backup and DB sync succeed with either client.",
"Backup, Restore, DB Sync and Schema Diff now tell you which tool they are using and where it came from (`Using system mysqldump`, `Using bundled mariadb-dump`), say so explicitly when a bundled copy is present but cannot run, and let you copy the install command in one click. A dump client whose major version is older than the server's produces a **non-blocking** warning at job start instead of silence. Those four pages also moved onto the same form layout the Objects view uses.",
"The app icon is back to its previous rendering — at the maintainer's request the macOS “legacy plate” strip added in #40 was reverted, so the Dock and launcher icon can show a light border again below roughly 72px.",
],
},
{
version: "0.8.2",
date: "2026-09-29",
highlights: [
"MySQL columns now show their values. Every non-string MySQL type was decoding to NULL, so `id`/integer columns looked empty in the grid and the query editor — and it was not only integers: `DECIMAL`, `BIT`, `YEAR`, `DATE`, `TIME`, `DATETIME`, `DATETIME(6)`, `TIMESTAMP`, `FLOAT` and `DOUBLE` were affected too (21 of 30 types). Decoding is now driven by each column's declared type instead of a guess, 64-bit integers and `DECIMAL` travel as strings so no digits are lost, and `DATETIME(6)` keeps its microseconds. Temporal values are shown as stored, never silently shifted into another time zone.",
"MySQL restore now works against MySQL 8. It used to shell out to a bundled MariaDB client, which cannot authenticate with MySQL 8's default `caching_sha2_password`, so restore failed with `ERROR 1045` (or `ERROR 2026` once TLS was involved) while browsing worked fine. Restore now runs in-process over the same driver that browsing uses: it stops at the first error and tells you which statement failed, it can be cancelled, and it understands the `DELIMITER` directive so dumps containing triggers and stored routines apply correctly. The **Clean** toggle is now real — it drops only the objects the dump recreates, so an unrelated table in the target survives — and because MySQL DDL commits as it runs, the form warns that a failed restore cannot be rolled back.",
"`.sql` files can be opened with Gridline straight from your file manager. Right-click a script → Open With → Gridline (it appears as an alternate handler, so your default for `.sql` is left alone) and it opens in a query tab named after the file. Opening never executes anything — Run still goes through the destructive-query confirmation — and re-opening the same file focuses the existing tab without discarding edits. If no connection is open, the script is held and Home prompts you to pick one, so a double-click never dead-ends.",
],
},
{ {
version: "0.8.1", version: "0.8.1",
date: "2026-09-24", date: "2026-09-24",
+1 -1
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png"; import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png"; import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png"; import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.8.1"; const version = "0.8.3";
const site = "https://getgridline.app"; const site = "https://getgridline.app";
const jsonLd = [ const jsonLd = [
buildSoftwareApplicationJsonLd({ buildSoftwareApplicationJsonLd({