Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e3b96bad | ||
|
|
daa38d163e | ||
|
|
092b3b2831 | ||
|
|
88e7e45818 | ||
|
|
ce4a73b0bb | ||
|
|
8b08ad0e46 | ||
|
|
b17bf95b6d |
@@ -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) | ❌ | |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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": {
|
||||||
|
|||||||
Generated
+39
-1
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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));
|
||||||
a.push("--skip-column-statistics".into());
|
if !client_is_mariadb {
|
||||||
|
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(¶ms, &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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?`.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match row.try_get::<u64, _>(i) {
|
||||||
|
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||||
|
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||||
|
Err(_) => serde_json::Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
|
}
|
||||||
return s
|
|
||||||
.map(|s| serde_json::Value::String(s))
|
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
|
||||||
.unwrap_or(serde_json::Value::Null);
|
/// 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::<Option<Vec<u8>>, _>(i) {
|
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||||
return b
|
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
|
|
||||||
.unwrap_or(serde_json::Value::Null);
|
|
||||||
}
|
}
|
||||||
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:?}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -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(¤t) {
|
||||||
|
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(¤t) {
|
|
||||||
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\""));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +126,18 @@ 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>
|
||||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
<div className="space-y-1">
|
||||||
{getPlatformInstructions()}
|
<div className="flex items-center justify-between gap-2">
|
||||||
</pre>
|
<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">
|
||||||
|
{installInstructions}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -83,4 +83,105 @@ describe("BackupPage DB-aware", () => {
|
|||||||
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
||||||
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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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(
|
||||||
setCheckingTools(true);
|
async (force: boolean) => {
|
||||||
setPgToolStatus(null);
|
setCheckingTools(true);
|
||||||
setMysqlToolStatus(null);
|
setPgToolStatus(null);
|
||||||
setAvailableSchemas([]);
|
setMysqlToolStatus(null);
|
||||||
|
|
||||||
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)
|
} else if (isMysql) {
|
||||||
.then((schemas) => setAvailableSchemas(schemas))
|
try {
|
||||||
.catch(() => setAvailableSchemas([]));
|
setMysqlToolStatus(await detectMysqlTools(force));
|
||||||
} else if (isMysql) {
|
} catch {
|
||||||
detectMysqlTools()
|
|
||||||
.then((status) => setMysqlToolStatus(status))
|
|
||||||
.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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCheckingTools(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPg, isMysql],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvailableSchemas([]);
|
||||||
|
void checkTools(false);
|
||||||
|
if (isPg || isMysql) {
|
||||||
getSchemas(connectionId)
|
getSchemas(connectionId)
|
||||||
.then((schemas) => setAvailableSchemas(schemas))
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
.catch(() => setAvailableSchemas([]));
|
.catch(() => setAvailableSchemas([]));
|
||||||
} else {
|
|
||||||
setCheckingTools(false);
|
|
||||||
}
|
}
|
||||||
}, [connectionId, isPg, isMysql]);
|
}, [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,271 +304,259 @@ 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">
|
||||||
<HardDrive size={14} className="text-accent" />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text">Backup</span>
|
<HardDrive size={14} className="text-accent" />
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-xs font-medium text-text">Backup</span>
|
||||||
{headerDescription}
|
<span className="text-[11px] text-text-muted">
|
||||||
</span>
|
{headerDescription}
|
||||||
|
</span>
|
||||||
|
</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>
|
</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="px-4 py-3 text-sm text-text-muted">
|
||||||
<div className="glass p-4 text-center">
|
{checkingMessage}
|
||||||
<p className="text-sm text-text-muted">
|
</div>
|
||||||
{checkingMessage}
|
)}
|
||||||
</p>
|
|
||||||
</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">
|
||||||
|
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client
|
||||||
|
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">
|
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
A bundled copy is present but could not run — reinstall
|
||||||
backup/restore operations. Install them using:
|
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(
|
<div className="space-y-1">
|
||||||
isPg
|
<div className="flex items-center justify-between gap-2">
|
||||||
? PG_INSTALL_INSTRUCTIONS
|
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
<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">
|
||||||
{/* Format */}
|
<span className="px-3 font-heading text-xs text-text">
|
||||||
{isPg ? (
|
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
|
||||||
<div className="space-y-1">
|
{dumpResolvedName ?? dumpFallbackName}
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
</span>
|
||||||
Format
|
</FormRow>
|
||||||
</label>
|
)}
|
||||||
<select
|
|
||||||
value={format}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormat(
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="custom">
|
|
||||||
Custom Archive
|
|
||||||
</option>
|
|
||||||
<option value="plain">Plain SQL</option>
|
|
||||||
<option value="tar">Tarball</option>
|
|
||||||
<option value="directory">
|
|
||||||
Directory
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : isMysql ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
|
||||||
Format
|
|
||||||
</label>
|
|
||||||
<div className="text-sm text-text py-2">
|
|
||||||
Plain SQL
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Output file */}
|
{/* Format */}
|
||||||
<div className="space-y-1 w-full">
|
{isPg ? (
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<FormRow label="Format">
|
||||||
Output File
|
<select
|
||||||
</label>
|
value={format}
|
||||||
<div className="flex gap-2">
|
onChange={(e) =>
|
||||||
<input
|
setFormat(e.target.value as BackupFormat)
|
||||||
type="text"
|
}
|
||||||
value={filePath}
|
className={`${controlClass} mr-3`}
|
||||||
onChange={(e) =>
|
>
|
||||||
setFilePath(e.target.value)
|
<option value="custom">Custom Archive</option>
|
||||||
}
|
<option value="plain">Plain SQL</option>
|
||||||
placeholder="/path/to/backup.dump"
|
<option value="tar">Tarball</option>
|
||||||
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"
|
<option value="directory">Directory</option>
|
||||||
/>
|
</select>
|
||||||
<button
|
</FormRow>
|
||||||
type="button"
|
) : isMysql ? (
|
||||||
onClick={handlePickFile}
|
<FormRow label="Format">
|
||||||
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"
|
<span className="px-3 font-heading text-xs text-text">
|
||||||
aria-label="Browse for file"
|
Plain SQL
|
||||||
>
|
</span>
|
||||||
<FolderOpen size={15} />
|
</FormRow>
|
||||||
</button>
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schema (optional) */}
|
{/* Output file */}
|
||||||
{(isPg || isMysql) && (
|
<FormRow label="Output file">
|
||||||
<div className="space-y-1">
|
<input
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
type="text"
|
||||||
Schema{" "}
|
value={filePath}
|
||||||
<span className="font-normal normal-case tracking-normal">
|
onChange={(e) => setFilePath(e.target.value)}
|
||||||
(optional)
|
placeholder="/path/to/backup.dump"
|
||||||
</span>
|
className={inputClass}
|
||||||
</label>
|
/>
|
||||||
<select
|
<button
|
||||||
value={schema}
|
type="button"
|
||||||
onChange={(e) =>
|
onClick={handlePickFile}
|
||||||
setSchema(e.target.value)
|
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"
|
||||||
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"
|
>
|
||||||
>
|
<FolderOpen size={13} />
|
||||||
<option value="">All schemas</option>
|
</button>
|
||||||
{availableSchemas.map((s) => (
|
</FormRow>
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PostgreSQL: no-owner toggle */}
|
{/* Schema */}
|
||||||
{isPg && (
|
{(isPg || isMysql) && (
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isPg || isMysql) && (
|
||||||
|
<FormSectionHeader label="Options" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PostgreSQL: no-owner toggle */}
|
||||||
|
{isPg && (
|
||||||
|
<FormRow label="No owner">
|
||||||
|
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={noOwner}
|
||||||
|
onChange={(e) => setNoOwner(e.target.checked)}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
--no-owner
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MySQL: option toggles */}
|
||||||
|
{isMysql && (
|
||||||
|
<>
|
||||||
|
<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={noOwner}
|
checked={singleTransaction}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNoOwner(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">
|
||||||
No Owner{" "}
|
--single-transaction
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
</code>
|
||||||
--no-owner
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
</FormRow>
|
||||||
|
<FormRow label="No data">
|
||||||
|
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={noData}
|
||||||
|
onChange={(e) => setNoData(e.target.checked)}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
--no-data
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Routines">
|
||||||
|
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={routines}
|
||||||
|
onChange={(e) => setRoutines(e.target.checked)}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
--routines
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Triggers">
|
||||||
|
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={triggers}
|
||||||
|
onChange={(e) => setTriggers(e.target.checked)}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
--triggers
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Events">
|
||||||
|
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={events}
|
||||||
|
onChange={(e) => setEvents(e.target.checked)}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
--events
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MySQL: option toggles */}
|
{/* Progress */}
|
||||||
{isMysql && (
|
{activeJob && (
|
||||||
<div className="space-y-2">
|
<div className="px-4 py-3">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
<BackupProgress
|
||||||
<input
|
progress={activeJob.status === "completed" ? 100 : 50}
|
||||||
type="checkbox"
|
jobType="dump"
|
||||||
checked={singleTransaction}
|
status={activeJob.status}
|
||||||
onChange={(e) =>
|
errorMessage={activeJob.error_message ?? undefined}
|
||||||
setSingleTransaction(e.target.checked)
|
/>
|
||||||
}
|
|
||||||
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">
|
|
||||||
Single Transaction{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
--single-transaction
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={noData}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNoData(e.target.checked)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
No Data{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
--no-data
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={routines}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRoutines(e.target.checked)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
Routines{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
--routines
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={triggers}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTriggers(e.target.checked)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
Triggers{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
--triggers
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={events}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEvents(e.target.checked)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
Events{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
--events
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Progress */}
|
</>
|
||||||
{activeJob && (
|
)}
|
||||||
<div className="px-4">
|
|
||||||
<BackupProgress
|
|
||||||
progress={activeJob.status === "completed" ? 100 : 50}
|
|
||||||
jobType="dump"
|
|
||||||
status={activeJob.status}
|
|
||||||
errorMessage={activeJob.error_message ?? undefined}
|
|
||||||
/>
|
|
||||||
</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,9 +112,18 @@ 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>
|
||||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
<div className="space-y-1">
|
||||||
{getPlatformInstructions()}
|
<div className="flex items-center justify-between gap-2">
|
||||||
</pre>
|
<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">
|
||||||
|
{installInstructions}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
: false;
|
||||||
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
|
|
||||||
: false;
|
|
||||||
const toolsBundled = isPg
|
const toolsBundled = isPg
|
||||||
? pgToolStatus?.pg_restore_source === "bundled"
|
? pgToolStatus?.pg_restore_source === "bundled"
|
||||||
: isMysql
|
: false;
|
||||||
? mysqlToolStatus?.mysqlSource === "bundled"
|
|
||||||
: 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,212 +234,227 @@ 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">
|
||||||
<Upload size={14} className="text-accent" />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text">Restore</span>
|
<Upload size={14} className="text-accent" />
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-xs font-medium text-text">Restore</span>
|
||||||
{headerDescription}
|
<span className="text-[11px] text-text-muted">
|
||||||
</span>
|
{headerDescription}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{/* Content */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
|
||||||
{/* Tool check */}
|
|
||||||
{checkingTools && checkingMessage && (
|
|
||||||
<div className="glass p-4 text-center">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
{checkingMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toolsMissing && !toolsBundled && (
|
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
|
||||||
<p className="text-amber-300 text-sm font-semibold">
|
|
||||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
|
||||||
</p>
|
|
||||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
|
||||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
|
||||||
backup/restore operations. Install them using:
|
|
||||||
</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,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!checkingTools && !toolsMissing && (
|
{!checkingTools && !toolsMissing && (
|
||||||
<>
|
<button
|
||||||
{/* Configuration card */}
|
type="button"
|
||||||
<div className="p-5 space-y-5">
|
onClick={() => void handleStartRestore()}
|
||||||
{/* Format (PostgreSQL only) */}
|
disabled={!canStart}
|
||||||
{isPg && (
|
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"
|
||||||
<div className="space-y-1">
|
>
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<Upload size={14} className="mr-1.5" />
|
||||||
Format
|
{isRunning ? "Restoring..." : "Start Restore"}
|
||||||
</label>
|
</button>
|
||||||
<select
|
|
||||||
value={format}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormat(e.target.value)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
Custom Archive
|
|
||||||
</option>
|
|
||||||
<option value="plain">Plain SQL</option>
|
|
||||||
<option value="tar">Tarball</option>
|
|
||||||
<option value="directory">
|
|
||||||
Directory
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Backup file */}
|
|
||||||
<div className="space-y-1 w-full">
|
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
|
||||||
Backup File
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={filePath}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilePath(e.target.value)
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<FileSearch size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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)
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
{availableSchemas.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clean toggle */}
|
|
||||||
{(isPg || isMysql || isSqlite) && (
|
|
||||||
<label
|
|
||||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
|
||||||
isPg && format === "plain"
|
|
||||||
? "opacity-40 pointer-events-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={clean}
|
|
||||||
onChange={(e) =>
|
|
||||||
setClean(e.target.checked)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
Clean{" "}
|
|
||||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
||||||
DROP before CREATE
|
|
||||||
</code>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{isPg && format === "plain" && (
|
|
||||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
|
||||||
Plain SQL restores run via psql and don't
|
|
||||||
support DROP-before-CREATE. Use Custom
|
|
||||||
Archive for clean restores.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destructive confirmation */}
|
|
||||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={confirmed}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfirmed(e.target.checked)
|
|
||||||
}
|
|
||||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
|
||||||
data-testid="restore-confirm-checkbox"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
|
||||||
I understand this will overwrite data on
|
|
||||||
the target database. This action cannot
|
|
||||||
be undone.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
{activeJob && (
|
|
||||||
<div className="px-4">
|
|
||||||
<BackupProgress
|
|
||||||
progress={activeJob.status === "completed" ? 100 : 50}
|
|
||||||
jobType="restore"
|
|
||||||
status={activeJob.status}
|
|
||||||
errorMessage={activeJob.error_message ?? undefined}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Tool check */}
|
||||||
|
{checkingTools && checkingMessage && (
|
||||||
|
<div className="px-4 py-3 text-sm text-text-muted">
|
||||||
|
{checkingMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolsMissing && !toolsBundled && (
|
||||||
|
<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">
|
||||||
|
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||||
|
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checkingTools && !toolsMissing && (
|
||||||
|
<>
|
||||||
|
{/* Restore tool */}
|
||||||
|
{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) */}
|
||||||
|
{isPg && (
|
||||||
|
<FormRow label="Format">
|
||||||
|
<select
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className={`${controlClass} mr-3`}
|
||||||
|
>
|
||||||
|
<option value="custom">Custom Archive</option>
|
||||||
|
<option value="plain">Plain SQL</option>
|
||||||
|
<option value="tar">Tarball</option>
|
||||||
|
<option value="directory">Directory</option>
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backup file */}
|
||||||
|
<FormRow label="Backup file">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filePath}
|
||||||
|
onChange={(e) => setFilePath(e.target.value)}
|
||||||
|
placeholder="/path/to/backup.dump"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePickFile}
|
||||||
|
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={13} />
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormSectionHeader label="Options" />
|
||||||
|
|
||||||
|
{/* Clean toggle */}
|
||||||
|
<FormRow label="Clean">
|
||||||
|
<label
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
|
||||||
|
cleanDisabled
|
||||||
|
? "opacity-40 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={clean}
|
||||||
|
onChange={(e) => setClean(e.target.checked)}
|
||||||
|
disabled={cleanDisabled}
|
||||||
|
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||||
|
{isMysql
|
||||||
|
? "drops only the objects this file defines"
|
||||||
|
: "DROP before CREATE"}
|
||||||
|
</code>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{isPg && format === "plain" && (
|
||||||
|
<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 support
|
||||||
|
DROP-before-CREATE. Use Custom Archive for clean
|
||||||
|
restores.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Destructive confirmation */}
|
||||||
|
<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">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||||
|
data-testid="restore-confirm-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||||
|
I understand this will overwrite data on the target
|
||||||
|
database. This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{activeJob && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<BackupProgress
|
||||||
|
progress={activeJob.status === "completed" ? 100 : 50}
|
||||||
|
jobType="restore"
|
||||||
|
status={activeJob.status}
|
||||||
|
errorMessage={activeJob.error_message ?? undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,4 +59,50 @@ describe("SyncPage DB-aware", () => {
|
|||||||
expect(options).not.toContain("pg1");
|
expect(options).not.toContain("pg1");
|
||||||
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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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(
|
||||||
setPgToolStatus(null);
|
async (force: boolean) => {
|
||||||
setMysqlToolStatus(null);
|
setPgToolStatus(null);
|
||||||
setConfirmed(false);
|
setMysqlToolStatus(null);
|
||||||
|
if (isPg) {
|
||||||
if (isPg) {
|
setCheckingTools(true);
|
||||||
setCheckingTools(true);
|
try {
|
||||||
detectPgTools()
|
setPgToolStatus(await detectPgTools(force));
|
||||||
.then((status) => setPgToolStatus(status))
|
} 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) {
|
}
|
||||||
setCheckingTools(true);
|
} else if (isMysql) {
|
||||||
detectMysqlTools()
|
setCheckingTools(true);
|
||||||
.then((status) => setMysqlToolStatus(status))
|
try {
|
||||||
.catch(() =>
|
setMysqlToolStatus(await detectMysqlTools(force));
|
||||||
|
} 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,229 +207,206 @@ 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">
|
||||||
<ArrowLeftRight size={14} className="text-accent" />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
<ArrowLeftRight size={14} className="text-accent" />
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||||
Transfer data between databases via pipe
|
<span className="text-[11px] text-text-muted">
|
||||||
</span>
|
Transfer data between databases via pipe
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{/* Content */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{(isPg || isMysql) && (
|
||||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
<button
|
||||||
{/* Tool check */}
|
type="button"
|
||||||
{checkingTools && checkingMessage && (
|
onClick={() => void checkTools(true)}
|
||||||
<div className="glass p-4 text-center">
|
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||||
<p className="text-sm text-text-muted">
|
>
|
||||||
{checkingMessage}
|
Check again
|
||||||
</p>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolsMissing && !toolsBundled && (
|
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
|
||||||
<p className="text-amber-300 text-sm font-semibold">
|
|
||||||
{isPg
|
|
||||||
? "PostgreSQL tools not found"
|
|
||||||
: "MySQL tools not found"}
|
|
||||||
</p>
|
|
||||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
|
||||||
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
|
||||||
database sync.
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
|
||||||
{isPg && !pgToolStatus?.pg_dump_found && (
|
|
||||||
<li>pg_dump is missing.</li>
|
|
||||||
)}
|
|
||||||
{isPg && !pgToolStatus?.pg_restore_found && (
|
|
||||||
<li>pg_restore is missing.</li>
|
|
||||||
)}
|
|
||||||
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
|
||||||
<li>mysqldump is missing.</li>
|
|
||||||
)}
|
|
||||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
|
||||||
<li>mysql is missing.</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!checkingTools && !toolsMissing && (
|
{!checkingTools && !toolsMissing && (
|
||||||
<>
|
<button
|
||||||
{/* Configuration card */}
|
type="button"
|
||||||
<div className="p-5 space-y-5">
|
onClick={() => void handleStartSync()}
|
||||||
{/* Source & Target connection pickers */}
|
disabled={!canStart}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
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"
|
||||||
<div className="space-y-1">
|
>
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
<ArrowLeftRight size={14} className="mr-1.5" />
|
||||||
<Database size={11} />
|
{isRunning ? "Syncing..." : "Start Sync"}
|
||||||
Source
|
</button>
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={sourceConnectionId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSourceConnectionId(
|
|
||||||
e.target.value,
|
|
||||||
);
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
Select source...
|
|
||||||
</option>
|
|
||||||
{connections.map((c) => (
|
|
||||||
<option
|
|
||||||
key={c.id}
|
|
||||||
value={c.id}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<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} />
|
|
||||||
Target
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={targetConnectionId}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTargetConnectionId(
|
|
||||||
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
|
|
||||||
? "Select target..."
|
|
||||||
: "Select a source first"}
|
|
||||||
</option>
|
|
||||||
{targetConnections.map((c) => (
|
|
||||||
<option
|
|
||||||
key={c.id}
|
|
||||||
value={c.id}
|
|
||||||
disabled={
|
|
||||||
c.id === sourceConnectionId
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</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 */}
|
|
||||||
{sourceConnectionId && targetConnectionId && (
|
|
||||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
|
||||||
<span className="font-medium text-text">
|
|
||||||
{connections.find(
|
|
||||||
(c) =>
|
|
||||||
c.id === sourceConnectionId,
|
|
||||||
)?.name ?? sourceConnectionId}
|
|
||||||
</span>
|
|
||||||
<ArrowLeftRight
|
|
||||||
size={12}
|
|
||||||
className="text-accent shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-text">
|
|
||||||
{connections.find(
|
|
||||||
(c) =>
|
|
||||||
c.id === targetConnectionId,
|
|
||||||
)?.name ?? targetConnectionId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destructive confirmation */}
|
|
||||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={confirmed}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfirmed(e.target.checked)
|
|
||||||
}
|
|
||||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
|
||||||
data-testid="sync-confirm-checkbox"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
|
||||||
I understand this will overwrite data on
|
|
||||||
the target database. This action cannot
|
|
||||||
be undone.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
{activeJob && (
|
|
||||||
<div className="px-4">
|
|
||||||
<BackupProgress
|
|
||||||
progress={activeJob.status === "completed" ? 100 : 50}
|
|
||||||
jobType="sync"
|
|
||||||
status={activeJob.status}
|
|
||||||
errorMessage={activeJob.error_message ?? undefined}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Tool check */}
|
||||||
|
{checkingTools && checkingMessage && (
|
||||||
|
<div className="px-4 py-3 text-sm text-text-muted">
|
||||||
|
{checkingMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolsMissing && !toolsBundled && (
|
||||||
|
<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">
|
||||||
|
{isPg
|
||||||
|
? "PostgreSQL tools not found"
|
||||||
|
: "MySQL tools not found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||||
|
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
||||||
|
database sync.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
||||||
|
{isPg && !pgToolStatus?.pg_dump_found && (
|
||||||
|
<li>pg_dump is missing.</li>
|
||||||
|
)}
|
||||||
|
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||||
|
<li>pg_restore is missing.</li>
|
||||||
|
)}
|
||||||
|
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||||
|
<li>mysqldump is missing.</li>
|
||||||
|
)}
|
||||||
|
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||||
|
<li>mysql is missing.</li>
|
||||||
|
)}
|
||||||
|
{isMysql && mysqlToolStatus?.mysqlResolvedName && (
|
||||||
|
<li className="text-amber-200/50">
|
||||||
|
Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checkingTools && !toolsMissing && (
|
||||||
|
<>
|
||||||
|
<FormSectionHeader label="Connections" />
|
||||||
|
|
||||||
|
{/* Source connection */}
|
||||||
|
<FormRow label="Source">
|
||||||
|
<select
|
||||||
|
value={sourceConnectionId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSourceConnectionId(e.target.value);
|
||||||
|
setTargetConnectionId("");
|
||||||
|
}}
|
||||||
|
className={`${controlClass} mr-3`}
|
||||||
|
>
|
||||||
|
<option value="">Select source...</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{/* Target connection — filtered to the source's db type */}
|
||||||
|
<FormRow label="Target">
|
||||||
|
<select
|
||||||
|
value={targetConnectionId}
|
||||||
|
onChange={(e) => setTargetConnectionId(e.target.value)}
|
||||||
|
disabled={!sourceConnectionId}
|
||||||
|
className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{sourceConnectionId
|
||||||
|
? "Select target..."
|
||||||
|
: "Select a source first"}
|
||||||
|
</option>
|
||||||
|
{targetConnections.map((c) => (
|
||||||
|
<option
|
||||||
|
key={c.id}
|
||||||
|
value={c.id}
|
||||||
|
disabled={c.id === sourceConnectionId}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{/* Flow indicator */}
|
||||||
|
{sourceConnectionId && targetConnectionId && (
|
||||||
|
<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">
|
||||||
|
{sourceName}
|
||||||
|
</span>
|
||||||
|
<ArrowLeftRight
|
||||||
|
size={12}
|
||||||
|
className="text-accent shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-text">
|
||||||
|
{targetName}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Destructive confirmation */}
|
||||||
|
<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">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||||
|
data-testid="sync-confirm-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||||
|
I understand this will overwrite data on
|
||||||
|
the target database. This action cannot
|
||||||
|
be undone.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{activeJob && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<BackupProgress
|
||||||
|
progress={activeJob.status === "completed" ? 100 : 50}
|
||||||
|
jobType="sync"
|
||||||
|
status={activeJob.status}
|
||||||
|
errorMessage={activeJob.error_message ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -432,4 +432,15 @@ describe("v0.7.8 command wrappers exist", () => {
|
|||||||
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+25
@@ -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
|
||||||
Executable
+46
@@ -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
|
||||||
Executable
+37
@@ -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"
|
||||||
Executable
+90
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user