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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things in the file cause it:

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

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

Fix, in two parts.

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

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

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

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

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

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

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

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

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

Verified: script + hooks exercised for the prod/tag case, the feature-branch
skip, the Gitea-remote skip and the recursion guard; the mirror is idempotent
(no-op when already in sync).
2026-09-24 03:56:27 +08:00
adrianbonpin 9089f313f8 fix(keychain) + release: macOS keychain ACL self-repair, bump 0.8.1 (#37)
Release / linux (push) Successful in 22m30s
Release / windows (push) Canceled after 0s
* fix(keychain): repair stale macOS keychain ACLs before reads

macOS records the trusted application of a legacy keychain item as the
executable *path* for an ad-hoc (dev/linker-signed) build, and as a code
requirement + cdhash partition list for a signed bundle. When the path
changes — the monorepo move (`src-tauri/` -> `desktop/src-tauri/`), a
reinstalled app, running the installed app after a dev build — the ACL no
longer admits the running binary, so macOS demands the keychain password on
*every* access: once per secret, every time, with no "Always Allow".

The pin only ran after writes, and writing to an inaccessible item needs the
same authorization, so the ACL was never repaired.

- keychain_acl::is_item_trusted() answers "am I already in this item's ACL?"
  with a metadata-only, prompt-free lookup (SecKeychainItemCopyAccess +
  SecAccessCopyACLList + comparison against this process's own
  SecTrustedApplication representation; a signed bundle's `cdhash:` partition
  lists count as trusted and are left alone).
- ensure_item_acl() now no-ops when already trusted (no duplicate ACL entries,
  no redundant authorization) and otherwise pins exactly as before.
- New before_read() hook, called from every keychain read path (DB password,
  SSH password, SSH passphrase — commands and internal helpers), so a stale
  item costs one authorization and is silent from then on.
- ItemSearchOptions reports "nothing matched" as errSecItemNotFound, which is
  now handled as "no item" instead of a lookup error — previously every
  connection without a stored password logged a spurious failure.

Verified: cargo test --lib (408 passed) plus the ignored live test
(`cargo test --lib -- --ignored keychain_acl`), which asserts a fresh item is
already trusted, repairs are idempotent and the secret still reads back. That
test now finishes in 0.25s instead of ~8s because the redundant
SecKeychainItemSetAccess is gone.

* chore(release): bump 0.8.1 and document GitHub-only releases

Version 0.8.0 -> 0.8.1 across every synced location: desktop/package.json,
desktop/src-tauri/Cargo.toml (+ Cargo.lock gridline entry), tauri.conf.json,
www/src/pages/index.astro, www/public/landing.js, both README download tables
(+ the MAINTENANCE note), and the version/docs-coverage/bundle-config tests.
Adds the 0.8.1 changelog entry (site + README Recent Changes).

AGENTS.md: releases are GitHub-only — tag `prod` and `git push github vN.M.N`
(origin/Gitea is a just-in-case mirror; never push branches, PRs or tags
there), and the keychain row documents the self-healing ACL repair.
2026-09-24 03:43:46 +08:00
adrianbonpin 3454ce67fe fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with
"connection is insecure (try using `sslmode=require`)" because the TLS mode
never reached the backend:

- ConnectionFormData carried no ssl_* fields and both submit handlers
  (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted
  them, so the SSH/SSL tab's mode was silently dropped before IPC.
- SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the
  Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path /
  ssl_key_path).
- parseConnectionString ignored query params, so a pasted Neon/Supabase URL's
  ?sslmode=require was discarded.

Pasting a URI now fills every setting it can (sslmode / ssl-mode, including
MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label
(database name, else host/file stem) as a placeholder that Test/Save uses when
the field is left blank. Managed presets pre-select their TLS mode (Supabase /
Neon → Require, PlanetScale → Verify Full).

Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech /
*.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so
connections saved before this fix keep working; an explicit mode (including
disable) always wins.

Tests: +15 frontend (parser, form payload, SSL field names, edit modal),
+4 Rust (resolve_ssl_mode / tls_decision_for).
2026-09-24 03:25:13 +08:00
adrianbonpin f807c22a36 docs(www): point the website, docs and badges back at GitHub (#35)
* docs(www): point the website, docs and badges back at GitHub

GitHub is the release destination again, so every user-facing reference to the
self-hosted Gitea instance now points at github.com/AdrianBonpin:

- www: nav + footer links, Download / Releases buttons, 'View on GitHub' and
  'Star on GitHub' CTAs, landing.js download fallback label, the repo
  preconnect/dns-prefetch hints, and the ROADMAP link (Gitea's
  src/branch/prod path becomes GitHub's blob/prod)
- www/src/lib/seo.test.ts: matching structured-data fixtures
- README: shields.io badge and the tap prose
- ROADMAP: Homebrew tap URL

Deliberately left pointing at Gitea (they are the fallback, not destinations):
.gitea/workflows/release.yml and scripts/release-mac.sh use git.ranio.xyz as
an API host, so rewriting those would break the fallback path.

* fix(www): update the stale FAQ count assertion

faq.test.ts asserted exactly 5 FAQ items but the list has had 6 since
bdf2bb5 (Release 0.7.15), so the www suite has been red since then. No CI
runs the www tests, which is why it went unnoticed. Found while verifying the
docs change above; unrelated to it.
2026-09-20 17:43:15 +08:00
adrianbonpin e4de489f53 fix(release): notarize and staple the DMGs, not just the .app (#34)
tauri-action notarizes and staples the .app but never the .dmg wrapping it, so
Gatekeeper rejected the downloaded DMG as "Unnotarized Developer ID" even
though the app inside was fine:

  spctl --assess --type open --context context:primary-signature -v \
    Gridline_0.8.0_aarch64.dmg
  -> rejected, source=Unnotarized Developer ID

Gatekeeper assesses the DMG itself when a user opens the download, so the app
being notarized does not save the mount. Submit each DMG to the same notary
service and staple the ticket into it.

- release.yml: new macOS-only step after tauri-action replaces each uploaded
  DMG with its stapled copy. Assets are replaced via the release ID because the
  GitHub releases/tags endpoint 404s for drafts, so uploading by tag cannot
  work while the release is still a draft.
- release-mac.sh: same notarize + staple pass for the local/Gitea path before
  upload, respecting GRIDLINE_SKIP_NOTARIZE.
- README: state that both the .app and the .dmg carry a stapled ticket.
2026-09-20 16:55:27 +08:00
adrianbonpin fdbfd3d0d2 fix(release): make the cask bump reliably runnable (#33)
The `release: published` trigger does not fire for a release whose draft was
created by tauri-action's GITHUB_TOKEN — verified on v0.8.0, which produced
zero workflow runs after publishing, so the Homebrew cask was never bumped and
the tap stayed empty.

Adds `bump_cask` to the workflow_dispatch inputs so the cask bump can be run
on demand for any tag, and switches the cask job to RELEASE_TAG (which resolves
for both dispatch and release events) instead of GITHUB_REF_NAME.

Also stops the cask template's header comment from being rewritten with the
substituted version and checksums on every render.
2026-09-20 16:37:33 +08:00
adrianbonpin 228660044a feat(release): allow rebuilding an existing tag via workflow_dispatch (#32)
Adds a `tag` input so a release can be re-run without rewriting its tag.
Tag-triggered runs use the workflow file at the tagged commit, so fixing the
workflow and re-pushing the same tag was previously the only way to rebuild —
which rewrites a published release ref and misrepresents what the tag points
at.

RELEASE_TAG resolves to the input on workflow_dispatch and to the ref
otherwise, and drives both the checkout ref and tauri-action's tagName.
2026-09-20 16:06:44 +08:00
adrianbonpin c4dc1c1c83 chore(release): push the Homebrew cask with a deploy key (#31)
Replace the HOMEBREW_TAP_TOKEN PAT with HOMEBREW_TAP_DEPLOY_KEY, a
write-enabled SSH deploy key scoped to only AdrianBonpin/homebrew-gridline.
actions/checkout's `ssh-key` input wires the key into the SSH agent for the
job, so the cask push goes over SSH and no broadly-scoped personal access
token has to be stored as a repository secret.

Also drops the now-wrong claim that the job no-ops until the secret is set.
2026-09-20 16:05:29 +08:00
adrianbonpin f1d9fdae3a fix(release): write the notarization .p8 with a trailing newline (#30)
`gh secret set` strips the trailing newline when storing APPLE_API_KEY_CONTENT,
and Apple's PEM parser expects the key file to end with one. Write it back
explicitly so notarization can load the key.
2026-09-20 15:58:05 +08:00
adrianbonpin 18157f453f ci(release): make GitHub Actions the primary release path (#29)
GitHub is unsuspended, so releases move back from Gitea to GitHub, with the
Gitea mirror kept as a fallback.

- release.yml: re-enable as the active path and wire up Developer-ID signing
  + notarization for the macOS jobs. The Apple steps are guarded on the
  secrets being present, so the workflow stays green (ad-hoc signed) until
  they are configured.
- release.yml: re-sign the bundled PostgreSQL/MariaDB client binaries with
  the Developer ID identity + hardened runtime + timestamp — Apple rejects
  ad-hoc-signed nested executables during notarization.
- release.yml: move the Homebrew cask bump to the `release: published` event.
  It previously ran on tag push, but the releases/tags API does not serve
  drafts, so it could never download the DMGs. It now also skips cleanly
  when HOMEBREW_TAP_TOKEN is unset rather than failing the workflow.
- release.yml: drop the stale header note claiming this is not the active
  release path.
- README + cask template: point downloads, issues, releases and the Homebrew
  tap back at github.com/AdrianBonpin.
2026-09-20 15:55:59 +08:00
adrianbonpin 5607773299 0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff (#27)
Release / windows (push) Successful in 17m5s
Release / linux (push) Successful in 24m10s
2026-09-11 17:20:41 +00:00
adrianbonpin 60d472dde2 fix: use tea CLI for macOS release upload + test landing.js version (#26) 2026-09-06 05:30:30 +00:00
adrianbonpin bdf2bb543b Release 0.7.15 (#25)
Release / windows (push) Successful in 17m44s
Release / linux (push) Successful in 21m9s
2026-09-06 04:58:10 +00:00
adrianbonpin 67f3532938 fix: release-mac.sh resolves tokens from the git credential store; fail fast (#24) 2026-09-04 04:21:16 +00:00
adrianbonpin 1d8dfce99a fix: make release-mac.sh idempotent for re-runs (#23) 2026-09-04 04:10:29 +00:00
adrianbonpin 8bcdc0888f chore: release 0.7.14 (#22)
Release / linux (push) Successful in 16m24s
Release / windows (push) Successful in 18m27s
2026-09-04 03:44:32 +00:00
adrianbonpin fd451d219d feat: pin keychain item ACLs to the app's code signature (macOS) (#21) 2026-09-04 03:41:54 +00:00
adrianbonpin a86f5f7199 docs: Homebrew install via Gitea tap (#20) 2026-09-04 02:54:08 +00:00
adrianbonpin 2fccb7b405 fix: use non-deprecated depends_on macos syntax in cask (#19) 2026-09-04 02:53:33 +00:00
adrianbonpin 0efabbdc2e fix: point Homebrew cask at Gitea releases (#18) 2026-09-04 02:46:06 +00:00
adrianbonpin 294e72e6ff chore: drop xattr postflight from Homebrew cask template (#17) 2026-09-04 02:32:43 +00:00
adrianbonpin 15f4dc9bac chore: sync Cargo.lock version to 0.7.13 (#16) 2026-09-04 02:03:13 +00:00
adrianbonpin 867df98958 fix: sign bundled DB tools with Developer ID for notarization (#15) 2026-09-04 02:02:54 +00:00
adrianbonpin e66d7ac102 release: v0.7.13 — Developer-ID signed + notarized macOS (#14)
Release / windows (push) Successful in 19m32s
Release / linux (push) Successful in 22m48s
2026-09-04 01:43:08 +00:00
adrianbonpin 84a6f8d41c feat: Developer-ID sign + notarize macOS builds (#13) 2026-09-04 01:40:48 +00:00
adrianbonpin 08b0f79f07 feat(www): SEO technical + on-page improvements (#12) 2026-09-02 09:10:50 +00:00
adrianbonpin 9611567daa chore: sync Cargo.lock to v0.7.12 + gitignore bundled DB tools (#11) 2026-09-02 08:45:26 +00:00
adrianbonpin 0bc5110e05 fix(www): point download buttons at Gitea + v0.7.12 (#10) 2026-09-02 08:43:31 +00:00
adrianbonpin 2433697951 fix(www): point download buttons at Gitea + v0.7.12 (#9) 2026-09-01 06:15:43 +00:00
adrianbonpin 341354ecb9 ci: use ASCII release body so Windows create succeeds (#8)
Release / linux (push) Successful in 18m41s
Release / windows (push) Successful in 19m4s
2026-09-01 05:06:03 +00:00
adrianbonpin 37e6638854 ci: make release-creation race-safe across parallel jobs (#7)
Release / windows (push) Failing after 17m35s
Release / linux (push) Successful in 20m23s
2026-09-01 04:44:42 +00:00
adrianbonpin 7bd4e8d05f fix: PG cell-edit serialization + recent-connections startup race (#6)
Release / windows (push) Failing after 18m10s
Release / linux (push) Successful in 19m10s
2026-09-01 04:23:07 +00:00
adrianbonpin 7a66b70c26 fix(www): Gridline favicon + Gitea links (#4) 2026-08-21 01:10:07 +00:00
152 changed files with 12580 additions and 1722 deletions
+44 -8
View File
@@ -140,13 +140,19 @@ jobs:
# Ensure the release exists (idempotent — both jobs may call this). # Ensure the release exists (idempotent — both jobs may call this).
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} — Linux and Windows installers." BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \ "${API}/releases" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle" BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
@@ -189,7 +195,13 @@ jobs:
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools" OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
mkdir -p "$OUT" mkdir -p "$OUT"
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip" URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
CACHE="/c/ci/cache/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
if [ -f "$CACHE" ]; then
echo "Using cached PG zip: $CACHE"
cp "$CACHE" /tmp/pg.zip
else
curl -fsSL "$URL" -o /tmp/pg.zip curl -fsSL "$URL" -o /tmp/pg.zip
fi
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa" EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c - echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
sha256sum /tmp/pg.zip sha256sum /tmp/pg.zip
@@ -205,16 +217,34 @@ jobs:
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools" OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
mkdir -p "$OUT" mkdir -p "$OUT"
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip" URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
CACHE="/c/ci/cache/mariadb-${MARIADB_VER}-winx64.zip"
if [ -f "$CACHE" ]; then
echo "Using cached MariaDB zip: $CACHE"
cp "$CACHE" /tmp/mariadb.zip
else
curl -fsSL "$URL" -o /tmp/mariadb.zip curl -fsSL "$URL" -o /tmp/mariadb.zip
fi
sha256sum /tmp/mariadb.zip sha256sum /tmp/mariadb.zip
unzip -o /tmp/mariadb.zip -d /tmp/mariadb unzip -o /tmp/mariadb.zip -d /tmp/mariadb
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64" WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/ cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/ cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
# rustc resolves `link.exe` via PATH for the windows-msvc target, but Git
# for Windows ships GNU coreutils with its own `link.exe` that shadows
# MSVC's linker (`link: extra operand ...`). Load the MSVC dev
# environment so PATH/LIB/INCLUDE point at the real MSVC toolchain.
# Fails fast here (instead of at link time) if the toolchain is missing.
- name: Set up MSVC build environment
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build installers - name: Build installers
shell: bash shell: cmd
run: bun run --filter gridline-desktop tauri build run: |
call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
bun run --filter gridline-desktop tauri build
- name: Create release and upload Windows assets - name: Create release and upload Windows assets
shell: bash shell: bash
@@ -227,13 +257,19 @@ jobs:
AUTH="Authorization: token ${GITHUB_TOKEN}" AUTH="Authorization: token ${GITHUB_TOKEN}"
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} — Linux and Windows installers." BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \ "${API}/releases" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle" BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
+210 -20
View File
@@ -7,27 +7,65 @@ name: Release
# git checkout prod && git pull # git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0 # git tag v0.5.0 && git push origin v0.5.0
# #
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json # SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces # signed + notarized in CI when the Apple secrets below are configured (see
# the linker-only signature that made macOS report the app as "damaged" and # README "macOS signing & notarization"). When the secrets are absent the mac
# refuse Finder launches — users now get the standard "Open Anyway" prompt # jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see # remain unsigned.
# README). Still NOT Developer-ID signed / notarized (no certs yet). #
# tauri-action automatically signs + notarizes when the signing secrets are # Required secrets for signed + notarized macOS builds:
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY / # APPLE_CERTIFICATE base64 of the Developer ID Application .p12
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future # APPLE_CERTIFICATE_PASSWORD password for that .p12
# builds are properly signed — no changes to this file required. # APPLE_SIGNING_IDENTITY "Developer ID Application: Name (TEAMID)"
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
# APPLE_API_KEY App Store Connect Key ID
# APPLE_API_ISSUER App Store Connect Issuer ID
# APPLE_API_KEY_CONTENT contents of the AuthKey_<KeyID>.p8
#
# The cask bump runs only after the draft release is PUBLISHED (the GitHub
# releases/tags API does not serve drafts), so it is wired to the `release:
# published` event rather than the tag push. It pushes with a write-enabled
# deploy key scoped to ONLY the tap repo (HOMEBREW_TAP_DEPLOY_KEY), so no
# broad personal access token is stored — the default GITHUB_TOKEN cannot
# push to a different repository.
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
release:
types: [published]
# Rebuild an EXISTING tag without moving it — e.g. to re-run a release after
# a workflow fix. Dispatch from `prod` and pass the tag to build; the tag
# itself is never rewritten, so the release ref stays truthful.
workflow_dispatch:
inputs:
tag:
description: 'Tag to build or bump the cask for, e.g. v0.8.0'
required: true
bump_cask:
description: 'Only bump the Homebrew cask for `tag` (skip the build)'
type: boolean
default: false
permissions: permissions:
contents: write contents: write
env:
# workflow_dispatch passes the tag as an input; push/release events derive it
# from the ref. Drives the checkout ref and tauri-action's tagName.
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
jobs: jobs:
publish: publish:
# Build + draft release happen on a tag push or a dispatch that isn't a
# cask-only run. The `release: published` event exists only to trigger the
# cask bump below.
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask != true)
env:
# Empty when the secret is unset — the tool steps below test for a
# non-empty value before re-signing.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -44,6 +82,69 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
# ---- Apple signing setup (macOS only, skipped when secrets absent) ---
- name: Detect Apple signing secrets
id: apple
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::APPLE_CERTIFICATE not set — macOS build will be ad-hoc signed and NOT notarized."
fi
# The bundled pg/mariadb tools are re-signed in their own steps below, so
# the identity must be in a keychain BEFORE those steps run. tauri-action
# is deliberately NOT given APPLE_CERTIFICATE (only the identity), so it
# does not create a second keychain.
- name: Import Apple Developer ID certificate
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="gridline-signing.keychain-db"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
EXISTING="$(security list-keychain -d user | tr -d ' \"')"
security list-keychain -d user -s "$KEYCHAIN" $EXISTING
rm -f "$RUNNER_TEMP/cert.p12"
security find-identity -v -p codesigning
- name: Prepare notarization credentials
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
set -euo pipefail
KEY_PATH="$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
mkdir -p "$(dirname "$KEY_PATH")"
# Apple's PEM parser expects a terminating newline, which
# `gh secret set` strips from the stored value — so write one
# explicitly. A doubled trailing newline is harmless to PEM parsers,
# but a missing one can make notarization fail to load the key.
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$KEY_PATH"
{
echo "APPLE_API_KEY=${APPLE_API_KEY}"
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}"
echo "APPLE_API_KEY_PATH=${KEY_PATH}"
} >> "$GITHUB_ENV"
- name: Install Linux dependencies - name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
@@ -136,6 +237,16 @@ jobs:
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}') libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b" install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
done done
# install_name_tool invalidates the linker signature. Re-sign with
# the Developer ID identity + hardened runtime + secure timestamp:
# Apple rejects ad-hoc-signed nested executables at notarization.
# No-op until the Apple secrets are configured.
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
for f in "$OUT"/pg_dump "$OUT"/pg_restore "$OUT"/psql "$OUT"/libpq.5.dylib; do
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$f"
done
fi
;; ;;
windows-latest) windows-latest)
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip" URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
@@ -268,7 +379,12 @@ jobs:
if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
done || true done || true
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
else
codesign --force --sign - "$dylib" 2>/dev/null || true codesign --force --sign - "$dylib" 2>/dev/null || true
fi
done done
done done
;; ;;
@@ -314,8 +430,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
projectPath: desktop projectPath: desktop
tagName: ${{ github.ref_name }} tagName: ${{ env.RELEASE_TAG }}
releaseName: 'Gridline ${{ github.ref_name }}' releaseName: 'Gridline ${{ env.RELEASE_TAG }}'
releaseDraft: true releaseDraft: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
# Assets use tauri-action's default versioned naming (e.g. # Assets use tauri-action's default versioned naming (e.g.
@@ -323,44 +439,118 @@ jobs:
# README download tables link to them statically per release — # README download tables link to them statically per release —
# remember to update both README tables when cutting a new version. # remember to update both README tables when cutting a new version.
# tauri-action notarizes + staples the .app but NOT the .dmg wrapping it,
# so Gatekeeper rejects the downloaded DMG as "Unnotarized Developer ID"
# even though the app inside is fine. Submit the DMG to the same notary
# service, staple the ticket, and replace the uploaded asset.
#
# Assets are replaced via the release ID rather than `gh release upload`:
# the GitHub releases/tags endpoint returns 404 for drafts, so uploading
# by tag cannot work while the release is still a draft.
- name: Notarize and staple the DMGs
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(desktop/src-tauri/target/*/release/bundle/dmg/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "no DMG produced" >&2
exit 1
fi
REL_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" | head -1)
if [ -z "$REL_ID" ]; then
echo "could not resolve a release id for ${RELEASE_TAG}" >&2
exit 1
fi
for dmg in "${dmgs[@]}"; do
name=$(basename "$dmg")
echo ">> notarizing $name (this can take a few minutes)"
xcrun notarytool submit "$dmg" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
# Replace the copy tauri-action uploaded, which carries no DMG ticket.
old=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \
--jq ".[] | select(.name == \"${name}\") | .id" | head -1)
if [ -n "$old" ]; then
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${old}"
fi
echo ">> re-uploading stapled $name"
curl -fsS -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${dmg}" \
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${name}"
echo
done
echo "all DMGs notarized and stapled"
# After the installers are uploaded, refresh the Homebrew cask in the # After the installers are uploaded, refresh the Homebrew cask in the
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs, # AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
# render Casks/gridline.rb from the template, and push it. Users then get # render Casks/gridline.rb from the template, and push it. Users then get
# the new version via `brew upgrade --cask gridline`. # the new version via `brew upgrade --cask gridline`.
# #
# Requires the HOMEBREW_TAP_TOKEN secret (a PAT with `repo` scope on the tap # Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
# repo — the default GITHUB_TOKEN cannot push to a different repo). The job # key on the tap repo (the default GITHUB_TOKEN cannot push to a different
# no-ops until that secret is set, so releases stay green before this is # repository, and a deploy key avoids storing a broad PAT). The job skips
# cleanly until that secret is set, so releases stay green before this is
# configured. # configured.
bump-cask: bump-cask:
needs: publish # Normally fires on `release: published`, which requires the DMGs to be
# downloadable — the GitHub releases/tags API returns 404 for drafts, so
# this cannot run on the tag push. NOTE: a release whose DRAFT was created
# by tauri-action's GITHUB_TOKEN does not reliably emit a `release` event,
# so `workflow_dispatch` with bump_cask=true is the dependable path.
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask == true)
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Check for tap deploy key
id: tap
shell: bash
run: |
if [ -n "${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::HOMEBREW_TAP_DEPLOY_KEY not set - skipping Homebrew cask bump."
fi
- name: Checkout - name: Checkout
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Resolve version and DMG checksums - name: Resolve version and DMG checksums
id: meta id: meta
if: steps.tap.outputs.enabled == 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${RELEASE_TAG#v}"
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Checkout tap repo - name: Checkout tap repo
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: AdrianBonpin/homebrew-gridline repository: AdrianBonpin/homebrew-gridline
token: ${{ secrets.HOMEBREW_TAP_TOKEN }} ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
path: tap path: tap
- name: Render and push cask - name: Render and push cask
if: steps.tap.outputs.enabled == 'true'
env: env:
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHA_ARM: ${{ steps.meta.outputs.sha_arm }} SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
+10
View File
@@ -38,3 +38,13 @@ docs/superpowers/
.env .env
!.env.example !.env.example
.worktrees/ .worktrees/
# Bundled DB client tools (built/downloaded during release, never committed)
desktop/src-tauri/resources/pg_tools/pg_dump
desktop/src-tauri/resources/pg_tools/pg_restore
desktop/src-tauri/resources/pg_tools/psql
desktop/src-tauri/resources/pg_tools/libpq.5.dylib
desktop/src-tauri/resources/mysql_tools/mariadb
desktop/src-tauri/resources/mysql_tools/mariadb-dump
desktop/src-tauri/resources/mysql_tools/libcrypto.3.dylib
desktop/src-tauri/resources/mysql_tools/libssl.3.dylib
+37 -21
View File
@@ -104,7 +104,7 @@ gridline/
├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml ├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml
├── docs/ # Project docs ├── docs/ # Project docs
├── screenshots/ # README marketing images ├── screenshots/ # README marketing images
├── scripts/ # Release tooling (render-cask.sh) ├── scripts/ # Release tooling (render-cask.sh, release-mac.sh) + Gitea mirror (mirror-gitea.sh, hooks/, install-git-hooks.sh)
└── AGENTS.md # This file └── AGENTS.md # This file
``` ```
@@ -167,24 +167,35 @@ cargo test # Rust tests (from desktop/sr
### Branch & PR workflow ### Branch & PR workflow
**GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first.
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always: **Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod` 1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
2. Commit and push the feature branch 2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
3. Open a PR into `prod` and merge it there 3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base prod`) and merge it there
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
Cut a release by tagging the **`prod`** branch once the PR is merged — `git tag vN.M.N && git push origin 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). **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.
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened". - **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
@@ -237,19 +248,20 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
### Connection Management ### Connection Management
| Feature | Status | Details | | Feature | Status | Details |
| :--- | :---: | :--- | | :--- | :---: | :--- |
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation | | Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; SQLite swaps the URI field for a file-path + Browse input (v0.7.0) | | MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0)
| 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 | | 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) |
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields | | SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | | SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths | | SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) |
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) | | SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins |
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) | | Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
### Home Screen & Organization ### Home Screen & Organization
@@ -290,21 +302,21 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version | | Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings | | Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE | | Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` | | Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY | | Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column | | Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit | | Column resize (drag handle) | ✅ | Double-click to auto-fit |
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete | | Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) | | Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8); plus Rust-side **Export all rows to file…** (full-result CSV/JSONL/JSON streaming with progress + cancel) (v0.8.0) |
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) | | Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
| Auto-refresh timer | ✅ | Configurable interval in settings | | Auto-refresh timer | ✅ | Configurable interval in settings |
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table | | Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed | | Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All | | Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) | | Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test | | Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt | | Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. | | Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows | | Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** | | Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu | | Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
@@ -346,6 +358,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. | | Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) | | Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) | | Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
| TimescaleDB hypertables | ✅ | Objects view type on PG connections with the extension; runtime availability gating (v0.8.0) |
### Query Editor ### Query Editor
| Feature | Status | Details | | Feature | Status | Details |
@@ -355,7 +368,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) | | Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) | | Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) | | SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
| Multiple result sets | ❌ | | | Multiple result sets | ✅ | Stacked panels per statement via execute_query_multi; DML/DDL notices; stop-at-first-error (v0.8.0) |
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) | | Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab | | Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab | | Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
@@ -371,9 +384,12 @@ 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) |
| Table structure export (DDL) | ❌ | | | Table structure export (DDL) | ❌ | |
### Settings ### Settings
+66 -40
View File
@@ -18,43 +18,46 @@
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a> <a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a> <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
<a href="https://github.com/adrianbonpin/gridline/stargazers"><img src="https://img.shields.io/github/stars/adrianbonpin/gridline?style=for-the-badge&logo=github&label=Stars" alt="GitHub stars" /></a> <a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
</p> </p>
<p> <p>
<a href="https://github.com/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge&logo=github&logoColor=white" alt="Download Latest" /></a> <a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://github.com/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge&logo=github&logoColor=white" alt="Open an Issue" /></a> <a href="https://github.com/AdrianBonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
</p> </p>
--- ---
## Download ## Download
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.11**): 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.7.11_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline-0.7.11-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.7.11_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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):
```bash ```bash
brew install --cask AdrianBonpin/gridline/gridline # one-time setup (the tap is hosted on GitHub):
# or, after a one-time `brew tap AdrianBonpin/gridline`: brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
brew install --cask gridline brew install --cask gridline
``` ```
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.) The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). 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.7.11 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:
@@ -99,6 +102,20 @@ 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.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.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump. - **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
- **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump. - **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump.
@@ -301,41 +318,48 @@ _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overc
### Download a release ### Download a release
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/adrianbonpin/gridline/releases) page. Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/adrianbonpin/gridline/issues/new) when you hit them. > ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
#### Installers are unsigned (for now) #### macOS signing & notarization
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee: Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches. **One-time setup** (only the repo owner needs to do this):
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version. 1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally: 2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
```bash | Secret | Value |
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app | :--------------------------- | :------------------------------------------------------------------------------------------------- |
``` | `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
| `APPLE_API_KEY` | App Store Connect **Key ID** |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined. 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.
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
- **Linux:** no warning — install and run normally.
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed. `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.
#### 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.7.11**): 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.7.11_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.7.11_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.11_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.7.11-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline-0.7.11-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.11-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.7.11_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_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.
@@ -345,11 +369,13 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.11 git tag v0.7.15
git push origin v0.7.11 git push origin v0.7.15
``` ```
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**. GitHub Actions (`.github/workflows/release.yml`) builds the **macOS (Apple Silicon + Intel), Windows, and Linux** installers in parallel and opens a **draft release** on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page. Review the draft and hit **Publish release** — publishing triggers a follow-up job that bumps the Homebrew cask in [`AdrianBonpin/homebrew-gridline`](https://github.com/AdrianBonpin/homebrew-gridline) (pushed via a write-enabled deploy key on the tap, `HOMEBREW_TAP_DEPLOY_KEY`; skipped when unset).
The self-hosted Gitea mirror (`.gitea/workflows/release.yml`) still builds Linux and Windows for the Gitea release, and `scripts/release-mac.sh` can build macOS locally — both are kept as a fallback if GitHub is unavailable.
Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names. Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
@@ -357,7 +383,7 @@ Before tagging, make sure the version number is in sync across `desktop/package.
```bash ```bash
# 1. Clone the repository # 1. Clone the repository
git clone https://github.com/adrianbonpin/gridline.git git clone https://github.com/AdrianBonpin/gridline.git
cd gridline cd gridline
# 2. Install all workspace dependencies # 2. Install all workspace dependencies
+76 -53
View File
@@ -6,38 +6,27 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## ✅ Shipped (0.7.10) ## ✅ Shipped (0.8.0)
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended. - **Schema diff / compare** — same-engine structural diff (PG↔PG, MySQL-family↔MySQL-family, SQLite↔SQLite) with per-object sync SQL; non-destructive items stage through the changes queue, destructive items are copy-only. Target = current connection; source = any saved same-family connection.
- **Version bump** 0.7.9 → **0.7.10**. - **Query Workbench: multiple result sets** — stacked result panels per statement with DML/DDL affected-count notices and stop-at-first-error *(deferred from 0.7.0)*
- **Query Workbench: result streaming to file** — full-result CSV/JSONL/JSON export with progress + cancel, constant memory (PG cursor fetch, MySQL/SQLite native streams)
- **MariaDB support** — first-class `db_type` reusing the MySQL driver path; provider card, filter, URI schemes, backup via the already-bundled mariadb tools
- **TimescaleDB support** — runtime extension detection; hypertables in the Objects view (dimensions, compression, chunks, size), read-only
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
- **Version bump** 0.7.15 → **0.8.0**.
## ✅ Shipped (0.7.9) ## ✅ Shipped (unreleased)
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started). - **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*.
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround. - **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins.
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it). - **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
- **Version bump** 0.7.8 → **0.7.9**.
## 🎯 Next up (0.8.0) — TBD
Scope to be determined.
## ✅ Shipped (0.7.8)
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
- **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
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
- **Version bump** 0.7.7 → **0.7.8**.
## 📋 In the queue ## 📋 In the queue
### Full Redis Support ### Full Redis Support
Connection + test work today; **key browsing is explicitly not part of 0.7.0** — it stays gated behind an "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers. Connection + test only today — browsing stays gated behind a clean "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
- Key browser (pattern search, filter by type, TTL display, key count) - Key browser (pattern search, filter by type, TTL display, key count)
- Value editors per type: string, list, hash, set, zset, stream, JSON - Value editors per type: string, list, hash, set, zset, stream, JSON
@@ -46,28 +35,25 @@ Connection + test work today; **key browsing is explicitly not part of 0.7.0**
### Additional Database Types ### Additional Database Types
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work) MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand).
- **TimescaleDB** (PostgreSQL extension — largely free once PG browsing is solid; surface hypertables/compression in the object tree)
- Candidates after that: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand)
### Managed Database Support (beyond Supabase / Neon) ### Managed Database Support (beyond Supabase / Neon)
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates: Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
- **PlanetScale** (Vitess/MySQL) - **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
- **Turso** (libSQL)
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
### Query Workbench Upgrades ### Query Workbench Upgrades
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)* Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
- **Result streaming to file** — export 500k+ rows without loading them all into memory
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one) - **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
### Schema & Data Tooling ### Schema & Data Tooling
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync Schema diff / compare moved to [0.8.0](#-next-up-080). Remaining:
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(PostgreSQL-only today)*
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel - **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
## 🔮 Planned ## 🔮 Planned
@@ -84,9 +70,9 @@ Bring-your-own-key — no bundled model, no paywall, key stored in the OS keycha
### Website & Docs ### Website & Docs
- Landing page with screenshots, feature tour, and download links The marketing site (`www/`, Astro) is live: landing page with screenshots, FAQ, and a changelog feed. Remaining:
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue) - User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
- Blog / changelog feed
### UI/UX Improvements (rolling) ### UI/UX Improvements (rolling)
@@ -99,15 +85,63 @@ Continuous polish, tracked as issues rather than one-off milestones:
- Session restore — reopen tabs and query state from the last session - Session restore — reopen tabs and query state from the last session
- Light-theme parity pass — dark is first-class; polish the light theme to match - Light-theme parity pass — dark is first-class; polish the light theme to match
Deferred from 0.7.0, slated for this bucket: Still deferred from 0.7.0, slated for this bucket:
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen - **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
- **Settings export/import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only) - **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
- **In-app changelog** — "What's new" panel fed from bundled release notes - **In-app changelog** — "What's new" panel fed from bundled release notes
--- ---
## ✅ Shipped (0.7.10)
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
- **Version bump** 0.7.9 → **0.7.10**.
## ✅ Shipped (0.7.15)
- **Insert Row** — adds an editable pending row at the top of the data grid (visible accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply
- **Smart cell editors** — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and FKs already had dropdowns), applied to both new rows and inline editing
- **Landing page support channel** — coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature
- **Landing page copy refresh** — em dashes dropped, OSS section simplified, FAQ added for how the project is funded
- **Version bump** 0.7.14 → **0.7.15**.
## ✅ Shipped (0.7.14)
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
- **Install via Homebrew** — add the Homebrew tap (`brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
- **Version bump** 0.7.13 → **0.7.14**.
## ✅ Shipped (0.7.13)
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
- **Version bump** 0.7.12 → **0.7.13**.
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh)*
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type
- **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
- **Full CI release pipeline for Windows** — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux
- **Version bump** 0.7.11 → **0.7.12**.
## ✅ Shipped (0.7.9)
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
- **Version bump** 0.7.8 → **0.7.9**.
## ✅ 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
- **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
- **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)
- **Windows/Linux title bar fix** — the macOS overlay drag strip (`h-7` in `App.tsx`) is now gated to macOS; Windows/Linux use the native title bar for dragging
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
- **Version bump** 0.7.7 → **0.7.8**.
## ✅ Shipped (0.7.7) ## ✅ Shipped (0.7.7)
### Admin follow-up ### Admin follow-up
@@ -151,24 +185,13 @@ Deferred from 0.7.0, slated for this bucket:
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls. - **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
- **Version bump** 0.6.0 → **0.7.0**. - **Version bump** 0.6.0 → **0.7.0**.
**Not in 0.7.0** (deferred, tracked above): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour. **Deferred from 0.7.0**: Redis key browsing and MySQL Objects/ERD views (still in the queue); SSH key management and onboarding tour (planned). Since shipped elsewhere: backup/restore/sync for MySQL & SQLite and settings import/export (0.7.8); multiple result sets (slated for 0.8.0).
## ✅ Shipped ## ✅ Shipped — earlier releases (pre-0.7.0)
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
- Objects view tabbed workspace (v0.7.6)
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
- Copy as DDL for any object (v0.7.5)
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell - Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
- New Connection screen revamp — provider grid + Supabase/NeonDB managed-pg presets (v0.7.0)
- DB viewer capability gating + Redis unsupported state (v0.7.0)
- Connection management with URI parser, SSH tunnels, TLS, OS keychain - Connection management with URI parser, SSH tunnels, TLS, OS keychain
- PostgreSQL and SQLite browse/query support
- Workspace tree, folders, tags, favorites, recents - Workspace tree, folders, tags, favorites, recents
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting - Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
- FK preview, JSON popover, inline cell editing, changes queue - FK preview, JSON popover, inline cell editing, changes queue
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.7.11", "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": {
+44 -1
View File
@@ -339,6 +339,19 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "bigdecimal"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
dependencies = [
"autocfg",
"libm",
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -1783,10 +1796,13 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.10" version = "0.8.3"
dependencies = [ dependencies = [
"bytes",
"chrono", "chrono",
"core-foundation 0.10.1",
"deadpool-postgres", "deadpool-postgres",
"futures-util",
"indexmap 2.14.0", "indexmap 2.14.0",
"redis", "redis",
"rusqlite", "rusqlite",
@@ -1794,6 +1810,8 @@ dependencies = [
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile 2.2.0", "rustls-pemfile 2.2.0",
"rustls-pki-types", "rustls-pki-types",
"security-framework 3.7.0",
"security-framework-sys",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -1804,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",
@@ -4420,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",
@@ -4502,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",
@@ -4544,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",
@@ -4561,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",
@@ -4581,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",
@@ -5015,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"
+22 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.11" 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"
@@ -31,7 +31,11 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tokio = { version = "1", features = ["full"] } 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"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] } bytes = "1"
futures-util = "0.3"
# 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
@@ -48,3 +52,19 @@ rustls-pemfile = "2"
rustls-pki-types = "1" rustls-pki-types = "1"
rustls-native-certs = "0.7" rustls-native-certs = "0.7"
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
# survive app re-signs without permission prompts) — macOS only.
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.7"
security-framework-sys = "2.17"
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
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"
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
app crashes under the hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- The WebView allocates executable memory for JIT; allow it. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+57
View File
@@ -1,6 +1,7 @@
//! Per-connection cancellation handles, stored independently of the pool //! Per-connection cancellation handles, stored independently of the pool
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex. //! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rusqlite::InterruptHandle; use rusqlite::InterruptHandle;
@@ -59,6 +60,12 @@ pub enum CancelHandle {
#[derive(Default)] #[derive(Default)]
pub struct CancelRegistry { pub struct CancelRegistry {
map: Mutex<HashMap<String, CancelHandle>>, map: Mutex<HashMap<String, CancelHandle>>,
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
/// sets one for MySQL; long-running in-process operations (currently the
/// native restore engine) poll it between statements and clear it when
/// they finish. Kept in a separate map from the handles so it needs no
/// `Clone` on the registry and survives handle replacement.
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
} }
impl CancelRegistry { impl CancelRegistry {
@@ -66,6 +73,41 @@ impl CancelRegistry {
Self::default() Self::default()
} }
/// Get (or create) the cancellation flag for a connection. Polling callers
/// should clear it before starting and after finishing.
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
self.flags
.lock()
.unwrap()
.entry(id.to_string())
.or_default()
.clone()
}
/// Mark a connection as cancel-requested. Called by `cancel_query` before
/// it sends the driver-specific cancel so in-process consumers observe the
/// request even when the driver surfaces it as an unrecognized error.
pub fn mark_cancelled(&self, id: &str) {
self.cancel_flag(id).store(true, Ordering::SeqCst);
}
/// Whether a cancel has been requested for this connection.
pub fn is_cancelled(&self, id: &str) -> bool {
self.flags
.lock()
.unwrap()
.get(id)
.map(|f| f.load(Ordering::SeqCst))
.unwrap_or(false)
}
/// Clear the cancel-requested flag for this connection.
pub fn clear_cancelled(&self, id: &str) {
if let Some(f) = self.flags.lock().unwrap().get(id) {
f.store(false, Ordering::SeqCst);
}
}
pub fn set_pg(&self, id: &str, c: PgCancel) { pub fn set_pg(&self, id: &str, c: PgCancel) {
self.set(id, CancelHandle::Pg(c)); self.set(id, CancelHandle::Pg(c));
} }
@@ -157,6 +199,21 @@ mod tests {
} }
} }
#[test]
fn cancel_flag_is_tracked_per_connection() {
let reg = CancelRegistry::new();
assert!(!reg.is_cancelled("c1"));
reg.mark_cancelled("c1");
assert!(reg.is_cancelled("c1"));
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
reg.clear_cancelled("c1");
assert!(!reg.is_cancelled("c1"));
// The same Arc is shared between getter and setter.
let flag = reg.cancel_flag("c1");
reg.mark_cancelled("c1");
assert!(flag.load(Ordering::SeqCst));
}
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions { fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
sqlx::mysql::MySqlConnectOptions::new() sqlx::mysql::MySqlConnectOptions::new()
.host("127.0.0.1").port(1).username("u").password("p").database("d") .host("127.0.0.1").port(1).username("u").password("p").database("d")
+538 -108
View File
@@ -3,6 +3,7 @@ use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use crate::db::tool_resolver::{self, ToolRequest, ToolResolution};
use crate::models::backup::*; use crate::models::backup::*;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,74 +41,106 @@ impl PgConnParams {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn get_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String { fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s) crate::commands::test_connection::sanitize_error(s)
} }
fn bundled_bin_name(tool: &str) -> String { /// Tauri-facing one-shot resolution with the process-lifetime cache.
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() } /// `force` bypasses the cache for an explicit user re-check.
fn resolve_request(app: &AppHandle, request: &ToolRequest, force: bool) -> ToolResolution {
let key = request.cache_key();
if !force {
if let Some(hit) = tool_resolver::cache_get(&key) {
return hit;
}
} }
/// Pure resolution decision (unit-testable): system > bundled > bare name. let resource_dir = app.path().resource_dir().ok();
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) { let bundled = resource_dir
if system_ok { return (tool.to_string(), Some("system".to_string())); } .as_ref()
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); } .map(|rd| tool_resolver::bundled_tool_path(rd, request.subdir, request.bundled_name()));
(tool.to_string(), None) let trusted = tool_resolver::trusted_dirs();
let start = std::time::Instant::now();
let probe = |p: &std::path::Path| {
tool_resolver::probe_within(start, tool_resolver::AGGREGATE_DEADLINE, p)
};
let resolved = tool_resolver::resolve_with(
request,
&trusted,
&tool_resolver::path_lookup,
bundled.as_deref(),
probe,
);
let version = resolved
.as_ref()
.and_then(|r| tool_resolver::version_of(&r.program));
let bundled_available = bundled
.as_deref()
.map(tool_resolver::bundled_present)
.unwrap_or(false);
let out = ToolResolution {
resolved,
bundled_path: bundled,
bundled_available,
version,
};
tool_resolver::cache_put(&key, out.clone());
out
} }
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source). /// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) { pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok(); match tool_resolver::pg_request(tool) {
let bundled = app.path().resource_dir().ok() Some(request) => {
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool))) tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
.filter(|p| p.exists()) }
.map(|p| p.to_string_lossy().to_string()); None => (tool.to_string(), None),
pick_tool(system_ok, bundled.as_deref(), tool) }
} }
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths { pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump"); PgToolPaths {
let (r, _) = resolve_tool(app, "pg_restore"); pg_dump: resolve_tool(app, "pg_dump").0,
let (p, _) = resolve_tool(app, "psql"); pg_restore: resolve_tool(app, "pg_restore").0,
PgToolPaths { pg_dump: d, pg_restore: r, psql: p } psql: resolve_tool(app, "psql").0,
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// detect_pg_tools // detect_pg_tools
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Shapes the tool status from resolved tool paths + sources. Headless so the /// Shapes both PG statuses from resolved tools. Headless so the Tauri command
/// Tauri command stays thin and the status logic stays unit-testable. /// stays thin and the shaping logic stays unit-testable.
fn build_pg_tool_status( fn build_pg_tool_status(dump: &ToolResolution, restore: &ToolResolution) -> PgToolStatus {
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus { PgToolStatus {
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(), pg_dump_found: dump.resolved.is_some(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(), pg_restore_found: restore.resolved.is_some(),
pg_dump_version: get_version(dump), pg_dump_version: dump.version.clone(),
pg_restore_version: get_version(restore), pg_restore_version: restore.version.clone(),
pg_dump_source: dump_src, pg_dump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_restore_source: restore_src, pg_restore_source: restore.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_dump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
pg_restore_resolved_name: restore.resolved.as_ref().map(|r| r.name.clone()),
pg_dump_bundled_available: dump.bundled_available,
pg_restore_bundled_available: restore.bundled_available,
} }
} }
/// Detection runs off the UI thread and can be forced past the cache.
#[tauri::command] #[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus { pub async fn detect_pg_tools(force: Option<bool>, app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump"); let force = force.unwrap_or(false);
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore"); tauri::async_runtime::spawn_blocking(move || {
build_pg_tool_status(&dump, &restore, dump_src, restore_src) let dump = resolve_request(&app_handle, &tool_resolver::pg_dump_request(), force);
let restore = resolve_request(&app_handle, &tool_resolver::pg_restore_request(), force);
build_pg_tool_status(&dump, &restore)
})
.await
.unwrap_or_else(|_| {
build_pg_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -501,90 +534,127 @@ fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
] ]
} }
/// Whether a resolved client is MariaDB's. MariaDB's clients print a
/// `...-MariaDB...` banner from `--version`; MySQL's print the Oracle banner.
/// This matters because MariaDB's clients — including the `mysqldump`/`mysql`
/// compatibility names some MariaDB packages ship — reject several MySQL-only
/// options with "unknown option", aborting the dump before it connects.
/// Probed once per program and cached for the process.
fn client_is_mariadb(program: &str) -> bool {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, bool>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock() {
if let Some(hit) = map.get(program) {
return *hit;
}
}
let is_mariadb = Command::new(program)
.arg("--version")
.output()
.map(|o| {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
text.contains("MariaDB")
})
.unwrap_or(false);
if let Ok(mut map) = cache.lock() {
map.insert(program.to_string(), is_mariadb);
}
is_mariadb
}
/// TLS flags for a dump client. MySQL's clients take `--ssl-mode=<mode>`;
/// MariaDB's have no `--ssl-mode` and use the boolean `--ssl`, which is the
/// equivalent of the only mode this code path uses (`REQUIRED`).
pub fn tls_args_for_client(client_is_mariadb: bool, tls_mode: Option<&str>) -> Vec<String> {
match (client_is_mariadb, tls_mode) {
(true, Some("REQUIRED")) => vec!["--ssl".into()],
(true, _) => Vec::new(),
(false, Some(m)) => vec![format!("--ssl-mode={m}")],
(false, None) => Vec::new(),
}
}
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the /// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
/// command), NEVER --password (process-list visibility). /// command), NEVER --password (process-list visibility).
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> { ///
/// `client_is_mariadb` gates the MySQL-only options: MariaDB's dump rejects
/// `--skip-column-statistics`. `--databases` is always emitted as a boolean
/// flag followed by the database name — `--databases=<db>` is rejected by
/// clients as an invalid value.
pub fn build_mysql_dump_args(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
client_is_mariadb: bool,
) -> Vec<String> {
let mut a = base_mysql_args(conn); let mut a = base_mysql_args(conn);
if options.single_transaction { a.push("--single-transaction".into()); } if options.single_transaction { a.push("--single-transaction".into()); }
if options.no_data { a.push("--no-data".into()); } if options.no_data { a.push("--no-data".into()); }
if options.routines { a.push("--routines".into()); } if options.routines { a.push("--routines".into()); }
if options.triggers { a.push("--triggers".into()); } if options.triggers { a.push("--triggers".into()); }
if options.events { a.push("--events".into()); } if options.events { a.push("--events".into()); }
a.push(format!("--databases={}", options.database)); a.push("--databases".into());
a.push(options.database.clone());
a.push(format!("--result-file={}", options.file_path)); a.push(format!("--result-file={}", options.file_path));
if !client_is_mariadb {
a.push("--skip-column-statistics".into()); a.push("--skip-column-statistics".into());
}
a a
} }
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> { /// System-first mariadb-dump/mysqldump resolution (bundled fallback).
let mut a = base_mysql_args(conn);
a.push(format!("--database={}", options.database));
a
}
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) { pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok(); match tool_resolver::mysql_request(tool) {
let bundled = app.path().resource_dir().ok() Some(request) => {
.map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool))) tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
.filter(|p| p.exists()) }
.map(|p| p.to_string_lossy().to_string()); None => (tool.to_string(), None),
pick_tool(system_ok, bundled.as_deref(), tool) }
} }
pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths { pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
let (d, _) = resolve_mysql_tool(app, "mariadb-dump"); MySqlToolPaths {
let (m, _) = resolve_mysql_tool(app, "mariadb"); mysqldump: resolve_mysql_tool(app, "mariadb-dump").0,
MySqlToolPaths { mysqldump: d, mysql: m } mysql: resolve_mysql_tool(app, "mariadb").0,
}
} }
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`) /// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live /// is translated per client — `--ssl-mode=` on MySQL clients, `--ssl` on
/// behavior = #[ignore] integration test.) /// MariaDB's (see `tls_args_for_client`) when routing through an SSH tunnel.
/// (Live behavior = #[ignore] integration test.)
pub fn run_mysql_dump( pub fn run_mysql_dump(
conn: &MySqlConnParams, conn: &MySqlConnParams,
options: &MySqlBackupOptions, options: &MySqlBackupOptions,
tools: &MySqlToolPaths, tools: &MySqlToolPaths,
tls_mode: Option<&str>, tls_mode: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut args = build_mysql_dump_args(conn, options); let is_mariadb = client_is_mariadb(&tools.mysqldump);
if let Some(m) = tls_mode { let mut args = build_mysql_dump_args(conn, options, is_mariadb);
args.push(format!("--ssl-mode={m}")); args.extend(tls_args_for_client(is_mariadb, tls_mode));
}
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output() let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) } if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
} }
pub fn run_mysql_restore(
conn: &MySqlConnParams,
options: &MySqlRestoreOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_restore_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
.map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
}
pub fn run_mysql_sync( pub fn run_mysql_sync(
source: &MySqlConnParams, source: &MySqlConnParams,
target: &MySqlConnParams, target: &MySqlConnParams,
tools: &MySqlToolPaths, tools: &MySqlToolPaths,
tls_mode: Option<&str>, tls_mode: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let is_mariadb = client_is_mariadb(&tools.mysqldump);
let mut dump_args = base_mysql_args(source); let mut dump_args = base_mysql_args(source);
dump_args.push("--single-transaction".into()); dump_args.push("--single-transaction".into());
dump_args.push("--add-drop-table".into()); dump_args.push("--add-drop-table".into());
dump_args.push(format!("--databases={}", source.database)); dump_args.push("--databases".into());
if let Some(m) = tls_mode { dump_args.push(source.database.clone());
dump_args.push(format!("--ssl-mode={m}")); dump_args.extend(tls_args_for_client(is_mariadb, tls_mode));
}
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?; let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
let stdout = dump.stdout.take().unwrap(); let stdout = dump.stdout.take().unwrap();
let mut restore_args = base_mysql_args(target); let mut restore_args = base_mysql_args(target);
@@ -598,6 +668,147 @@ pub fn run_mysql_sync(
} }
} }
// ---------------------------------------------------------------------------
// Native MySQL restore engine (in-process via sqlx — no CLI, no subprocess)
// ---------------------------------------------------------------------------
/// Restore reads from disk, so it is capped at the existing CSV/JSON import
/// limit (100 MB) rather than the 5 MB *editor* cap — a file too large to open
/// in the editor must still be restorable (spec §2).
pub const MAX_RESTORE_FILE_BYTES: u64 = 100 * 1024 * 1024;
/// Enforce the restore size cap. Extracted from the command so the boundary is
/// testable; restore reads from disk, so it uses the 100 MB bulk cap rather
/// than the 5 MB *editor* cap (spec §2).
pub fn check_restore_file_size(bytes: u64, cap: u64) -> Result<(), String> {
if bytes <= cap {
return Ok(());
}
Err(format!(
"Restore file is {} MB; the limit is {} MB",
bytes.div_ceil(1024 * 1024),
cap / (1024 * 1024)
))
}
/// Inputs for one native restore run. A struct rather than a long argument
/// list so the headless core stays readable and directly testable.
pub struct MySqlRestoreRun<'a> {
pub database: &'a str,
pub statements: &'a [String],
pub clean: bool,
}
/// Progress units for a restore: every statement, plus one for the clean
/// drop phase so the fraction never jumps backwards.
pub fn restore_progress_total(statement_count: usize, clean: bool) -> usize {
statement_count + usize::from(clean)
}
/// The error a failed restore reports. Includes the position so the user can
/// find the statement in their file, and never echoes the statement text
/// (SQL must not be logged — project guardrail).
pub fn format_restore_statement_error(index_1based: usize, total: usize, detail: &str) -> String {
format!("Statement {index_1based} of {total} failed: {detail}")
}
/// Map a driver error for the restore path: a `KILL QUERY` cancellation must
/// read as a cancellation, not as a broken statement.
fn restore_statement_error(e: &sqlx::Error) -> String {
if crate::commands::query::is_mysql_cancel_error(e) {
"Query cancelled".to_string()
} else {
sanitize_error(&format!("{e}"))
}
}
/// Execute an already-split restore script over one live MySQL connection.
///
/// Stop-at-first-error by contract: MySQL DDL is non-transactional and
/// auto-commits, so there is nothing to roll back and continuing past a
/// failure would compound an already-partial restore (spec §6).
pub async fn run_mysql_restore_on(
conn: &mut sqlx::mysql::MySqlConnection,
run: MySqlRestoreRun<'_>,
on_progress: &mut (dyn FnMut(usize, usize) + Send),
is_cancelled: &(dyn Fn() -> bool + Sync),
) -> Result<(), String> {
crate::commands::query::check_statement_budget(
run.statements.len(),
crate::commands::query::MAX_RESTORE_STATEMENTS,
true,
)?;
// The form lets the user target a database other than the one this
// connection defaults to, so select it explicitly.
exec_restore_statement(
conn,
&format!("USE {}", crate::db::mysql::mysql_quote_ident(run.database)),
)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let total = restore_progress_total(run.statements.len(), run.clean);
let mut done = 0usize;
if run.clean {
let drops = crate::db::mysql_clean::plan_clean_drops(run.database, run.statements);
if !drops.is_empty() {
// Foreign keys off for the drop phase, so object ordering cannot
// fail the restore; restored on the same session right after.
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 0")
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
for drop_sql in &drops {
if is_cancelled() {
return Err("Query cancelled".to_string());
}
exec_restore_statement(conn, drop_sql)
.await
.map_err(|e| restore_statement_error(&e))?;
}
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 1")
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
}
done += 1;
on_progress(done, total);
}
for (idx, stmt) in run.statements.iter().enumerate() {
if is_cancelled() {
return Err("Query cancelled".to_string());
}
if let Err(e) = exec_restore_statement(conn, stmt).await {
if crate::commands::query::is_mysql_cancel_error(&e) {
return Err("Query cancelled".to_string());
}
return Err(format_restore_statement_error(
idx + 1,
run.statements.len(),
&sanitize_error(&format!("{e}")),
));
}
done += 1;
on_progress(done, total);
}
Ok(())
}
/// Run a single restore statement, preserving the driver error so the caller
/// can distinguish a cancellation from a genuine statement failure.
///
/// Uses the *simple* query protocol (a bare string through `Executor`), not the
/// prepared-statement protocol: MySQL rejects several restore statements
/// (`USE`, `SET`, some DDL) with error 1295 when prepared.
async fn exec_restore_statement(
conn: &mut sqlx::mysql::MySqlConnection,
sql: &str,
) -> Result<(), sqlx::Error> {
use sqlx::Executor;
conn.execute(sql).await.map(|_| ())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission) // Tauri commands (thin wrappers: store lookup + keychain + event emission)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -610,6 +821,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: Some(1.0), progress: Some(1.0),
output_line: None, output_line: None,
error: None, error: None,
warning: None,
}, },
Err(e) => BackupProgressEvent { Err(e) => BackupProgressEvent {
job_id: job_id.to_string(), job_id: job_id.to_string(),
@@ -617,6 +829,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: None, progress: None,
output_line: None, output_line: None,
error: Some(e), error: Some(e),
warning: None,
}, },
}; };
let _ = app_handle.emit("backup-progress", event); let _ = app_handle.emit("backup-progress", event);
@@ -805,20 +1018,40 @@ pub async fn db_sync(
// MySQL dump / restore / sync commands (v0.7.8) // MySQL dump / restore / sync commands (v0.7.8)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tauri::command] fn build_mysql_tool_status(dump: &ToolResolution, client: &ToolResolution) -> MySqlToolStatus {
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
MySqlToolStatus { MySqlToolStatus {
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(), mysqldump_found: dump.resolved.is_some(),
mysql_found: Command::new(&m).arg("--version").output().is_ok(), mysql_found: client.resolved.is_some(),
mysqldump_version: get_version(&d), mysqldump_version: dump.version.clone(),
mysql_version: get_version(&m), mysql_version: client.version.clone(),
mysqldump_source: ds, mysqldump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysql_source: ms, mysql_source: client.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysqldump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
mysql_resolved_name: client.resolved.as_ref().map(|r| r.name.clone()),
mysqldump_is_mariadb: dump
.resolved
.as_ref()
.map(|r| r.name.starts_with("mariadb"))
.unwrap_or(false),
mysqldump_bundled_available: dump.bundled_available,
mysql_bundled_available: client.bundled_available,
} }
} }
#[tauri::command]
pub async fn detect_mysql_tools(force: Option<bool>, app_handle: AppHandle) -> MySqlToolStatus {
let force = force.unwrap_or(false);
tauri::async_runtime::spawn_blocking(move || {
let dump = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), force);
let client = resolve_request(&app_handle, &tool_resolver::mysql_client_request(), force);
build_mysql_tool_status(&dump, &client)
})
.await
.unwrap_or_else(|_| {
build_mysql_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
}
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the /// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
/// SSH tunnel endpoint when present. /// SSH tunnel endpoint when present.
fn mysql_endpoint( fn mysql_endpoint(
@@ -833,6 +1066,68 @@ fn mysql_endpoint(
(conn.host.clone(), conn.port.unwrap_or(3306), false) (conn.host.clone(), conn.port.unwrap_or(3306), false)
} }
/// Advisory server version for the compatibility check. Fail-open by design:
/// any error (TLS, SSH tunnel, auth) returns `None`, and the dump proceeds.
async fn mysql_server_version(
host: &str,
port: i64,
via_tunnel: bool,
conn: &crate::models::Connection,
password: &str,
) -> Option<String> {
let database = conn.database.clone().unwrap_or_default();
let original_host = conn.host.clone();
let ssl_mode = conn.ssl_mode.clone();
let ssl_ca = conn.ssl_ca_path.clone();
let username = conn.username.clone().unwrap_or_else(|| "root".into());
let mut c = crate::commands::db_viewer::open_mysql_connection(
host,
port as u16,
&username,
password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
)
.await
.ok()?;
sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&mut c)
.await
.ok()
}
/// Emit the advisory warning when the resolved dump client is clearly older
/// than the server. Never returns an error — the job always proceeds.
async fn maybe_emit_version_warning(
app_handle: &AppHandle,
job_id: &str,
resolution: &ToolResolution,
server_version: Option<String>,
) {
let (Some(client_version), Some(tool)) = (&resolution.version, &resolution.resolved) else {
return;
};
let Some(server_version) = server_version else { return };
let Some(warning) = tool_resolver::warning_for(client_version, &server_version, &tool.name)
else {
return;
};
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id.to_string(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some(warning),
},
);
}
#[tauri::command] #[tauri::command]
pub async fn mysql_dump( pub async fn mysql_dump(
connection_id: String, connection_id: String,
@@ -857,6 +1152,15 @@ pub async fn mysql_dump(
.unwrap_or_default(); .unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn); let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(&host, port, via_tunnel, &conn, &password).await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let params = MySqlConnParams::new( let params = MySqlConnParams::new(
host, host,
port, port,
@@ -901,20 +1205,129 @@ pub async fn mysql_restore(
.unwrap_or_default(); .unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn); let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
let params = MySqlConnParams::new(
host, // Native restore: no tool resolution, no subprocess, no MYSQL_PWD in a
port, // child environment. The sqlx driver handles MySQL 8 auth and TLS.
conn.username.clone().unwrap_or_else(|| "root".into()), let statements = {
options.database.clone(), let bytes = std::fs::metadata(&options.file_path)
password, .map_err(|e| format!("Cannot read restore file: {e}"))?
.len();
check_restore_file_size(bytes, MAX_RESTORE_FILE_BYTES)?;
let text = std::fs::read_to_string(&options.file_path)
.map_err(|e| format!("Cannot read restore file: {e}"))?;
let statements = crate::db::sql_split::split_statements(&text);
crate::commands::query::check_statement_budget(
statements.len(),
crate::commands::query::MAX_RESTORE_STATEMENTS,
true,
)?;
statements
};
let database = options.database.clone();
let clean = options.clean;
let original_host = conn.host.clone();
let ssl_mode = conn.ssl_mode.clone();
let ssl_ca = conn.ssl_ca_path.clone();
let username = conn.username.clone().unwrap_or_else(|| "root".into());
// Real cancellation: a per-restore flag that the existing `cancel_query`
// command sets, plus the restore session's MySQL thread id registered so
// `KILL QUERY` aborts the in-flight statement. Cleared before each run so a
// stale request from a previous operation cannot abort this one.
let cancel_flag = state.cancel_registry.cancel_flag(&connection_id);
cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst);
let cancel_opts = crate::commands::db_viewer::mysql_connect_options(
&host,
port as u16,
&username,
&password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
); );
let tools = resolve_mysql_tool_paths(&app_handle);
let tls = if via_tunnel { Some("REQUIRED") } else { None };
let job_id_clone = job_id.clone(); let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || { let connection_id_for_cancel = connection_id.clone();
let result = run_mysql_restore(&params, &options, &tools, tls);
tokio::spawn(async move {
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_secs(1);
let mut emit_progress = |done: usize, total: usize| {
// Throttle: a 50k-statement dump must not flood the IPC channel.
if last_emit.elapsed() < std::time::Duration::from_millis(200) && done < total {
return;
}
last_emit = std::time::Instant::now();
let _ = app_handle_clone.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "running".into(),
progress: Some(done as f64 / total.max(1) as f64),
output_line: Some(format!("Statement {done} of {total}")),
error: None,
warning: None,
},
);
};
let is_cancelled = {
let flag = cancel_flag.clone();
move || flag.load(std::sync::atomic::Ordering::SeqCst)
};
let result = async {
let mut mconn = crate::commands::db_viewer::open_mysql_connection(
&host,
port as u16,
&username,
&password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
)
.await?;
// Register the restore session so the UI's `cancel_query` can
// `KILL QUERY` it (mirrors how browsing registers pooled conns).
// The registry is reached through the app handle because it is not
// `Clone`.
let registry = app_handle_clone.state::<crate::AppState>();
let restore_conn_id: i64 =
sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut mconn)
.await
.unwrap_or(-1);
registry.cancel_registry.set_mysql(
&connection_id_for_cancel,
crate::cancel::MySqlCancel {
conn_id: Some(restore_conn_id),
connect_options: cancel_opts,
},
);
let r = run_mysql_restore_on(
&mut mconn,
MySqlRestoreRun { database: &database, statements: &statements, clean },
&mut emit_progress,
&is_cancelled,
)
.await;
registry
.cancel_registry
.set_mysql_conn_id(&connection_id_for_cancel, None);
registry.cancel_registry.clear_cancelled(&connection_id_for_cancel);
r
}
.await;
emit_result(&app_handle_clone, &job_id_clone, result); emit_result(&app_handle_clone, &job_id_clone, result);
}); });
@@ -971,6 +1384,23 @@ pub async fn mysql_sync(
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn); let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn); let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(
&src_host,
src_port,
src_via_tunnel,
&source_conn,
&src_password,
)
.await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let source = MySqlConnParams::new( let source = MySqlConnParams::new(
src_host, src_host,
src_port, src_port,
+367 -34
View File
@@ -132,15 +132,44 @@ fn build_pg_restore_args_with_schema() {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
#[test] #[test]
fn detect_pg_tools_does_not_panic() { fn build_pg_tool_status_reflects_resolutions() {
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the let dump = ToolResolution {
// status-shaping logic testable without one. resolved: Some(crate::db::tool_resolver::Resolution {
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None); program: "/opt/homebrew/bin/mariadb-dump".into(),
// May or may not find tools, but the call itself must not panic source: crate::db::tool_resolver::ToolSource::TrustedDir,
let _ = status.pg_dump_found; name: "pg_dump".into(),
let _ = status.pg_restore_found; }),
let _ = status.pg_dump_version; bundled_path: Some(std::path::PathBuf::from("/app/resources/pg_tools/pg_dump")),
let _ = status.pg_restore_version; bundled_available: true,
version: Some("pg_dump (PostgreSQL) 16.4".into()),
};
let status = build_pg_tool_status(&dump, &ToolResolution::none());
assert!(status.pg_dump_found);
assert!(!status.pg_restore_found);
assert_eq!(status.pg_dump_source.as_deref(), Some("system"));
assert_eq!(status.pg_dump_resolved_name.as_deref(), Some("pg_dump"));
assert!(status.pg_dump_bundled_available);
assert!(!status.pg_restore_bundled_available);
}
#[test]
fn build_mysql_tool_status_reports_the_resolved_dump_name() {
let dump = ToolResolution {
resolved: Some(crate::db::tool_resolver::Resolution {
program: "/opt/homebrew/bin/mariadb-dump".into(),
source: crate::db::tool_resolver::ToolSource::TrustedDir,
name: "mariadb-dump".into(),
}),
bundled_path: Some(std::path::PathBuf::from("/app/resources/mysql_tools/mariadb-dump")),
bundled_available: true,
version: Some("mariadb-dump 11.4.5-MariaDB".into()),
};
let status = build_mysql_tool_status(&dump, &ToolResolution::none());
assert!(status.mysqldump_found);
assert!(!status.mysql_found);
assert_eq!(status.mysqldump_resolved_name.as_deref(), Some("mariadb-dump"));
assert!(status.mysqldump_is_mariadb);
assert!(!status.mysql_bundled_available);
} }
#[test] #[test]
@@ -152,6 +181,10 @@ fn pg_tool_status_serialization() {
pg_restore_version: None, pg_restore_version: None,
pg_dump_source: None, pg_dump_source: None,
pg_restore_source: None, pg_restore_source: None,
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false,
}; };
let json = serde_json::to_string(&status).unwrap(); let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found")); assert!(json.contains("pg_dump_found"));
@@ -171,6 +204,7 @@ fn backup_progress_event_completed() {
progress: Some(1.0), progress: Some(1.0),
output_line: None, output_line: None,
error: None, error: None,
warning: None,
}; };
let json = serde_json::to_string(&evt).unwrap(); let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"completed\"")); assert!(json.contains("\"completed\""));
@@ -185,12 +219,41 @@ fn backup_progress_event_failed() {
progress: None, progress: None,
output_line: None, output_line: None,
error: Some("connection refused".into()), error: Some("connection refused".into()),
warning: None,
}; };
let json = serde_json::to_string(&evt).unwrap(); let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"failed\"")); assert!(json.contains("\"failed\""));
assert!(json.contains("\"connection refused\"")); assert!(json.contains("\"connection refused\""));
} }
#[test]
fn backup_progress_event_serializes_a_non_fatal_warning() {
let evt = BackupProgressEvent {
job_id: "job-warn".into(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some("Client/server version mismatch: …".into()),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":\"Client/server version mismatch"), "{json}");
}
#[test]
fn backup_progress_event_serializes_a_null_warning_when_absent() {
let evt = BackupProgressEvent {
job_id: "job-quiet".into(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
warning: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":null"), "{json}");
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Integration tests (headless, against live DBs) // Integration tests (headless, against live DBs)
// //
@@ -406,19 +469,6 @@ fn integration_plain_dump_restore() {
// Tool resolution (Task 2.1: system-first, bundled-fallback) // Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2) // SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -507,28 +557,311 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
database: "shop".into(), file_path: "/tmp/d.sql".into(), database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false, single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
}; };
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts); let args = build_mysql_dump_args(
&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()),
&opts,
false,
);
assert!(args.iter().any(|a| a == "--single-transaction")); assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data")); assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines")); assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop")); // `--databases` is a BOOLEAN flag: the database name must be a separate
// positional argument. `--databases=shop` is rejected by clients as
// "invalid value 'shop'".
let i = args.iter().position(|a| a == "--databases").expect("--databases flag");
assert_eq!(args[i + 1], "shop");
assert!(!args.iter().any(|a| a.starts_with("--databases=")), "{args:?}");
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql")); assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env) // no --password on the command line (uses MYSQL_PWD env)
assert!(args.iter().all(|a| !a.starts_with("--password"))); assert!(args.iter().all(|a| !a.starts_with("--password")));
} }
#[test] #[test]
fn mysql_restore_args_no_clean_flags() { fn mysql_env_uses_mysql_pwd_not_password_arg() {
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false }; let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts); let args = build_mysql_dump_args(
assert!(args.iter().any(|a| a == "--database=shop")); &MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()),
assert!(args.iter().any(|a| a == "--host=h")); &opts,
assert!(args.iter().all(|a| a != "--force")); false,
);
assert!(args.iter().all(|a| !a.starts_with("--password")));
} }
#[test] #[test]
fn mysql_env_uses_mysql_pwd_not_password_arg() { fn mysql_dump_args_omit_column_statistics_for_mariadb_clients() {
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false }; // The bundled client is MariaDB 11.4.5 and rejects the MySQL-8-only option
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts); // with `unknown option '--skip-column-statistics'`, aborting the dump.
assert!(args.iter().all(|a| !a.starts_with("--password"))); let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
let conn = MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into());
let mariadb = build_mysql_dump_args(&conn, &opts, true);
assert!(!mariadb.iter().any(|a| a == "--skip-column-statistics"), "{mariadb:?}");
let mysql = build_mysql_dump_args(&conn, &opts, false);
assert!(mysql.iter().any(|a| a == "--skip-column-statistics"), "{mysql:?}");
}
#[test]
fn tls_args_map_ssl_mode_only_for_mysql_clients() {
// MySQL clients take `--ssl-mode=<mode>`; MariaDB clients have no such
// option and use the boolean `--ssl`.
assert_eq!(tls_args_for_client(false, Some("REQUIRED")), vec!["--ssl-mode=REQUIRED".to_string()]);
assert_eq!(tls_args_for_client(true, Some("REQUIRED")), vec!["--ssl".to_string()]);
assert_eq!(tls_args_for_client(false, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::<String>::new());
}
#[test]
fn client_is_mariadb_reads_the_version_banner() {
// A program that cannot be executed must not panic — it reports false
// (keep the MySQL-only flags), and the dump fails at spawn anyway.
assert!(!client_is_mariadb("/nonexistent/definitely-not-a-tool"));
}
/// Regression guard for the bundled fallback path: the args we generate must be
/// *accepted* by the real bundled MariaDB client. It spawns the staged binary
/// against a closed port and requires the failure to be a CONNECTION error —
/// never an option-parsing error. No live server needed.
///
/// The binaries are gitignored (the release workflow downloads them), so when
/// they are not staged this test logs loudly and returns instead of failing.
#[test]
fn bundled_mariadb_dump_accepts_our_generated_args() {
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
if !bundled.is_file() {
eprintln!(
"SKIPPED bundled_mariadb_dump_accepts_our_generated_args: {bundled:?} is not staged \
(run the release workflow's tool-staging step to enable this guard)"
);
return;
}
let conn = MySqlConnParams::new("127.0.0.1".into(), 1, "root".into(), "mysql".into(), "unused".into());
let opts = MySqlBackupOptions {
database: "mysql".into(),
file_path: std::env::temp_dir().join("gridline_args_guard.sql").to_string_lossy().to_string(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
assert!(client_is_mariadb(bundled.to_str().unwrap()), "the bundled dump tool must be MariaDB's");
let mut args = build_mysql_dump_args(&conn, &opts, true);
args.extend(tls_args_for_client(true, Some("REQUIRED")));
let out = std::process::Command::new(&bundled).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
// "unknown option" covers `--skip-column-statistics`; "unknown variable"
// covers `--ssl-mode` (MariaDB's own wording for the same class of mistake).
assert!(!err.contains("unknown option"), "bundled client rejected an option: {err}");
assert!(!err.contains("unknown variable"), "bundled client rejected an option: {err}");
assert!(!err.contains("invalid value"), "bundled client rejected an option value: {err}");
assert!(
err.contains("2002") || err.contains("can't connect") || err.contains("connection refused"),
"expected a connection failure (port 1 is closed), got: {err}"
);
}
// ── native MySQL restore: error reporting contract ──────────────────
#[test]
fn restore_failure_names_the_statement_position() {
// The user must be able to find the offending statement in their file.
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
}
#[test]
fn restore_progress_total_counts_the_clean_phase() {
// clean adds one progress unit (the drop phase) so the bar never resets.
assert_eq!(restore_progress_total(5, false), 5);
assert_eq!(restore_progress_total(5, true), 6);
}
#[tokio::test]
#[ignore]
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
use sqlx::ConnectOptions;
use sqlx::Connection;
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
.host(&host).port(port).username(&user).password(&pass).database(&db)
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
.disable_statement_logging()
.connect().await.expect("connect");
// Scratch table, removed again at the end, so the live DB is never harmed.
let table = "gl_restore_probe";
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
.execute(&mut conn).await.expect("pre-clean");
let script = format!(
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
INSERT INTO `{table}` VALUES (1, 'first');"
);
let statements = crate::db::sql_split::split_statements(&script);
let mut events: Vec<(usize, usize)> = Vec::new();
let mut on_progress = |done: usize, total: usize| events.push((done, total));
let never = || false;
run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
&mut on_progress,
&never,
).await.expect("first restore");
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
.fetch_one(&mut conn).await.expect("count");
assert_eq!(count, 1);
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
// Second run with clean: the table must be dropped and recreated, so the
// insert cannot collide with the existing primary key.
let mut events2: Vec<(usize, usize)> = Vec::new();
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
&mut on_progress2,
&never,
).await.expect("clean restore must not hit a duplicate key");
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
.fetch_one(&mut conn).await.expect("count2");
assert_eq!(count2, 1, "clean restore must replace, not append");
// A statement that cannot succeed must fail and name its position.
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
let mut noop = |_: usize, _: usize| {};
let err = run_mysql_restore_on(
&mut conn,
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
&mut noop,
&never,
).await.expect_err("must fail");
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
let _ = conn.close().await;
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
.host(&host).port(port).username(&user).password(&pass).database(&db)
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
.connect().await.expect("reconnect");
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
.execute(&mut cleanup).await.expect("cleanup");
let _ = cleanup.close().await;
}
// ── restore file-size cap ───────────────────────────────────────────
#[test]
fn restore_size_cap_accepts_a_file_at_the_limit() {
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
}
#[test]
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
.expect_err("must reject");
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
}
// ── live MySQL dump: system tool vs. bundled tool ───────────────────
//
// Issue #44: the app reported "mysqldump not found" even with MySQL
// client tools installed. These two #[ignore]d tests exercise the real
// `run_mysql_dump` against a live server — once via a tool the shared
// resolver found as a *system* client, once via the *bundled* MariaDB
// client — so both provenance paths are covered end to end.
//
// Run with:
//
// GRIDLINE_TEST_MYSQL_HOST=... GRIDLINE_TEST_MYSQL_PORT=... \
// GRIDLINE_TEST_MYSQL_USER=... GRIDLINE_TEST_MYSQL_DB=... \
// GRIDLINE_TEST_MYSQL_PASSWORD=... \
// cargo test --lib live_mysql_dump -- --ignored --nocapture
fn mysql_conn_from_env(prefix: &str) -> MySqlConnParams {
MySqlConnParams::new(
env(&format!("{prefix}_HOST")),
env(&format!("{prefix}_PORT")).parse().unwrap(),
env(&format!("{prefix}_USER")),
env(&format!("{prefix}_DB")),
env(&format!("{prefix}_PASSWORD")),
)
}
fn live_mysql_dump_opts(conn: &MySqlConnParams, file_name: &str) -> MySqlBackupOptions {
MySqlBackupOptions {
database: conn.database.clone(),
file_path: std::env::temp_dir().join(file_name).to_string_lossy().to_string(),
single_transaction: true,
no_data: false,
routines: true,
triggers: true,
events: false,
}
}
/// Requires GRIDLINE_TEST_MYSQL_{HOST,PORT,USER,DB,PASSWORD} and a system
/// mariadb-dump/mysqldump on this machine.
#[test]
#[ignore]
fn live_mysql_dump_with_a_system_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_system.sql");
let req = crate::db::tool_resolver::mysql_dump_request();
let trusted = crate::db::tool_resolver::trusted_dirs();
let resolved = crate::db::tool_resolver::resolve_with(
&req,
&trusted,
&crate::db::tool_resolver::path_lookup,
None,
crate::db::tool_resolver::probe,
)
.expect("no system mariadb-dump/mysqldump resolved");
eprintln!("resolved: {} ({})", resolved.program, resolved.source.as_str());
let tools = MySqlToolPaths {
mysqldump: resolved.program.clone(),
mysql: resolved.program.clone(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
}
/// Requires GRIDLINE_TEST_MYSQL_* and a staged `resources/mysql_tools` tree.
#[test]
#[ignore]
fn live_mysql_dump_with_the_bundled_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_bundled.sql");
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
assert!(bundled.is_file(), "bundled mariadb-dump not staged at {bundled:?}");
assert!(crate::db::tool_resolver::probe(&bundled), "bundled mariadb-dump does not run");
let tools = MySqlToolPaths {
mysqldump: bundled.to_string_lossy().to_string(),
mysql: bundled.to_string_lossy().to_string(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("bundled dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "bundled dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
} }
@@ -2,7 +2,7 @@ use crate::models::{Connection, ConnectionInput};
use crate::store::Store; use crate::store::Store;
use std::sync::Mutex; use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"]; const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
fn validate(input: &ConnectionInput) -> Result<(), String> { fn validate(input: &ConnectionInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 100 { if input.name.is_empty() || input.name.chars().count() > 100 {
@@ -360,3 +360,7 @@ mod tests {
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0); assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
} }
} }
#[cfg(test)]
#[path = "connections.test.rs"]
mod connections_tests;
@@ -0,0 +1,47 @@
use super::*;
fn mariadb_input() -> ConnectionInput {
ConnectionInput {
name: "Maria local".into(),
db_type: "mariadb".into(),
host: "127.0.0.1".into(),
port: Some(3306),
username: Some("root".into()),
folder_id: None,
tag_ids: vec![],
password: None,
database: Some("test".into()),
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
use_keychain: true,
}
}
#[test]
fn validate_accepts_mariadb_with_port() {
assert!(validate(&mariadb_input()).is_ok());
}
#[test]
fn validate_rejects_mariadb_without_port() {
let mut input = mariadb_input();
input.port = None;
assert!(validate(&input).is_err(), "mariadb requires a port like mysql");
}
#[test]
fn validate_rejects_unknown_db_type() {
let mut input = mariadb_input();
input.db_type = "oracle".into();
assert!(validate(&input).is_err());
}
+322 -46
View File
@@ -5,13 +5,15 @@
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle}; use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
use crate::models::db_viewer::{ use crate::models::db_viewer::{
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
QueryResult, SequenceInfo, TableInfo, TriggerInfo, HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
}; };
use bytes::BytesMut;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error;
use sqlx::Row; use sqlx::Row;
use tauri::State; use tauri::State;
use tokio_postgres::types::ToSql; use tokio_postgres::types::{Format, IsNull, ToSql, Type};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper functions // Helper functions
@@ -564,6 +566,14 @@ pub fn build_pg_insert_sql(
table: &str, table: &str,
columns: &[(String, serde_json::Value)], columns: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) { ) -> (String, Vec<serde_json::Value>) {
// A table with no editable columns (e.g. only a serial/identity PK) inserts
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
if columns.is_empty() {
return (
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
vec![],
);
}
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect(); let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
let mut params: Vec<serde_json::Value> = Vec::new(); let mut params: Vec<serde_json::Value> = Vec::new();
let placeholders: Vec<String> = columns let placeholders: Vec<String> = columns
@@ -585,26 +595,75 @@ pub fn build_pg_insert_sql(
) )
} }
/// A value bound to a PostgreSQL parameter in TEXT wire format, accepting any
/// parameter type.
///
/// The frontend ships edited values as plain strings (and PK locators as
/// numbers/booleans), but binding a native `String` fails for every non-text
/// column type — `String::accepts` only matches `varchar`/`text`/`bpchar`/
/// `name` — with `error serializing parameter N: cannot convert between the
/// Rust type ... and the Postgres type ...`. Binding a native `i64` is also
/// wrong because `i64::accepts` only matches `int8`.
///
/// `PgText` accepts *any* parameter type and writes the value as UTF-8 text in
/// text wire format, so PostgreSQL itself parses the literal into the target
/// column type. This works uniformly for `integer`, `bigint`, `numeric`,
/// `boolean`, `uuid`, `json`/`jsonb`, timestamps, enums, etc. — exactly what
/// PostgreSQL does for an `'<literal>'` in a query.
#[derive(Debug, Clone)]
struct PgText(String);
impl From<&str> for PgText {
fn from(s: &str) -> Self {
PgText(s.to_string())
}
}
impl ToSql for PgText {
fn to_sql(
&self,
_ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
out.extend_from_slice(self.0.as_bytes());
Ok(IsNull::No)
}
fn accepts(_ty: &Type) -> bool {
true
}
fn encode_format(&self, _ty: &Type) -> Format {
Format::Text
}
fn to_sql_checked(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
self.to_sql(ty, out)
}
}
/// Convert a JSON value into a boxed PostgreSQL-bindable value. /// Convert a JSON value into a boxed PostgreSQL-bindable value.
/// ///
/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors. /// Every non-null value is bound as a `PgText` (text wire format) so the server
/// Unknown/complex types are stringified as a fallback. /// parses it into the target column type; SQL `NULL` is bound as `None`. This
/// is deliberately type-agnostic — the caller already knows the column type
/// through the SQL context (e.g. `SET "col" = $1` lets PostgreSQL infer `$1`
/// as `col`'s type).
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> { fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
match v { if v.is_null() {
serde_json::Value::Null => Box::new(Option::<String>::None), return Box::new(Option::<PgText>::None);
serde_json::Value::Bool(b) => Box::new(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Box::new(i)
} else if let Some(f) = n.as_f64() {
Box::new(f)
} else {
Box::new(n.to_string())
}
}
serde_json::Value::String(s) => Box::new(s.clone()),
other => Box::new(other.to_string()),
} }
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
};
Box::new(PgText::from(text.as_str()))
} }
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding. /// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
@@ -882,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`
@@ -893,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());
} }
@@ -936,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(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 ?`.
@@ -1025,7 +1164,7 @@ pub async fn db_connect(
// connections honor the user's mode. Building this before opening the // connections honor the user's mode. Building this before opening the
// tunnel means a config error can't leak the tunnel. // tunnel means a config error can't leak the tunnel.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
will_tunnel, will_tunnel,
); );
let tls = crate::db::tls::build_tls_config( let tls = crate::db::tls::build_tls_config(
@@ -1139,7 +1278,7 @@ pub async fn db_connect(
} }
Err(e) => Err(format!("Connection failed: {}", e)), Err(e) => Err(format!("Connection failed: {}", e)),
} }
} else if config.db_type == "mysql" { } else if config.db_type == "mysql" || config.db_type == "mariadb" {
run_mysql_connect( run_mysql_connect(
&connection_id, &connection_id,
&config, &config,
@@ -2581,6 +2720,58 @@ pub async fn get_functions(
} }
} }
#[tauri::command]
pub async fn get_hypertables(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<HypertableListResponse, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let detected: bool = client
.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
.await
.map(|r| r.get::<_, bool>(0))
.unwrap_or(false);
if !detected {
return Ok(HypertableListResponse {
available: false,
reason: Some("TimescaleDB extension is not installed".into()),
items: vec![],
});
}
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_hypertables_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| format!("TimescaleDB introspection failed: {e}"))?;
Ok(HypertableListResponse {
available: true,
reason: None,
items: rows
.iter()
.map(|r| HypertableInfo {
name: r.get(0),
schema: r.get(1),
num_dimensions: r.get::<_, Option<i64>>(2).unwrap_or(0),
compression_enabled: r.get::<_, Option<bool>>(3).unwrap_or(false),
num_chunks: r.get::<_, Option<i64>>(4).unwrap_or(0),
total_size_bytes: r.get::<_, Option<i64>>(5),
})
.collect(),
})
}
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(HypertableListResponse {
available: false,
reason: Some("Hypertables are a TimescaleDB (PostgreSQL) feature".into()),
items: vec![],
}),
None => Err("Connection not found".into()),
}
}
#[tauri::command] #[tauri::command]
pub async fn get_indexes( pub async fn get_indexes(
connection_id: String, connection_id: String,
@@ -3051,6 +3242,15 @@ mod tests {
); );
} }
/// A table with no editable columns (only a serial/identity PK) must insert
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
#[test]
fn build_pg_insert_sql_empty_columns_uses_default_values() {
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
assert!(params.is_empty());
}
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement /// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
/// from `sqlite_master`. /// from `sqlite_master`.
#[test] #[test]
@@ -3320,6 +3520,78 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// PgText: universal text-format parameter binding (serialization fix)
// -----------------------------------------------------------------------
#[test]
fn pg_text_accepts_any_postgres_type() {
// String::accepts only matches text-ish types, so a plain String can't
// bind to an integer/boolean/uuid/jsonb column. PgText must accept all
// of these (the server parses the text into the column's own type).
for ty in [
Type::TEXT,
Type::VARCHAR,
Type::INT4,
Type::INT8,
Type::FLOAT8,
Type::NUMERIC,
Type::BOOL,
Type::UUID,
Type::JSON,
Type::JSONB,
Type::TIMESTAMP,
Type::TIMESTAMPTZ,
Type::DATE,
Type::TIME,
] {
assert!(
PgText::accepts(&ty),
"PgText must accept {:?}",
ty
);
}
// Sanity: a native String must NOT accept a non-text type (proving the
// root cause this fix addresses).
assert!(!<&str as ToSql>::accepts(&Type::INT4));
}
#[test]
fn pg_text_serializes_as_utf8_text() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::json!("42"));
let mut out = BytesMut::new();
let isnull = val.to_sql_checked(&Type::INT4, &mut out).unwrap();
assert!(matches!(isnull, IsNull::No));
assert_eq!(&out[..], b"42");
// Text wire format: the bytes are raw UTF-8, not binary-encoded int.
assert!(matches!(val.encode_format(&Type::INT4), Format::Text));
}
#[test]
fn pg_text_boxes_null_to_sql_null() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::Value::Null);
let mut out = BytesMut::new();
assert!(matches!(
val.to_sql_checked(&Type::TEXT, &mut out).unwrap(),
IsNull::Yes
));
assert!(out.is_empty());
}
#[test]
fn pg_text_stringifies_booleans_and_numbers() {
let b = pg_box_value(&serde_json::json!(true));
assert!(matches!(b.encode_format(&Type::BOOL), Format::Text));
let n = pg_box_value(&serde_json::json!(5));
assert!(matches!(n.encode_format(&Type::INT4), Format::Text));
}
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> { async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
tokio::sync::Mutex::new(ConnectionPoolManager::new()) tokio::sync::Mutex::new(ConnectionPoolManager::new())
} }
@@ -3497,3 +3769,7 @@ mod tests {
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999")); assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
} }
} }
#[cfg(test)]
#[path = "db_viewer.test.rs"]
mod db_viewer_extra_tests;
@@ -0,0 +1,73 @@
use crate::db::pool::{ConnectionPoolManager, DbHandle};
#[test]
fn hypertables_query_targets_timescale_information_schema() {
let q = crate::db::introspection::pg_hypertables_query("public");
assert!(q.contains("timescaledb_information.hypertables"));
assert!(q.contains("hypertable_schema = $1"));
assert!(q.contains("timescaledb_information.chunks"));
}
#[test]
fn timescale_detect_query_checks_pg_extension() {
let q = crate::db::introspection::pg_timescale_detect_query();
assert!(q.contains("pg_extension"));
assert!(q.contains("'timescaledb'"));
}
#[test]
fn hypertable_response_degrades_gracefully() {
let resp = crate::models::db_viewer::HypertableListResponse {
available: false,
reason: Some("TimescaleDB extension is not installed".into()),
items: vec![],
};
let json = serde_json::to_string(&resp).expect("serializes");
assert!(json.contains("\"available\":false"));
}
/// Live test. On a plain PostgreSQL connection (no TimescaleDB) the detection
/// helper must report `available: false`, never an error. Follows the exact
/// env-var + client-construction convention used by the live PG tests in
/// `commands/objects.test.rs`.
#[tokio::test]
#[ignore]
async fn hypertables_unavailable_on_plain_pg() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move {
let _ = conn.await;
});
let mut pm = ConnectionPoolManager::new();
let id = "test-pg".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
let pm = tokio::sync::Mutex::new(pm);
// Exercise the detection helper directly (the command needs `State`, which
// is not constructible in tests).
let detected = {
let mut g = pm.lock().await;
match g.get(&id) {
Some(DbHandle::Postgresql(c, _)) => {
c.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
.await
}
_ => panic!("expected a postgresql handle"),
}
};
assert!(detected.is_ok(), "detection query must run on plain PG");
let available: bool = detected.unwrap().get(0);
assert!(!available, "plain PG must report available=false");
}
@@ -3,7 +3,7 @@ use crate::store::Store;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Mutex; use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"]; const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct ImportRecord { pub(crate) struct ImportRecord {
+18 -3
View File
@@ -1,3 +1,4 @@
use crate::keychain_acl;
use tauri_plugin_keyring_store::KeyringExt; use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain. /// Store a connection password in the OS keychain.
@@ -11,7 +12,10 @@ pub fn save_connection_password(
app.keyring() app.keyring()
.store .store
.set_password(&connection_id, &password) .set_password(&connection_id, &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
keychain_acl::after_store(&app, &connection_id);
Ok(())
} }
/// Retrieve a connection password from the OS keychain. /// Retrieve a connection password from the OS keychain.
@@ -21,6 +25,9 @@ pub fn get_connection_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
// Repair a stale item ACL first: after a rebuild/path change macOS would
// otherwise ask for the keychain password on every access (once per secret).
keychain_acl::before_read(&app, &connection_id);
app.keyring() app.keyring()
.store .store
.get_password(&connection_id) .get_password(&connection_id)
@@ -33,6 +40,7 @@ pub fn get_connection_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, connection_id);
app.keyring() app.keyring()
.store .store
.get_password(connection_id) .get_password(connection_id)
@@ -55,7 +63,9 @@ pub fn save_connection_ssh_password(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("password", &connection_id), &password) .set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
Ok(())
} }
/// Retrieve an SSH tunnel password from the OS keychain. /// Retrieve an SSH tunnel password from the OS keychain.
@@ -65,6 +75,7 @@ pub fn get_connection_ssh_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", &connection_id)) .get_password(&ssh_account("password", &connection_id))
@@ -93,7 +104,9 @@ pub fn save_connection_ssh_passphrase(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase) .set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
Ok(())
} }
/// Retrieve an SSH private-key passphrase from the OS keychain. /// Retrieve an SSH private-key passphrase from the OS keychain.
@@ -103,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("passphrase", &connection_id)) .get_password(&ssh_account("passphrase", &connection_id))
@@ -127,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, &ssh_account("password", connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", connection_id)) .get_password(&ssh_account("password", connection_id))
+3
View File
@@ -7,7 +7,10 @@ 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 schema_diff;
pub mod schema_graph; pub mod schema_graph;
pub mod settings; pub mod settings;
pub mod ssh; pub mod ssh;
@@ -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");
}
+712 -13
View File
@@ -10,7 +10,7 @@
//! 3. Every query is recorded in the local `query_history` table. //! 3. Every query is recorded in the local `query_history` table.
use crate::db::pool::DbHandle; use crate::db::pool::DbHandle;
use crate::models::db_viewer::{ColumnInfo, QueryResult}; use crate::models::db_viewer::{ColumnInfo, MultiQueryResult, QueryResult, StatementNotice};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Column, Row}; use sqlx::{Column, Row};
use std::time::Instant; use std::time::Instant;
@@ -34,7 +34,7 @@ fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED) e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
} }
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool { pub(crate) fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
match e.as_database_error().and_then(|d| d.code()) { match e.as_database_error().and_then(|d| d.code()) {
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY) Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
_ => e.to_string().to_lowercase().contains("interrupted"), _ => e.to_string().to_lowercase().contains("interrupted"),
@@ -565,25 +565,194 @@ pub(crate) fn mysql_wrap_count(query: &str) -> String {
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim()) format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
} }
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature). /// How a MySQL column value must be decoded, decided from the column's
/// declared type name.
///
/// Why type-directed rather than a blind try-chain: sqlx's MySQL integer
/// compatibility is broad, so `try_get::<i16>` succeeds for a `BIGINT`
/// holding a small value but fails once the value grows — the same column
/// would emit different JSON types for different rows. `MySqlTypeInfo`
/// exposes only `name()` publicly, which is sufficient because it already
/// encodes signedness (`INT UNSIGNED`) and boolean-ness (`BOOLEAN`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MysqlCellKind {
/// Width in bits and signedness; drives the number-vs-string policy.
Int { bits: u8, signed: bool },
/// `BIT(M)` — a bitmask, rendered as a number (spec §4.1).
Bit,
Float,
Double,
Decimal,
Date,
Time,
DateTime,
Json,
Text,
Bytes,
Null,
/// Anything unrecognised (including `GEOMETRY`, which no sqlx decoder
/// accepts even with `chrono`/`bigdecimal` enabled).
Unknown,
}
/// Map a `sqlx` MySQL type name to its decode strategy.
pub(crate) fn mysql_cell_kind(type_name: &str) -> MysqlCellKind {
use MysqlCellKind::*;
let t = type_name.trim().to_ascii_uppercase();
match t.as_str() {
"BOOLEAN" | "TINYINT" => Int { bits: 8, signed: true },
"TINYINT UNSIGNED" => Int { bits: 8, signed: false },
"SMALLINT" => Int { bits: 16, signed: true },
"SMALLINT UNSIGNED" => Int { bits: 16, signed: false },
"MEDIUMINT" => Int { bits: 24, signed: true },
"MEDIUMINT UNSIGNED" => Int { bits: 24, signed: false },
"INT" => Int { bits: 32, signed: true },
"INT UNSIGNED" => Int { bits: 32, signed: false },
"BIGINT" => Int { bits: 64, signed: true },
"BIGINT UNSIGNED" => Int { bits: 64, signed: false },
// YEAR is an unsigned 16-bit value; it needs the unsigned rung, not
// the signed one (int_compatible excludes UNSIGNED columns).
"YEAR" => Int { bits: 16, signed: false },
"BIT" => Bit,
"FLOAT" => Float,
"DOUBLE" => Double,
"DECIMAL" => Decimal,
"DATE" => Date,
"TIME" => Time,
// TIMESTAMP shares the Datetime wire type. Decoded naive on purpose:
// MySQL converts TIMESTAMP to/from the *session* time zone, so
// claiming UTC would assert something the value does not support.
"DATETIME" | "TIMESTAMP" => DateTime,
"JSON" => Json,
"CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT"
| "ENUM" | "SET" => Text,
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => Bytes,
"NULL" => Null,
_ => Unknown,
}
}
/// Convert a sqlx MySql row cell to serde_json::Value, driven by the column's
/// **declared** type (see [`mysql_cell_kind`]).
///
/// Type-directed rather than a blind try-chain because sqlx's MySQL integer
/// compatibility is broad: `try_get::<i16>` succeeds for a `BIGINT` holding
/// a small value and fails once the value grows, so a value-driven chain
/// would emit different JSON types for different rows of one column.
/// Shared with the DB-viewer commands (pub(crate)). /// Shared with the DB-viewer commands (pub(crate)).
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value { pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) { use sqlx::{Row, TypeInfo, ValueRef};
return v;
// `MySqlTypeInfo.r#type`/`.flags` are `pub(crate)`, so the declared type is
// read through the public `name()`, which already distinguishes
// `INT UNSIGNED`, `BIGINT UNSIGNED` and `BOOLEAN` (TINYINT(1)).
let type_name = match row.try_get_raw(i) {
Ok(v) if !v.is_null() => v.type_info().name().to_string(),
// Genuine SQL NULL, or a column we cannot even describe.
_ => return serde_json::Value::Null,
};
match mysql_cell_kind(&type_name) {
MysqlCellKind::Int { bits, signed } => {
if signed {
match row.try_get::<i64, _>(i) {
// <=32-bit fits a JS Number exactly; 64-bit is stringified
// so the value survives the IPC boundary (matches PG).
Ok(v) if bits <= 32 => serde_json::json!(v),
Ok(v) => crate::commands::db_viewer::i64_to_json(v),
Err(_) => serde_json::Value::Null,
} }
if let Ok(s) = row.try_get::<Option<String>, _>(i) { } else {
return s match row.try_get::<u64, _>(i) {
.map(|s| serde_json::Value::String(s)) Ok(v) if bits <= 32 => serde_json::json!(v),
.unwrap_or(serde_json::Value::Null); Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
} }
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) { }
return b }
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())) MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
.unwrap_or(serde_json::Value::Null); Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Float => match row.try_get::<f32, _>(i) {
Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Double => match row.try_get::<f64, _>(i) {
Ok(v) => serde_json::json!(v),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Decimal => match row.try_get::<sqlx::types::BigDecimal, _>(i) {
// Stringified for the same reason as 64-bit ints: exact digits.
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
// Temporal values are emitted naive, never as an instant: MySQL
// converts TIMESTAMP to and from the *session* time zone, so an
// explicit `Z` would assert something the value does not support.
MysqlCellKind::Date => match row.try_get::<chrono::NaiveDate, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::Time => match row.try_get::<chrono::NaiveTime, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
},
MysqlCellKind::DateTime => {
// `try_get_unchecked` (not `try_get`) because sqlx's `NaiveDateTime`
// `Type`/`Compatible` impl matches `ColumnType::Datetime` only, so the
// checked path rejects a TIMESTAMP column even though both share the
// exact same wire format and `Decode` impl. Only the type-compat gate
// is bypassed here; the decode is the same one DATETIME uses.
match row.try_get_unchecked::<chrono::NaiveDateTime, _>(i) {
Ok(v) => serde_json::Value::String(v.to_string()),
Err(_) => serde_json::Value::Null,
}
}
MysqlCellKind::Json => row
.try_get::<serde_json::Value, _>(i)
.unwrap_or(serde_json::Value::Null),
MysqlCellKind::Text => mysql_text_to_json(row, i),
MysqlCellKind::Bytes => mysql_bytes_to_json(row, i),
MysqlCellKind::Null => serde_json::Value::Null,
MysqlCellKind::Unknown => mysql_unknown_to_json(row, i),
}
}
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
/// decode for the odd VARBINARY-shaped metadata column.
fn mysql_text_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(v) = row.try_get::<String, _>(i) {
return serde_json::Value::String(v);
}
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
} }
serde_json::Value::Null serde_json::Value::Null
} }
/// Binary/blob columns. Preserves the pre-existing lossy-UTF-8 rendering;
/// changing binary presentation is explicitly out of scope (spec §3).
fn mysql_bytes_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
}
mysql_text_to_json(row, i)
}
/// Conservative fallback for a type we do not recognise — exactly the three
/// decoders that were safe before this change, so an unknown type can never
/// be worse than before (`GEOMETRY` lands here and stays null).
fn mysql_unknown_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
use sqlx::Row;
if let Ok(v) = row.try_get::<serde_json::Value, _>(i) {
return v;
}
mysql_text_to_json(row, i)
}
async fn execute_mysql_query_on( async fn execute_mysql_query_on(
conn: &mut sqlx::mysql::MySqlConnection, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
@@ -708,6 +877,528 @@ async fn execute_mysql_raw(
}) })
} }
// ---------------------------------------------------------------------------
// Multiple result sets (execute_query_multi)
// ---------------------------------------------------------------------------
// Per the spec: statements execute sequentially; SELECT-producing statements
// get wrapped pagination (first page per set + total count — per-set paging is
// deliberately omitted because re-running a script would re-execute DML);
// DML/DDL produce notices; the first error stops the run, prior sets survive.
/// 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 {
Set(QueryResult),
Notice {
kind: &'static str,
text: String,
affected: Option<i64>,
},
}
/// Classify a statement by its first significant keyword (execution-time
/// classification per spec §4 — NOT in the tokenizer).
pub(crate) fn statement_kind(stmt: &str) -> &'static str {
let k: String = stmt
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect::<String>()
.to_lowercase();
match k.as_str() {
"select" | "with" | "values" | "table" => "select",
"insert" | "update" | "delete" => "dml",
_ => "ddl",
}
}
/// Parse a PG CommandComplete tag ("INSERT 0 5" → 5; "CREATE TABLE" → None).
pub(crate) fn affected_from_tag(tag: &str) -> Option<i64> {
tag.split_whitespace().last()?.parse::<i64>().ok()
}
fn notice_for(
idx: usize,
kind: &str,
text: String,
affected: Option<i64>,
) -> StatementNotice {
StatementNotice {
statement_index: idx,
kind: kind.to_string(),
text,
affected,
}
}
// ── SQLite walker ─────────────────────────────────────────────────
pub(crate) fn execute_sqlite_statement(
conn: &rusqlite::Connection,
stmt: &str,
page: i64,
page_size: i64,
) -> Result<StatementOutcome, String> {
match conn.execute(stmt, []) {
Ok(n) => Ok(StatementOutcome::Notice {
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
text: format!("OK — {n} rows affected"),
affected: Some(n as i64),
}),
Err(rusqlite::Error::ExecuteReturnedResults) => {
let (columns, all_rows) = execute_sqlite_with_query(conn, stmt)?;
let total_rows = all_rows.len() as i64;
let rows: Vec<Vec<serde_json::Value>> = all_rows
.into_iter()
.take(page_size.max(0) as usize)
.collect();
Ok(StatementOutcome::Set(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
}))
}
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::OperationInterrupted =>
{
Err("Query cancelled".to_string())
}
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn run_sqlite_statements(
conn: &rusqlite::Connection,
statements: &[String],
page: i64,
page_size: i64,
result_sets: &mut Vec<QueryResult>,
notices: &mut Vec<StatementNotice>,
) -> Option<String> {
for (idx, stmt) in statements.iter().enumerate() {
match execute_sqlite_statement(conn, stmt, page, page_size) {
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
Ok(StatementOutcome::Notice { kind, text, affected }) => {
notices.push(notice_for(idx, kind, text, affected))
}
Err(e) if e == "Query cancelled" => return Some(e),
Err(e) => {
notices.push(StatementNotice {
statement_index: idx,
kind: "error".into(),
text: e.clone(),
affected: None,
});
return Some(format!("Statement {} failed: {e}", idx + 1));
}
}
}
None
}
// ── PostgreSQL walker ─────────────────────────────────────────────
pub(crate) async fn execute_pg_statement(
client: &tokio_postgres::Client,
stmt: &str,
page: i64,
page_size: i64,
) -> Result<StatementOutcome, String> {
let trimmed = stmt.trim();
let off = (page.saturating_sub(1).max(0)) * page_size;
// 1. Try wrapped pagination — a parse error here executes nothing.
let wrapped_count = format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", trimmed);
if let Ok(row) = client.query_one(&wrapped_count, &[]).await {
let total_rows: i64 = row.get(0);
let wrapped_data = format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
trimmed
);
let data_rows = client
.query(&wrapped_data, &[&page_size, &off])
.await
.map_err(|e| {
if is_pg_cancel_error(&e) {
"Query cancelled".to_string()
} else {
format!("{e}")
}
})?;
let columns: Vec<ColumnInfo> = match data_rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: format!("{:?}", c.type_()),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => Vec::new(),
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
.collect()
})
.collect();
return Ok(StatementOutcome::Set(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
}));
}
// 2. Raw single-statement execution via simple_query (captures the
// CommandComplete tag so DML notices carry affected counts).
let messages = client
.simple_query(trimmed)
.await
.map_err(|e| {
if is_pg_cancel_error(&e) {
"Query cancelled".to_string()
} else {
crate::commands::db_viewer::pg_error_message(&e)
}
})?;
let mut columns: Vec<ColumnInfo> = Vec::new();
let mut all_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut last_tag: Option<String> = None;
for msg in messages {
match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => {
if columns.is_empty() {
columns = row
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: "text".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
}
let values: Vec<serde_json::Value> = (0..row.len())
.map(|i| match row.try_get::<usize>(i) {
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
_ => serde_json::Value::Null,
})
.collect();
all_rows.push(values);
}
tokio_postgres::SimpleQueryMessage::CommandComplete(tag) => {
last_tag = Some(tag.to_string());
}
_ => {}
}
}
if all_rows.is_empty() {
let tag = last_tag.clone().unwrap_or_else(|| "OK".to_string());
let affected = last_tag.as_deref().and_then(affected_from_tag);
return Ok(StatementOutcome::Notice {
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
text: tag,
affected,
});
}
let total_rows = all_rows.len() as i64;
let rows: Vec<Vec<serde_json::Value>> = all_rows
.into_iter()
.take(page_size.max(0) as usize)
.collect();
Ok(StatementOutcome::Set(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
}))
}
pub(crate) async fn run_pg_statements(
client: &tokio_postgres::Client,
statements: &[String],
page: i64,
page_size: i64,
result_sets: &mut Vec<QueryResult>,
notices: &mut Vec<StatementNotice>,
) -> Option<String> {
for (idx, stmt) in statements.iter().enumerate() {
match execute_pg_statement(client, stmt, page, page_size).await {
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
Ok(StatementOutcome::Notice { kind, text, affected }) => {
notices.push(notice_for(idx, kind, text, affected))
}
Err(e) if e == "Query cancelled" => return Some(e),
Err(e) => {
notices.push(StatementNotice {
statement_index: idx,
kind: "error".into(),
text: e.clone(),
affected: None,
});
return Some(format!("Statement {} failed: {e}", idx + 1));
}
}
}
None
}
// ── MySQL walker ──────────────────────────────────────────────────
async fn execute_mysql_statement_on(
conn: &mut sqlx::mysql::MySqlConnection,
stmt: &str,
page: i64,
page_size: i64,
) -> Result<StatementOutcome, String> {
let trimmed = stmt.trim();
let off = (page.saturating_sub(1).max(0)) * page_size;
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(&mut *conn)
.await
{
Ok(n) => n,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
return execute_mysql_raw(conn, trimmed, page, page_size, off)
.await
.map(|qr| {
if qr.columns.is_empty() {
StatementOutcome::Notice {
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
text: "OK".to_string(),
affected: None,
}
} else {
StatementOutcome::Set(qr)
}
});
}
};
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size)
.bind(off)
.fetch_all(&mut *conn)
.await
{
Ok(rows) => rows,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(e) => return Err(crate::commands::db_viewer::sanitize_error(&format!("{e}"))),
};
let columns: Vec<ColumnInfo> = match data_rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: c.type_info().to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => Vec::new(),
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
.collect();
Ok(StatementOutcome::Set(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
}))
}
async fn run_mysql_statements(
conn: &mut sqlx::mysql::MySqlConnection,
statements: &[String],
page: i64,
page_size: i64,
result_sets: &mut Vec<QueryResult>,
notices: &mut Vec<StatementNotice>,
cancel_registry: &crate::cancel::CancelRegistry,
connection_id: &str,
) -> Option<String> {
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap_or(-1);
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
for (idx, stmt) in statements.iter().enumerate() {
match execute_mysql_statement_on(conn, stmt, page, page_size).await {
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
Ok(StatementOutcome::Notice { kind, text, affected }) => {
notices.push(notice_for(idx, kind, text, affected))
}
Err(e) if e == "Query cancelled" => {
cancel_registry.set_mysql_conn_id(connection_id, None);
return Some(e);
}
Err(e) => {
notices.push(StatementNotice {
statement_index: idx,
kind: "error".into(),
text: e.clone(),
affected: None,
});
cancel_registry.set_mysql_conn_id(connection_id, None);
return Some(format!("Statement {} failed: {e}", idx + 1));
}
}
}
cancel_registry.set_mysql_conn_id(connection_id, None);
None
}
/// Execute a multi-statement script, returning every result set and notices.
/// `execute_query` is untouched; the query workspace calls this instead.
#[tauri::command]
pub async fn execute_query_multi(
connection_id: String,
query: String,
page: Option<i64>,
page_size: Option<i64>,
state: State<'_, crate::AppState>,
) -> Result<MultiQueryResult, String> {
let start = Instant::now();
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let statements = crate::db::sql_split::split_statements(&query);
if statements.is_empty() {
return Err("Query cannot be empty".to_string());
}
check_statement_budget(statements.len(), MAX_STATEMENTS, false)?;
let mut result_sets: Vec<QueryResult> = Vec::new();
let mut notices: Vec<StatementNotice> = Vec::new();
let err_text: Option<String>;
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
err_text =
run_pg_statements(client, &statements, p, ps, &mut result_sets, &mut notices).await;
}
Some(DbHandle::Sqlite(conn)) => {
err_text = run_sqlite_statements(conn, &statements, p, ps, &mut result_sets, &mut notices);
}
Some(DbHandle::MySql(pool)) => {
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
err_text = run_mysql_statements(
&mut *conn,
&statements,
p,
ps,
&mut result_sets,
&mut notices,
&state.cancel_registry,
&connection_id,
)
.await;
}
None => return Err("Connection not found".to_string()),
}
let elapsed = start.elapsed().as_millis() as i64;
let row_count = result_sets.first().map(|q| q.rows.len() as i64).unwrap_or(0);
let history_id = Uuid::new_v4().to_string();
if let Some(e) = &err_text {
insert_history(
&state.db_store,
&history_id,
&connection_id,
&query,
Some(elapsed),
Some(row_count),
"error",
Some(e.as_str()),
);
} else {
insert_history(
&state.db_store,
&history_id,
&connection_id,
&query,
Some(elapsed),
Some(row_count),
"success",
None,
);
}
Ok(MultiQueryResult {
result_sets,
notices,
execution_time_ms: elapsed,
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Query history commands // Query history commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -845,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())?;
@@ -1378,3 +2073,7 @@ mod tests {
assert!(res.unwrap_err().contains("Query cancelled")); assert!(res.unwrap_err().contains("Query cancelled"));
} }
} }
#[cfg(test)]
#[path = "query.test.rs"]
mod query_tests;
@@ -0,0 +1,369 @@
use super::*;
// ── statement_kind / affected_from_tag (pure) ──────────────────────
#[test]
fn statement_kind_classifies_by_first_keyword() {
assert_eq!(statement_kind("SELECT 1"), "select");
assert_eq!(statement_kind(" with c as (select 1) select * from c"), "select");
assert_eq!(statement_kind("insert into t values (1)"), "dml");
assert_eq!(statement_kind("UPDATE t SET a=1"), "dml");
assert_eq!(statement_kind("DELETE FROM t"), "dml");
assert_eq!(statement_kind("CREATE TABLE t (a int)"), "ddl");
assert_eq!(statement_kind("-- c\nDROP TABLE t"), "ddl");
}
#[test]
fn affected_from_tag_parses_pg_command_tags() {
assert_eq!(affected_from_tag("INSERT 0 5"), Some(5));
assert_eq!(affected_from_tag("UPDATE 3"), Some(3));
assert_eq!(affected_from_tag("CREATE TABLE"), None);
}
// ── SQLite walker — real in-memory database, no env vars, not ignored ──
fn mem_conn() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
conn.execute_batch(
"CREATE TABLE t (a INTEGER, b TEXT);
INSERT INTO t VALUES (1, 'x'), (2, 'y'), (3, 'z');",
)
.expect("seed");
conn
}
#[test]
fn sqlite_walker_yields_notice_for_dml_with_affected() {
let conn = mem_conn();
let out = execute_sqlite_statement(&conn, "INSERT INTO t VALUES (4, 'w')", 1, 50)
.expect("statement runs");
match out {
StatementOutcome::Notice { kind, affected, .. } => {
assert_eq!(kind, "dml");
assert_eq!(affected, Some(1));
}
_ => panic!("expected Notice, got Set"),
}
}
#[test]
fn sqlite_walker_yields_set_for_select() {
let conn = mem_conn();
let out = execute_sqlite_statement(&conn, "SELECT a FROM t ORDER BY a", 1, 2)
.expect("statement runs");
match out {
StatementOutcome::Set(qr) => {
assert_eq!(qr.columns.len(), 1);
assert_eq!(qr.rows.len(), 2, "page size caps rows");
assert_eq!(qr.total_rows, 3, "total counts all rows");
}
_ => panic!("expected Set, got Notice"),
}
}
#[test]
fn sqlite_walker_notices_ddl() {
let conn = mem_conn();
let out = execute_sqlite_statement(&conn, "CREATE TABLE u (x INT)", 1, 50).unwrap();
match out {
StatementOutcome::Notice { kind, .. } => assert_eq!(kind, "ddl"),
_ => panic!("expected Notice"),
}
}
#[test]
fn run_sqlite_statements_stops_at_first_error_with_prior_sets() {
let conn = mem_conn();
let statements = crate::db::sql_split::split_statements(
"SELECT a FROM t; SELECT * FROM missing; SELECT 1",
);
let mut sets = Vec::new();
let mut notices = Vec::new();
let err = run_sqlite_statements(&conn, &statements, 1, 50, &mut sets, &mut notices);
assert_eq!(sets.len(), 1, "first set is returned");
assert!(err.is_some(), "the failed statement stops the run");
}
/// Live PG walker test — mirror the env/client conventions of the live PG
/// tests in `commands/objects.test.rs`. Assert: `SELECT 1; SELECT 2` (split
/// via sql_split) produces two Sets via `run_pg_statements`, and
/// `UPDATE`-on-missing-table produces a first-error stop with the error
/// notice attached.
#[tokio::test]
#[ignore]
async fn pg_multi_statement_live() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move {
let _ = conn.await;
});
// Two SELECTs → two Sets, no notices.
let stmts = crate::db::sql_split::split_statements("SELECT 1; SELECT 2");
let mut sets = vec![];
let mut notices = vec![];
let err = run_pg_statements(&client, &stmts, 1, 50, &mut sets, &mut notices).await;
assert!(err.is_none());
assert_eq!(sets.len(), 2);
assert!(notices.is_empty());
// UPDATE on a missing table → first-error stop with an error notice.
let bad = crate::db::sql_split::split_statements("UPDATE gridline_missing SET a=1");
let mut sets2 = vec![];
let mut notices2 = vec![];
let err2 = run_pg_statements(&client, &bad, 1, 50, &mut sets2, &mut notices2).await;
assert!(err2.is_some(), "missing table must stop the run");
assert!(
notices2.iter().any(|n| n.kind == "error"),
"an error notice must be attached"
);
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:?}");
}
@@ -0,0 +1,539 @@
//! Streaming export of a full query result to a local file (spec §4/§5C).
//!
//! Headless `run_*_export` functions (testable without Tauri State) + one
//! thin command that owns the pool lock, the cancel registration, and the
//! `export-progress` events. Fail-closed: any error deletes the partial file.
use crate::db::pool::DbHandle;
use crate::models::db_viewer::ColumnInfo;
use serde::{Deserialize, Serialize};
use sqlx::{Column, Row};
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::State;
const PROGRESS_ROWS: u64 = 5_000;
const CURSOR_NAME: &str = "_gridline_export";
const CURSOR_FETCH: usize = 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExportFormat {
Csv,
Jsonl,
Json,
}
impl ExportFormat {
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"csv" => Ok(ExportFormat::Csv),
"jsonl" => Ok(ExportFormat::Jsonl),
"json" => Ok(ExportFormat::Json),
other => Err(format!("unsupported export format: {other} (csv | jsonl | json)")),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportSummary {
pub rows_written: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ExportProgressEvent {
pub job_id: String,
/// "running" | "done" | "error"
pub status: String,
pub rows: u64,
pub error: Option<String>,
}
/// Read-only heuristic mirroring the frontend `isDestructiveQuery` set (spec §2:
/// never trust the frontend guard alone).
pub fn is_read_only_sql(sql: &str) -> bool {
let k: String = sql
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect::<String>()
.to_uppercase();
!matches!(
k.as_str(),
"INSERT" | "UPDATE" | "DELETE" | "DROP" | "ALTER" | "TRUNCATE" | "CREATE" | "REPLACE"
)
}
/// Validate export inputs: single, read-only statement + absolute path.
fn validate_export_inputs(sql: &str, path: &Path) -> Result<(), String> {
if !path.is_absolute() {
return Err("Export path must be absolute".to_string());
}
if !is_read_only_sql(sql) {
return Err("Export accepts read-only queries only (INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE are rejected)".to_string());
}
let statements = crate::db::sql_split::split_statements(sql);
if statements.len() != 1 {
return Err("Export accepts a single query".to_string());
}
Ok(())
}
// ── Format writers (pure) ─────────────────────────────────────────
/// Quote one CSV cell with formula-injection protection (spec §2) — mirrors
/// the client-side `csvCell` brought to parity in Task 11.
pub fn csv_cell(value: &serde_json::Value) -> String {
let raw = match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let guarded = match raw.chars().next() {
Some('=') | Some('+') | Some('-') | Some('@') | Some('\t') | Some('\r') => {
format!("'{raw}")
}
_ => raw.clone(),
};
if guarded.contains(',') || guarded.contains('"') || guarded.contains('\n') || guarded.contains('\r') {
format!("\"{}\"", guarded.replace('"', "\"\""))
} else {
guarded
}
}
pub fn csv_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
let mut line = String::new();
for (i, _) in columns.iter().enumerate() {
if i > 0 {
line.push(',');
}
line.push_str(&csv_cell(row.get(i).unwrap_or(&serde_json::Value::Null)));
}
line
}
pub fn csv_header(columns: &[ColumnInfo]) -> String {
columns
.iter()
.map(|c| csv_cell(&serde_json::Value::String(c.name.clone())))
.collect::<Vec<_>>()
.join(",")
}
pub fn jsonl_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
let mut obj = serde_json::Map::new();
for (i, c) in columns.iter().enumerate() {
obj.insert(c.name.clone(), row.get(i).cloned().unwrap_or(serde_json::Value::Null));
}
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
}
fn write_row<W: Write>(
w: &mut W,
format: ExportFormat,
columns: &[ColumnInfo],
row: &[serde_json::Value],
first: bool,
) -> std::io::Result<()> {
match format {
ExportFormat::Csv => writeln!(w, "{}", csv_line(columns, row)),
ExportFormat::Jsonl => writeln!(w, "{}", jsonl_line(columns, row)),
ExportFormat::Json => {
if !first {
writeln!(w, ",")?;
}
write!(w, "{}", jsonl_line(columns, row))
}
}
}
/// Run the export, deleting the partial file on any failure (fail-closed).
fn with_partial_cleanup<W, F>(path: &PathBuf, w: W, f: F) -> Result<ExportSummary, String>
where
W: Write,
F: FnOnce(&mut std::io::BufWriter<W>) -> Result<ExportSummary, String>,
{
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, w);
match f(&mut writer) {
Ok(summary) => {
writer.flush().map_err(|e| e.to_string())?;
Ok(summary)
}
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
// ── SQLite (native streaming) ─────────────────────────────────────
pub fn run_sqlite_export(
conn: &rusqlite::Connection,
sql: &str,
format: ExportFormat,
path: &PathBuf,
progress: &mut (dyn FnMut(u64) + Send),
) -> Result<ExportSummary, String> {
validate_export_inputs(sql, path)?;
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
with_partial_cleanup(path, file, |writer| {
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
.map(|i| ColumnInfo {
name: stmt.column_name(i).unwrap_or("?").to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
if format == ExportFormat::Csv {
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
}
if format == ExportFormat::Json {
write!(writer, "[").map_err(|e| e.to_string())?;
}
let col_count = columns.len();
let mut rows_written: u64 = 0;
let mut first = true;
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
let values: Vec<serde_json::Value> = (0..col_count)
.map(|i| sqlite_ref_to_json(row, i))
.collect();
write_row(writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
first = false;
rows_written += 1;
if rows_written % PROGRESS_ROWS == 0 {
progress(rows_written);
}
}
if format == ExportFormat::Json {
writeln!(writer, "]").map_err(|e| e.to_string())?;
}
progress(rows_written);
Ok(ExportSummary { rows_written })
})
}
fn sqlite_ref_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
use rusqlite::types::ValueRef;
match row.get_ref(i) {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
Ok(ValueRef::Real(v)) => serde_json::json!(v),
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
Err(_) => serde_json::Value::Null,
}
}
// ── PostgreSQL (cursor FETCH loop — constant memory, stable order) ──
pub async fn run_pg_export(
client: &tokio_postgres::Client,
sql: &str,
format: ExportFormat,
path: &PathBuf,
progress: &mut (dyn FnMut(u64) + Send),
) -> Result<ExportSummary, String> {
validate_export_inputs(sql, path)?;
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
let trimmed = sql.trim();
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
// LINEAR FORM: run the cursor loop inline; on any error drop the writer
// and remove the partial file.
let run = async {
client.simple_query("BEGIN").await.map_err(|e| format!("export begin failed: {e}"))?;
if let Err(e) = client
.simple_query(&format!("DECLARE {CURSOR_NAME} CURSOR FOR ({trimmed})"))
.await
{
let _ = client.simple_query("ROLLBACK").await;
return Err(format!("export declare failed: {e}"));
}
let mut columns: Vec<ColumnInfo> = Vec::new();
let mut rows_written: u64 = 0;
let mut first = true;
if format == ExportFormat::Json {
write!(writer, "[").map_err(|e| e.to_string())?;
}
loop {
let msgs = client
.simple_query(&format!("FETCH FORWARD {CURSOR_FETCH} FROM {CURSOR_NAME}"))
.await;
let msgs = match msgs {
Ok(m) => m,
Err(e) => {
let _ = client.simple_query("ROLLBACK").await;
return Err(format!("export fetch failed: {e}"));
}
};
let mut batch_rows: Vec<Vec<serde_json::Value>> = Vec::new();
for msg in msgs {
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
if columns.is_empty() {
columns = row
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: "text".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
}
// Simple-query protocol returns every value as text; decode
// as &str (usize has no FromSql impl) and NULL as None.
let values: Vec<serde_json::Value> = (0..row.len())
.map(|i| match row.try_get(i) {
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
_ => serde_json::Value::Null,
})
.collect();
batch_rows.push(values);
}
}
if batch_rows.is_empty() {
break;
}
if format == ExportFormat::Csv && rows_written == 0 {
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
}
for row in batch_rows {
write_row(&mut writer, format, &columns, &row, first).map_err(|e| e.to_string())?;
first = false;
rows_written += 1;
if rows_written % PROGRESS_ROWS == 0 {
progress(rows_written);
}
}
}
if format == ExportFormat::Json {
writeln!(writer, "]").map_err(|e| e.to_string())?;
}
let _ = client.simple_query(&format!("CLOSE {CURSOR_NAME}")).await;
let _ = client.simple_query("ROLLBACK").await;
progress(rows_written);
Ok(ExportSummary { rows_written })
}
.await;
match run {
Ok(summary) => {
writer.flush().map_err(|e| e.to_string())?;
Ok(summary)
}
Err(e) => {
drop(writer);
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
// ── MySQL (sqlx stream — native streaming) ─────────────────────────
pub async fn run_mysql_export(
conn: &mut sqlx::mysql::MySqlConnection,
sql: &str,
format: ExportFormat,
path: &PathBuf,
progress: &mut (dyn FnMut(u64) + Send),
) -> Result<ExportSummary, String> {
validate_export_inputs(sql, path)?;
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
use futures_util::TryStreamExt;
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
let run = async {
let mut stream = sqlx::query(sql).fetch(&mut *conn);
let mut columns: Vec<ColumnInfo> = Vec::new();
let mut rows_written: u64 = 0;
let mut first = true;
if format == ExportFormat::Json {
write!(writer, "[").map_err(|e| e.to_string())?;
}
while let Some(row) = stream.try_next().await.map_err(|e| {
crate::commands::db_viewer::sanitize_error(&format!("{e}"))
})? {
if columns.is_empty() {
columns = row
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: c.type_info().to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
if format == ExportFormat::Csv {
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
}
}
let values: Vec<serde_json::Value> =
(0..row.len()).map(|i| crate::commands::query::mysql_cell_to_json(&row, i)).collect();
write_row(&mut writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
first = false;
rows_written += 1;
if rows_written % PROGRESS_ROWS == 0 {
progress(rows_written);
}
}
if format == ExportFormat::Json {
writeln!(writer, "]").map_err(|e| e.to_string())?;
}
progress(rows_written);
Ok(ExportSummary { rows_written })
}
.await;
match run {
Ok(summary) => {
writer.flush().map_err(|e| e.to_string())?;
Ok(summary)
}
Err(e) => {
drop(writer);
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
// ── Dispatcher + thin command ──────────────────────────────────────
/// Dispatch across engines. The caller (command) owns the pool lock and the
/// cancel registration; MySQL callers must hold a dedicated connection.
pub(crate) async fn run_export_query(
handle: &mut DbHandle,
sql: &str,
format: ExportFormat,
path: &PathBuf,
progress: &mut (dyn FnMut(u64) + Send),
) -> Result<ExportSummary, String> {
match handle {
DbHandle::Sqlite(conn) => run_sqlite_export(conn, sql, format, path, progress),
DbHandle::Postgresql(client, _) => run_pg_export(client, sql, format, path, progress).await,
DbHandle::MySql(pool) => {
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
run_mysql_export(&mut *conn, sql, format, path, progress).await
}
}
}
#[tauri::command]
pub async fn export_query_to_file(
connection_id: String,
sql: String,
format: String,
path: String,
state: State<'_, crate::AppState>,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
use tauri::Emitter;
let fmt = ExportFormat::from_str(&format)?;
let path_buf = PathBuf::from(&path);
let job_id = uuid::Uuid::new_v4().to_string();
let mut last_emit = std::time::Instant::now();
let job = job_id.clone();
let mut progress_cb = |rows: u64| {
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
last_emit = std::time::Instant::now();
let _ = app.emit(
"export-progress",
ExportProgressEvent {
job_id: job.clone(),
status: "running".into(),
rows,
error: None,
},
);
}
};
let mut pm = state.pool_manager.lock().await;
let handle = pm
.get(&connection_id)
.ok_or_else(|| "Connection not found".to_string())?;
// MySQL: the STREAMING connection must be the one registered for
// KILL QUERY cancellation — acquire it once here, register its id,
// stream through it, then clear the registration.
let result = match handle {
DbHandle::MySql(pool) => {
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap_or(-1);
state.cancel_registry.set_mysql_conn_id(&connection_id, Some(conn_id));
let r = run_mysql_export(&mut *conn, &sql, fmt, &path_buf, &mut progress_cb).await;
state.cancel_registry.set_mysql_conn_id(&connection_id, None);
r
}
other => run_export_query(other, &sql, fmt, &path_buf, &mut progress_cb).await,
};
match result {
Ok(summary) => {
let _ = app.emit(
"export-progress",
ExportProgressEvent {
job_id: job_id.clone(),
status: "done".into(),
rows: summary.rows_written,
error: None,
},
);
Ok(serde_json::json!({ "rows_written": summary.rows_written, "path": path }))
}
Err(e) => {
let _ = app.emit(
"export-progress",
ExportProgressEvent {
job_id: job_id.clone(),
status: "error".into(),
rows: 0,
error: Some(e.clone()),
},
);
Err(e)
}
}
}
#[cfg(test)]
#[path = "query_export.test.rs"]
mod query_export_tests;
@@ -0,0 +1,235 @@
use super::*;
use sqlx::Connection;
#[test]
fn read_only_heuristic_matches_frontend_guard_set() {
for sql in ["SELECT 1", " with x as (select 1) select * from x", "-- note\nSELECT 1"] {
assert!(is_read_only_sql(sql), "should allow: {sql}");
}
for sql in [
"INSERT INTO t VALUES (1)",
"update t set a = 1",
"DELETE FROM t",
"DROP TABLE t",
"ALTER TABLE t ADD c int",
"TRUNCATE t",
"CREATE TABLE t (a int)",
"REPLACE INTO t VALUES (1)",
] {
assert!(!is_read_only_sql(sql), "should reject: {sql}");
}
}
#[test]
fn csv_cell_quotes_and_guards_formula_injection() {
assert_eq!(csv_cell(&serde_json::Value::String("plain".into())), "plain");
assert_eq!(csv_cell(&serde_json::Value::String("a,b".into())), "\"a,b\"");
assert_eq!(csv_cell(&serde_json::Value::String("a\"b".into())), "\"a\"\"b\"");
assert_eq!(csv_cell(&serde_json::Value::String("a\nb".into())), "\"a\nb\"");
assert_eq!(csv_cell(&serde_json::Value::String("=SUM(A1)".into())), "'=SUM(A1)");
assert_eq!(csv_cell(&serde_json::Value::String("+x".into())), "'+x");
assert_eq!(csv_cell(&serde_json::Value::String("-1".into())), "'-1");
assert_eq!(csv_cell(&serde_json::Value::String("@cmd".into())), "'@cmd");
assert_eq!(csv_cell(&serde_json::Value::Null), "");
assert_eq!(csv_cell(&serde_json::json!(42)), "42");
}
#[test]
fn csv_line_bom_is_written_by_header_only() {
let cols = vec![
crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
crate::models::db_viewer::ColumnInfo { name: "b".into(), data_type: "text".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
];
let line = csv_line(&cols, &[serde_json::json!("1"), serde_json::Value::Null]);
assert_eq!(line, "1,");
}
#[test]
fn jsonl_line_encodes_row_object() {
let cols = vec![crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false }];
let line = jsonl_line(&cols, &[serde_json::json!("x\"y")]);
assert_eq!(line, "{\"a\":\"x\\\"y\"}");
}
#[test]
fn sqlite_export_streams_all_rows_with_bom_and_guard() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE t (a INTEGER, b TEXT);
INSERT INTO t VALUES (1, 'plain');
INSERT INTO t VALUES (2, '=SUM(A1)');
INSERT INTO t VALUES (3, NULL);",
)
.unwrap();
let path = std::env::temp_dir().join(format!("gridline-export-test-{}.csv", std::process::id()));
let mut rows_seen = 0u64;
let summary = run_sqlite_export(&conn, "SELECT a, b FROM t ORDER BY a", ExportFormat::Csv, &path, &mut |n| rows_seen = n)
.expect("export runs");
assert_eq!(summary.rows_written, 3);
assert_eq!(rows_seen, 3);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
let text = String::from_utf8_lossy(&bytes[3..]);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "a,b");
assert_eq!(lines[1], "1,plain");
assert_eq!(lines[2], "2,'=SUM(A1)");
assert_eq!(lines[3], "3,");
let _ = std::fs::remove_file(&path);
}
#[test]
fn sqlite_export_rejects_write_statements_and_multi_statement() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
let path = std::env::temp_dir().join("gridline-export-should-not-exist.csv");
let err = run_sqlite_export(&conn, "INSERT INTO t VALUES (1)", ExportFormat::Csv, &path, &mut |_| {})
.expect_err("write SQL must be rejected");
assert!(err.contains("read-only"), "got: {err}");
let err = run_sqlite_export(&conn, "SELECT 1; SELECT 2", ExportFormat::Csv, &path, &mut |_| {})
.expect_err("multi-statement must be rejected");
assert!(err.contains("single"), "got: {err}");
assert!(!path.exists(), "no partial file may be left behind");
}
#[test]
fn sqlite_export_error_deletes_partial_file() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t (a INTEGER); INSERT INTO t VALUES (1),(2),(3);").unwrap();
let path = std::env::temp_dir().join(format!("gridline-export-partial-{}.csv", std::process::id()));
let err = run_sqlite_export(&conn, "SELECT a, b FROM t", ExportFormat::Csv, &path, &mut |_| {})
.expect_err("bad column must fail");
assert!(!err.is_empty());
assert!(!path.exists(), "partial file must be deleted on error");
}
/// Live PG export — mirror the env/client conventions of the live PG tests in
/// `commands/objects.test.rs`. Assert a cursor export of a 3-row table
/// produces the same 4 CSV lines as the SQLite test (BOM + header + rows).
#[tokio::test]
#[ignore]
async fn pg_export_live() {
let h = match std::env::var("GRIDLINE_TEST_PG_HOST") {
Ok(v) => v,
Err(_) => return,
};
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let _conn_task = tokio::spawn(async move {
let _ = conn.await;
});
client
.batch_execute(
"DROP TABLE IF EXISTS gridline_export_test;
CREATE TABLE gridline_export_test (a INTEGER, b TEXT);
INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL);",
)
.await
.expect("setup");
let path = std::env::temp_dir().join(format!("gridline-export-pg-{}.csv", std::process::id()));
let mut rows_seen = 0u64;
let summary = run_pg_export(
&client,
"SELECT a, b FROM gridline_export_test ORDER BY a",
ExportFormat::Csv,
&path,
&mut |n| rows_seen = n,
)
.await
.expect("export runs");
assert_eq!(summary.rows_written, 3);
assert_eq!(rows_seen, 3);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
let text = String::from_utf8_lossy(&bytes[3..]);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "a,b");
assert_eq!(lines[1], "1,plain");
assert_eq!(lines[2], "2,'=SUM(A1)");
assert_eq!(lines[3], "3,");
let _ = std::fs::remove_file(&path);
let _ = client.batch_execute("DROP TABLE IF EXISTS gridline_export_test").await;
}
/// Live MySQL export — mirror the env conventions of the MySQL live tests in
/// `commands/db_viewer.rs` (GRIDLINE_TEST_MYSQL_*). Same assertions.
#[tokio::test]
#[ignore]
async fn mysql_export_live() {
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")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3306);
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 opts = sqlx::mysql::MySqlConnectOptions::new()
.host(&host)
.port(port)
.username(&user)
.password(&pass)
.database(&db);
let mut conn = sqlx::mysql::MySqlConnection::connect_with(&opts)
.await
.expect("connect to test MySQL");
sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
.execute(&mut conn)
.await
.ok();
sqlx::query("CREATE TABLE gridline_export_test (a INT, b TEXT)")
.execute(&mut conn)
.await
.expect("create");
sqlx::query("INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL)")
.execute(&mut conn)
.await
.expect("insert");
let path = std::env::temp_dir().join(format!("gridline-export-mysql-{}.csv", std::process::id()));
let mut rows_seen = 0u64;
let summary = run_mysql_export(
&mut conn,
"SELECT a, b FROM gridline_export_test ORDER BY a",
ExportFormat::Csv,
&path,
&mut |n| rows_seen = n,
)
.await
.expect("export runs");
assert_eq!(summary.rows_written, 3);
assert_eq!(rows_seen, 3);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
let text = String::from_utf8_lossy(&bytes[3..]);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "a,b");
assert_eq!(lines[1], "1,plain");
assert_eq!(lines[2], "2,'=SUM(A1)");
assert_eq!(lines[3], "3,");
let _ = std::fs::remove_file(&path);
let _ = sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
.execute(&mut conn)
.await;
}
@@ -0,0 +1,82 @@
//! Thin `compare_schemas` command (spec §4: two pool lookups → snapshots →
//! diff; errors name the failing side; staging never happens here).
use crate::models::db_viewer::DiffReport;
use tauri::State;
/// Engine family for diff pairing: mysql and mariadb are one family.
pub fn engine_family(db_type: &str) -> String {
match db_type {
"mysql" | "mariadb" => "mysql".to_string(),
"sqlite" => "sqlite".to_string(),
"postgresql" => "postgresql".to_string(),
other => other.to_string(),
}
}
fn validate_same_family(source_db_type: &str, target_db_type: &str) -> Result<(), String> {
let (s, t) = (engine_family(source_db_type), engine_family(target_db_type));
if s != t {
return Err(format!(
"Schema diff requires the same engine family — source is {s}, target is {t}"
));
}
Ok(())
}
fn connection_db_type(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: &str,
) -> Result<String, String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
let conns = store.get_connections().map_err(|e| e.to_string())?;
conns
.iter()
.find(|c| c.id == connection_id)
.map(|c| c.db_type.clone())
.ok_or_else(|| format!("connection {connection_id} not found"))
}
#[tauri::command]
pub async fn compare_schemas(
source_connection_id: String,
source_schema: String,
target_connection_id: String,
target_schema: String,
state: State<'_, crate::AppState>,
) -> Result<DiffReport, String> {
let source_db_type = connection_db_type(&state.db_store, &source_connection_id)
.map_err(|e| format!("Source connection failed: {e}"))?;
let target_db_type = connection_db_type(&state.db_store, &target_connection_id)
.map_err(|e| format!("Target connection failed: {e}"))?;
validate_same_family(&source_db_type, &target_db_type)?;
let mut pm = state.pool_manager.lock().await;
let source = {
let handle = pm
.get(&source_connection_id)
.ok_or_else(|| "Source connection failed: not connected (open it in the DB viewer first)".to_string())?;
crate::db::schema_diff::capture_snapshot(handle, &source_db_type, &source_schema)
.await
.map_err(|e| format!("Source connection failed: {e}"))?
};
let target = {
let handle = pm
.get(&target_connection_id)
.ok_or_else(|| "Target connection failed: not connected".to_string())?;
crate::db::schema_diff::capture_snapshot(handle, &target_db_type, &target_schema)
.await
.map_err(|e| format!("Target connection failed: {e}"))?
};
let mut report = crate::db::schema_diff::diff_snapshots(&source, &target);
report.source_label = format!("{} · {}", report.source_label, source_connection_id);
report.target_label = format!("{} · {}", report.target_label, target_connection_id);
Ok(report)
}
#[cfg(test)]
#[path = "schema_diff.test.rs"]
mod schema_diff_tests;
@@ -0,0 +1,25 @@
use super::*;
#[test]
fn engine_family_pairs_mysql_with_mariadb() {
assert_eq!(engine_family("mysql"), engine_family("mariadb"));
assert_eq!(engine_family("mysql"), "mysql");
assert_eq!(engine_family("postgresql"), "postgresql");
assert_eq!(engine_family("sqlite"), "sqlite");
assert_ne!(engine_family("postgresql"), engine_family("mysql"));
}
#[test]
fn family_check_rejects_cross_engine_pairs() {
let err = validate_same_family("postgresql", "mysql").expect_err("must reject");
assert!(err.contains("same engine"), "got: {err}");
assert!(validate_same_family("mariadb", "mysql").is_ok());
assert!(validate_same_family("mysql", "mariadb").is_ok());
assert!(validate_same_family("postgresql", "postgresql").is_ok());
}
/// Live end-to-end: register two connections and diff their schemas through
/// the real pool path.
#[test]
#[ignore = "needs the pool manager wiring from the command; run via the UI or the live harness"]
fn compare_command_live_pg() {}
@@ -107,7 +107,7 @@ fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
pub fn validate_test_input(config: &DbConfig) -> Option<String> { pub fn validate_test_input(config: &DbConfig) -> Option<String> {
let db_type = config.db_type.to_lowercase(); let db_type = config.db_type.to_lowercase();
let valid_types = ["postgresql", "mysql", "sqlite", "redis"]; let valid_types = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
if !valid_types.contains(&db_type.as_str()) { if !valid_types.contains(&db_type.as_str()) {
return Some(format!( return Some(format!(
"unsupported database type: {}. Supported types: {}", "unsupported database type: {}. Supported types: {}",
@@ -220,6 +220,7 @@ pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> Te
let result = match config.db_type.to_lowercase().as_str() { let result = match config.db_type.to_lowercase().as_str() {
"postgresql" => test_pg_connection(config, ssh).await, "postgresql" => test_pg_connection(config, ssh).await,
"mysql" => test_mysql_connection(config, ssh).await, "mysql" => test_mysql_connection(config, ssh).await,
"mariadb" => test_mysql_connection(config, ssh).await,
"sqlite" => test_sqlite_connection(config), "sqlite" => test_sqlite_connection(config),
"redis" => test_redis_connection(config, ssh).await, "redis" => test_redis_connection(config, ssh).await,
other => TestConnectionResult { other => TestConnectionResult {
@@ -264,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode. // degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel, target.via_tunnel,
); );
let tls = match crate::db::tls::build_tls_config( let tls = match crate::db::tls::build_tls_config(
@@ -367,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode. // degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel, target.via_tunnel,
); );
match decision { match decision {
@@ -724,3 +725,7 @@ mod tests {
); );
} }
} }
#[cfg(test)]
#[path = "test_connection.test.rs"]
mod test_connection_tests;
@@ -0,0 +1,54 @@
use super::*;
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
use crate::db::pool::DbConfig;
use std::sync::Arc;
fn mariadb_config(port: Option<i64>) -> DbConfig {
DbConfig {
db_type: "mariadb".into(),
host: "127.0.0.1".into(),
port,
username: Some("root".into()),
password: None,
database: Some("test".into()),
..Default::default()
}
}
#[test]
fn validate_test_input_accepts_mariadb_with_port() {
assert_eq!(validate_test_input(&mariadb_config(Some(3306))), None);
}
#[test]
fn validate_test_input_rejects_mariadb_without_port() {
assert!(validate_test_input(&mariadb_config(None)).is_some());
}
/// Live test against a real MariaDB server. Run with:
/// `GRIDLINE_TEST_MARIADB_HOST=… GRIDLINE_TEST_MARIADB_PORT=3306 \
/// GRIDLINE_TEST_MARIADB_USER=… GRIDLINE_TEST_MARIADB_PASSWORD=… \
/// GRIDLINE_TEST_MARIADB_DB=… cargo test -- --ignored mariadb_live`
#[test]
#[ignore]
fn mariadb_live_test_connection_reports_mariadb_version() {
let config = DbConfig {
host: std::env::var("GRIDLINE_TEST_MARIADB_HOST").expect("GRIDLINE_TEST_MARIADB_HOST"),
port: std::env::var("GRIDLINE_TEST_MARIADB_PORT")
.ok()
.and_then(|p| p.parse::<i64>().ok()),
username: std::env::var("GRIDLINE_TEST_MARIADB_USER").ok(),
password: std::env::var("GRIDLINE_TEST_MARIADB_PASSWORD").ok(),
database: std::env::var("GRIDLINE_TEST_MARIADB_DB").ok(),
..mariadb_config(None)
};
let rt = tokio::runtime::Runtime::new().unwrap();
let ssh = std::sync::Mutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let result = rt.block_on(async { test_mysql_connection(&config, &ssh).await });
assert!(result.ok, "connection failed: {:?}", result.error);
let version = result.server_version.unwrap_or_default().to_lowercase();
assert!(
version.contains("mariadb"),
"expected a MariaDB server, got version: {version:?}"
);
}
+120
View File
@@ -887,3 +887,123 @@ mod tests {
assert!(sql.contains("$1") && sql.contains("$2")); assert!(sql.contains("$1") && sql.contains("$2"));
} }
} }
// ---------------------------------------------------------------------------
// TimescaleDB (hypertables)
// ---------------------------------------------------------------------------
/// Detect whether the timescaledb extension is installed on this database.
pub fn pg_timescale_detect_query() -> String {
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string()
}
/// Query hypertables in a schema (TimescaleDB 2.x information views).
/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes.
pub fn pg_hypertables_query(_schema: &str) -> String {
"SELECT h.hypertable_name, h.hypertable_schema, \
h.num_dimensions, \
COALESCE(h.compression_enabled, false), \
(SELECT COUNT(*) FROM timescaledb_information.chunks c \
WHERE c.hypertable_schema = h.hypertable_schema \
AND c.hypertable_name = h.hypertable_name), \
pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \
FROM timescaledb_information.hypertables h \
WHERE h.hypertable_schema = $1 \
ORDER BY h.hypertable_name"
.to_string()
}
// ---------------------------------------------------------------------------
// Schema-diff snapshot queries (0.8.0) — one set-based query per object class
// ---------------------------------------------------------------------------
/// Table columns with resolved types, nullability, and defaults.
/// Columns: table_name, column_name, data_type, is_nullable, default_expr
pub fn pg_diff_columns_query() -> String {
"SELECT c.relname, a.attname, \
pg_catalog.format_type(a.atttypid, a.atttypmod), \
NOT a.attnotnull, \
pg_catalog.pg_get_expr(d.adbin, d.adrelid) \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum \
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') \
ORDER BY c.relname, a.attnum"
.to_string()
}
/// Constraints (PK/UNIQUE/CHECK/FK) with exact column lists and FK metadata.
/// Columns: table_name, constraint_name, contype, cols, ref_cols, ref_table, confdeltype, confupdtype, definition
pub fn pg_diff_constraints_query() -> String {
"SELECT cl.relname, con.conname, con.contype::text, \
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum), \
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum), \
(SELECT n2.nspname || '.' || cl2.relname \
FROM pg_class cl2 JOIN pg_namespace n2 ON n2.oid = cl2.relnamespace \
WHERE cl2.oid = con.confrelid), \
con.confdeltype::text, con.confupdtype::text, \
pg_get_constraintdef(con.oid, true) \
FROM pg_catalog.pg_constraint con \
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace \
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'c', 'f') \
ORDER BY cl.relname, con.contype, con.conname"
.to_string()
}
/// Secondary indexes (constraint-backed indexes excluded), with column lists.
/// Columns: table_name, index_name, is_unique, cols
pub fn pg_diff_indexes_query() -> String {
"SELECT t.relname, i.relname, ix.indisunique, \
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
WHERE k.attnum > 0) \
FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = $1 AND NOT ix.indisprimary \
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) \
ORDER BY t.relname, i.relname"
.to_string()
}
/// Views + materialized views. Columns: schema, name, definition, materialized
pub fn pg_diff_views_query() -> String {
"SELECT v.schemaname, v.viewname, v.definition, false \
FROM pg_views v WHERE v.schemaname = $1 \
UNION ALL \
SELECT v.schemaname, v.matviewname, v.definition, true \
FROM pg_matviews v WHERE v.schemaname = $1 \
ORDER BY 2"
.to_string()
}
/// Sequences. Columns: schema, name, start, increment, min, max, cycle
pub fn pg_diff_sequences_query() -> String {
"SELECT sequence_schema, sequence_name, \
COALESCE(start_value::text, '1'), COALESCE(increment::text, '1'), \
COALESCE(minimum_value::text, '1'), COALESCE(maximum_value::text, '9223372036854775807'), \
cycle_option = 'YES' \
FROM information_schema.sequences \
WHERE sequence_schema = $1 \
ORDER BY sequence_name"
.to_string()
}
/// Enums with ordered labels. Columns: schema, name, label (row per label)
pub fn pg_diff_enums_query() -> String {
"SELECT n.nspname, t.typname, e.enumlabel \
FROM pg_type t \
JOIN pg_namespace n ON n.oid = t.typnamespace \
JOIN pg_enum e ON e.enumtypid = t.oid \
WHERE t.typtype = 'e' AND n.nspname = $1 \
ORDER BY t.typname, e.enumsortorder"
.to_string()
}
+4
View File
@@ -1,9 +1,13 @@
pub mod introspection; pub mod introspection;
pub mod mysql; pub mod mysql;
pub mod mysql_clean;
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;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+60
View File
@@ -324,3 +324,63 @@ mod tests {
assert_eq!(mysql_default_sort(&[]), ""); assert_eq!(mysql_default_sort(&[]), "");
} }
} }
// ── Schema-diff snapshot queries (0.8.0) ──────────────────────────────
/// Columns: table_name, column_name, column_type, is_nullable, column_default
pub fn mysql_snapshot_columns_query(schema: &str) -> String {
format!(
"SELECT table_name, column_name, column_type, is_nullable, \
COALESCE(column_default, '') \
FROM information_schema.columns \
WHERE table_schema = '{}' \
ORDER BY table_name, ordinal_position",
schema.replace('\'', "''")
)
}
/// Constraints (one row per constraint-column). Columns: table_name,
/// constraint_name, constraint_type, column_name, referenced_table_name,
/// delete_rule, update_rule, check_clause
pub fn mysql_snapshot_constraints_query(schema: &str) -> String {
format!(
"SELECT kcu.table_name, kcu.constraint_name, tc.constraint_type, kcu.column_name, \
COALESCE(kcu.referenced_table_name, ''), \
COALESCE(rc.delete_rule, ''), COALESCE(rc.update_rule, ''), \
COALESCE((SELECT cc.check_clause FROM information_schema.check_constraints cc \
WHERE cc.constraint_schema = kcu.constraint_schema \
AND cc.constraint_name = kcu.constraint_name), '') \
FROM information_schema.key_column_usage kcu \
LEFT JOIN information_schema.table_constraints tc \
ON tc.constraint_schema = kcu.constraint_schema \
AND tc.constraint_name = kcu.constraint_name \
AND tc.table_name = kcu.table_name \
LEFT JOIN information_schema.referential_constraints rc \
ON rc.constraint_schema = kcu.constraint_schema \
AND rc.constraint_name = kcu.constraint_name \
WHERE kcu.constraint_schema = '{}' AND tc.constraint_type IS NOT NULL \
ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position",
schema.replace('\'', "''")
)
}
/// Secondary indexes (one row per index-column). Columns: table_name,
/// index_name, non_unique, column_name
pub fn mysql_snapshot_indexes_query(schema: &str) -> String {
format!(
"SELECT table_name, index_name, non_unique, column_name \
FROM information_schema.statistics \
WHERE table_schema = '{}' AND index_name != 'PRIMARY' \
ORDER BY table_name, index_name, seq_in_index",
schema.replace('\'', "''")
)
}
/// Views. Columns: table_schema, table_name, view_definition
pub fn mysql_snapshot_views_query(schema: &str) -> String {
format!(
"SELECT table_schema, table_name, view_definition \
FROM information_schema.views \
WHERE table_schema = '{}'",
schema.replace('\'', "''")
)
}
+220
View File
@@ -0,0 +1,220 @@
//! Planning for a MySQL "clean" restore.
//!
//! `clean` drops only the objects the dump recreates, matching the UI label
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
//! the target database that the dump does not mention survive. It is
//! deliberately *not* `DROP DATABASE` (spec §4.3).
//!
//! Pure by design: takes already-split statements, returns the DROP script.
use crate::db::mysql::mysql_quote_ident;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObjectKind {
Table,
View,
Procedure,
Function,
Trigger,
Event,
}
impl ObjectKind {
fn keyword(self) -> &'static str {
match self {
ObjectKind::Table => "TABLE",
ObjectKind::View => "VIEW",
ObjectKind::Procedure => "PROCEDURE",
ObjectKind::Function => "FUNCTION",
ObjectKind::Trigger => "TRIGGER",
ObjectKind::Event => "EVENT",
}
}
/// Dependents first, tables last, so the drop phase reads predictably.
/// Foreign-key ordering is handled by the caller disabling
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
fn order(self) -> u8 {
match self {
ObjectKind::Trigger => 0,
ObjectKind::Event => 1,
ObjectKind::Procedure => 2,
ObjectKind::Function => 3,
ObjectKind::View => 4,
ObjectKind::Table => 5,
}
}
}
/// Tokens that may appear between the object keyword and its name.
const MODIFIERS: &[&str] = &[
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
"=",
];
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
/// Returns an empty vector when no object can be identified (fail closed).
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
let mut found: Vec<(ObjectKind, String)> = Vec::new();
for stmt in statements {
if let Some((kind, name)) = parse_create_target(stmt) {
let qualified = qualify(default_schema, &name);
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
found.push((kind, qualified));
}
}
}
found.sort_by_key(|(k, _)| k.order());
found
.into_iter()
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
.collect()
}
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
let tokens = unwrap_version_comments(stmt);
let tokens = tokenize(&tokens);
let mut it = tokens.iter().peekable();
while let Some(tok) = it.next() {
if !tok.eq_ignore_ascii_case("CREATE") {
continue;
}
// Walk forward until an object keyword is found.
while let Some(t) = it.next() {
let upper = t.to_ascii_uppercase();
let kind = match upper.as_str() {
"TABLE" => ObjectKind::Table,
"VIEW" => ObjectKind::View,
"PROCEDURE" => ObjectKind::Procedure,
"FUNCTION" => ObjectKind::Function,
"TRIGGER" => ObjectKind::Trigger,
"EVENT" => ObjectKind::Event,
_ => continue,
};
// The first following token that is neither a modifier nor an
// `x=y` clause is the object name.
for cand in it.by_ref() {
let up = cand.to_ascii_uppercase();
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
continue;
}
// A routine's name is followed by its argument list with no
// space (`p`()`, hence a single token); strip that trailing
// `()` so the DROP names the routine, not its signature. This
// must be a *string* suffix — `trim_end_matches('(')` (a char
// pattern) strips nothing from `p`()`.
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
if name.is_empty() {
return None;
}
// Fail closed: a token that is not a plausible identifier means
// we cannot name the object, so plan no DROP rather than emit a
// bogus one. `CREATE TABLE (id int)` tokenizes as
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
// definition fragment, not a table name.
if !is_plausible_identifier(&name) {
return None;
}
return Some((kind, name));
}
return None;
}
}
None
}
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
/// outside a backtick-quoted region means the token is part of an argument
/// list, a column definition, or a statement terminator — not an identifier.
fn is_plausible_identifier(cand: &str) -> bool {
if cand.is_empty() {
return false;
}
let mut in_backtick = false;
for ch in cand.chars() {
match ch {
'`' => in_backtick = !in_backtick,
'(' | ')' | ',' | ';' if !in_backtick => return false,
_ => {}
}
}
true
}
/// Replace `/*!50003 … */` executable comments with their inner text — the
/// shape mysqldump uses to wrap routine and trigger definitions.
fn unwrap_version_comments(stmt: &str) -> String {
let mut out = String::with_capacity(stmt.len());
let chars: Vec<char> = stmt.chars().collect();
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
// skip `/*!` and any leading digits
let mut j = i + 3;
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
out.push(' ');
while j < chars.len()
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
{
out.push(chars[j]);
j += 1;
}
out.push(' ');
i = (j + 2).min(chars.len());
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
/// Split on whitespace, treating backtick-quoted regions as part of one token.
fn tokenize(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
let mut in_backtick = false;
for ch in s.chars() {
match ch {
'`' => {
in_backtick = !in_backtick;
cur.push(ch);
}
c if c.is_whitespace() && !in_backtick => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
fn qualify(default_schema: &str, name: &str) -> String {
let parts: Vec<String> = name
.split('.')
.map(|p| mysql_quote_ident(strip_backticks(p)))
.collect();
if parts.len() == 1 {
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
} else {
parts.join(".")
}
}
fn strip_backticks(s: &str) -> &str {
s.trim_matches('`')
}
#[cfg(test)]
#[path = "mysql_clean.test.rs"]
mod mysql_clean_tests;
@@ -0,0 +1,78 @@
use super::*;
fn plan(sql: &str) -> Vec<String> {
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
}
#[test]
fn plans_a_drop_for_each_recreated_table() {
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
assert_eq!(
got,
vec![
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
]
);
}
#[test]
fn qualifies_unqualified_names_with_the_target_schema() {
assert_eq!(
plan("CREATE TABLE orders (id int);"),
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
);
}
#[test]
fn keeps_an_explicit_schema_qualifier() {
assert_eq!(
plan("CREATE TABLE `other`.`orders` (id int);"),
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
);
}
#[test]
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
// This is the shape mysqldump actually emits for routines/triggers.
let got = plan(
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
);
assert_eq!(
got,
vec![
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
]
);
}
#[test]
fn deduplicates_repeated_objects() {
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
}
#[test]
fn orders_dependents_before_tables() {
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
let kinds: Vec<&str> = got
.iter()
.map(|s| s.split_whitespace().nth(1).unwrap())
.collect();
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
}
#[test]
fn ignores_non_create_statements() {
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
assert!(got.is_empty(), "got {got:?}");
}
#[test]
fn skips_a_create_whose_name_cannot_be_determined() {
// Fail closed: if we cannot name the object we must not guess at a DROP.
let got = plan("CREATE TABLE (id int);");
assert!(got.is_empty(), "got {got:?}");
}
+992
View File
@@ -0,0 +1,992 @@
//! Schema snapshot capture + the pure diff engine (engine lands in Task 14).
//!
//! Snapshots are read-only, single-use, and capped (5,000 objects — spec §2).
//! Sync SQL is generated in the TARGET's dialect with identifiers quoted
//! per engine (spec §2: never raw interpolation).
use crate::db::pool::DbHandle;
use crate::models::db_viewer::*;
use std::collections::BTreeMap;
pub(crate) const SNAPSHOT_CAP: usize = 5_000;
/// Map a PG `conf*type` action code to its SQL name.
pub(crate) fn pg_fk_action(code: &str) -> &'static str {
match code {
"r" => "RESTRICT",
"c" => "CASCADE",
"n" => "SET NULL",
"d" => "SET DEFAULT",
_ => "NO ACTION",
}
}
/// Enforce the 5,000-object cap, setting `truncated` (never silent).
pub(crate) fn apply_snapshot_cap(snap: &mut SchemaSnapshot) {
let mut total = snap.tables.len() + snap.views.len() + snap.sequences.len() + snap.enums.len();
let over = total > SNAPSHOT_CAP;
if over {
let mut remaining = SNAPSHOT_CAP;
fn take<T>(v: &mut Vec<T>, budget: &mut usize) {
if v.len() > *budget {
v.truncate(*budget);
}
*budget -= v.len();
}
take(&mut snap.tables, &mut remaining);
take(&mut snap.views, &mut remaining);
take(&mut snap.sequences, &mut remaining);
take(&mut snap.enums, &mut remaining);
total = SNAPSHOT_CAP;
}
snap.object_count = total;
snap.truncated = over;
}
fn csv_names(s: &str) -> Vec<String> {
s.split(", ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()
}
/// Capture a PostgreSQL schema snapshot (six set-based queries, one per class).
pub(crate) async fn capture_pg(
client: &tokio_postgres::Client,
engine: &str,
schema: &str,
) -> Result<SchemaSnapshot, String> {
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
let mut views: Vec<SnapshotView> = Vec::new();
let mut sequences: Vec<SnapshotSequence> = Vec::new();
let mut enums: BTreeMap<String, SnapshotEnum> = BTreeMap::new();
// Columns
let rows = client
.query(&crate::db::introspection::pg_diff_columns_query(), &[&schema])
.await
.map_err(|e| format!("columns query failed: {e}"))?;
for r in rows {
let tname: String = r.get(0);
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
schema: schema.to_string(),
name: tname,
..Default::default()
});
entry.columns.push(SnapshotColumn {
name: r.get(1),
data_type: r.get(2),
nullable: r.get(3),
default_value: r.get::<_, Option<String>>(4),
});
}
// Constraints (PK / UNIQUE / CHECK / FK)
// Column order: table_name(0), constraint_name(1), contype(2), cols(3),
// ref_cols(4), ref_table(5), confdeltype(6), confupdtype(7), definition(8)
let rows = client
.query(&crate::db::introspection::pg_diff_constraints_query(), &[&schema])
.await
.map_err(|e| format!("constraints query failed: {e}"))?;
for r in rows {
let tname: String = r.get(0);
let cname: String = r.get(1);
let contype: String = r.get(2);
let Some(entry) = tables.get_mut(&tname) else { continue };
match contype.as_str() {
"p" => entry.primary_key = csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
"u" => entry.uniques.push(SnapshotConstraint {
name: cname,
kind: "unique".into(),
definition: r.get::<_, Option<String>>(3).unwrap_or_default(),
}),
"c" => entry.checks.push(SnapshotConstraint {
name: cname,
kind: "check".into(),
definition: r.get::<_, Option<String>>(8).unwrap_or_default(),
}),
"f" => {
let ref_qualified: Option<String> = r.get(5);
let (ref_schema, ref_table) = ref_qualified
.map(|q| match q.split_once('.') {
Some((s, t)) => (s.to_string(), t.to_string()),
None => (String::new(), q.clone()),
})
.unwrap_or_default();
entry.foreign_keys.push(SnapshotFk {
name: cname,
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
ref_schema,
ref_table,
ref_columns: csv_names(&r.get::<_, Option<String>>(4).unwrap_or_default()),
on_delete: pg_fk_action(&r.get::<_, Option<String>>(6).unwrap_or_default()).to_string(),
on_update: pg_fk_action(&r.get::<_, Option<String>>(7).unwrap_or_default()).to_string(),
});
}
_ => {}
}
}
// Indexes
let rows = client
.query(&crate::db::introspection::pg_diff_indexes_query(), &[&schema])
.await
.map_err(|e| format!("indexes query failed: {e}"))?;
for r in rows {
let tname: String = r.get(0);
let Some(entry) = tables.get_mut(&tname) else { continue };
entry.indexes.push(SnapshotIndex {
name: r.get(1),
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
unique: r.get(2),
});
}
// Views + matviews
let rows = client
.query(&crate::db::introspection::pg_diff_views_query(), &[&schema])
.await
.map_err(|e| format!("views query failed: {e}"))?;
for r in rows {
views.push(SnapshotView {
schema: r.get(0),
name: r.get(1),
definition: r.get(2),
materialized: r.get(3),
});
}
// Sequences
let rows = client
.query(&crate::db::introspection::pg_diff_sequences_query(), &[&schema])
.await
.map_err(|e| format!("sequences query failed: {e}"))?;
for r in rows {
sequences.push(SnapshotSequence {
schema: r.get(0),
name: r.get(1),
start: r.get(2),
increment: r.get(3),
minimum: r.get(4),
maximum: r.get(5),
cycle: r.get(6),
});
}
// Enums (one row per label — aggregate by name)
let rows = client
.query(&crate::db::introspection::pg_diff_enums_query(), &[&schema])
.await
.map_err(|e| format!("enums query failed: {e}"))?;
for r in rows {
let ename: String = r.get(1);
let entry = enums.entry(ename.clone()).or_insert_with(|| SnapshotEnum {
schema: r.get(0),
name: ename,
labels: Vec::new(),
});
entry.labels.push(r.get(2));
}
let mut snap = SchemaSnapshot {
engine: engine.to_string(),
schema: schema.to_string(),
tables: tables.into_values().collect(),
views,
sequences,
enums: enums.into_values().collect(),
object_count: 0,
truncated: false,
};
apply_snapshot_cap(&mut snap);
Ok(snap)
}
/// Capture a SQLite (in-file/in-memory) snapshot.
pub(crate) fn capture_sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
crate::db::schema_diff::capture::sqlite(conn)
}
/// Capture a MySQL/MariaDB snapshot.
pub(crate) async fn capture_mysql(
conn: &mut sqlx::mysql::MySqlConnection,
engine: &str,
schema: &str,
) -> Result<SchemaSnapshot, String> {
crate::db::schema_diff::capture::mysql(conn, engine, schema).await
}
/// Dispatch a snapshot capture across engines. `engine` is the CONNECTION's
/// db_type (mariadb vs mysql matters for the diff dialect).
pub(crate) async fn capture_snapshot(
handle: &mut DbHandle,
engine: &str,
schema: &str,
) -> Result<SchemaSnapshot, String> {
match handle {
DbHandle::Postgresql(client, _) => capture_pg(client, engine, schema).await,
DbHandle::MySql(pool) => {
let mut conn = pool
.acquire()
.await
.map_err(|e| format!("source/target connection failed: {e}"))?;
capture_mysql(&mut *conn, engine, schema).await
}
DbHandle::Sqlite(conn) => capture_sqlite(conn),
}
}
pub(crate) mod capture {
use super::*;
pub(crate) fn sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
let mut snap = SchemaSnapshot {
engine: "sqlite".into(),
schema: "main".into(),
..Default::default()
};
let mut stmt = conn
.prepare("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND type IN ('table', 'view') ORDER BY name")
.map_err(|e| e.to_string())?;
let objects: Vec<(String, String, Option<String>)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (otype, name, sql) in objects {
if otype == "view" {
snap.views.push(SnapshotView {
schema: "main".into(),
name,
definition: sql.unwrap_or_default(),
materialized: false,
});
continue;
}
let mut table = SnapshotTable {
schema: "main".into(),
name: name.clone(),
..Default::default()
};
// Columns + PK + defaults
let mut cols = conn
.prepare(&format!("PRAGMA table_info({})", quote_double(&name)))
.map_err(|e| e.to_string())?;
let col_rows: Vec<(String, String, i64, Option<String>, i64)> = cols
.query_map([], |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
drop(cols);
for (cname, ctype, notnull, default, pk) in col_rows {
if pk > 0 {
table.primary_key.push(cname.clone());
}
table.columns.push(SnapshotColumn {
name: cname,
data_type: ctype,
nullable: notnull == 0,
default_value: default,
});
}
// Indexes (skip the PK-mandated sqlite_autoindex_*)
let mut idx = conn
.prepare(&format!("PRAGMA index_list({})", quote_double(&name)))
.map_err(|e| e.to_string())?;
let idx_rows: Vec<(String, i64)> = idx
.query_map([], |r| Ok((r.get(1)?, r.get(2)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
drop(idx);
for (iname, unique) in idx_rows {
if iname.starts_with("sqlite_autoindex") {
continue;
}
let mut info = conn
.prepare(&format!("PRAGMA index_info({})", quote_double(&iname)))
.map_err(|e| e.to_string())?;
let icols: Vec<String> = info
.query_map([], |r| r.get::<_, String>(2))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
drop(info);
table.indexes.push(SnapshotIndex {
name: iname,
columns: icols,
unique: unique != 0,
});
}
// Foreign keys
let mut fks = conn
.prepare(&format!("PRAGMA foreign_key_list({})", quote_double(&name)))
.map_err(|e| e.to_string())?;
let fk_rows: Vec<(i64, i64, String, String, String, String, String)> = fks
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
drop(fks);
// (id, seq, table, from, to, on_update, on_delete) — group by id
let mut grouped: BTreeMap<i64, SnapshotFk> = BTreeMap::new();
for (id, _seq, reftable, from, to, on_update, on_delete) in fk_rows {
let fk = grouped.entry(id).or_insert_with(|| SnapshotFk {
name: format!("fk_{name}_{id}"),
columns: Vec::new(),
ref_schema: "main".into(),
ref_table: reftable,
ref_columns: Vec::new(),
on_delete: String::new(),
on_update: String::new(),
});
fk.columns.push(from);
if !to.is_empty() {
fk.ref_columns.push(to);
}
if !on_delete.is_empty() {
fk.on_delete = on_delete.to_uppercase();
}
if !on_update.is_empty() {
fk.on_update = on_update.to_uppercase();
}
}
table.foreign_keys = grouped.into_values().collect();
snap.tables.push(table);
}
apply_snapshot_cap(&mut snap);
Ok(snap)
}
pub(crate) async fn mysql(
conn: &mut sqlx::mysql::MySqlConnection,
engine: &str,
schema: &str,
) -> Result<SchemaSnapshot, String> {
use crate::db::mysql::{mysql_row_string, mysql_snapshot_columns_query, mysql_snapshot_constraints_query, mysql_snapshot_indexes_query, mysql_snapshot_views_query};
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
let mut views: Vec<SnapshotView> = Vec::new();
let rows = sqlx::query(&mysql_snapshot_columns_query(schema))
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("columns query failed: {e}"))?;
for r in rows {
let tname = mysql_row_string(&r, 0);
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
schema: schema.to_string(),
name: tname,
..Default::default()
});
entry.columns.push(SnapshotColumn {
name: mysql_row_string(&r, 1),
data_type: mysql_row_string(&r, 2),
nullable: mysql_row_string(&r, 3) == "YES",
default_value: (!mysql_row_string(&r, 4).is_empty()).then(|| mysql_row_string(&r, 4)),
});
}
let rows = sqlx::query(&mysql_snapshot_constraints_query(schema))
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("constraints query failed: {e}"))?;
for r in rows {
let tname = mysql_row_string(&r, 0);
let cname = mysql_row_string(&r, 1);
let ctype = mysql_row_string(&r, 2);
let col = mysql_row_string(&r, 3);
let Some(entry) = tables.get_mut(&tname) else { continue };
match ctype.as_str() {
"PRIMARY KEY" => {
if !entry.primary_key.contains(&col) {
entry.primary_key.push(col);
}
}
"UNIQUE" => {
if let Some(u) = entry.uniques.iter_mut().find(|u| u.name == cname) {
u.definition = if u.definition.is_empty() { col.clone() } else { format!("{}, {col}", u.definition) };
} else {
entry.uniques.push(SnapshotConstraint {
name: cname,
kind: "unique".into(),
definition: col,
});
}
}
"FOREIGN KEY" => {
let ref_table = mysql_row_string(&r, 4);
let on_delete = mysql_row_string(&r, 5).to_uppercase();
let on_update = mysql_row_string(&r, 6).to_uppercase();
if let Some(fk) = entry.foreign_keys.iter_mut().find(|f| f.name == cname) {
fk.columns.push(col);
} else {
entry.foreign_keys.push(SnapshotFk {
name: cname,
columns: vec![col],
ref_schema: String::new(),
ref_table,
ref_columns: Vec::new(),
on_delete,
on_update,
});
}
}
"CHECK" => {
if let Some(c) = entry.checks.iter_mut().find(|c| c.name == cname) {
c.definition = format!("{} {}", c.definition, mysql_row_string(&r, 7));
} else {
entry.checks.push(SnapshotConstraint {
name: cname,
kind: "check".into(),
definition: mysql_row_string(&r, 7),
});
}
}
_ => {}
}
}
let rows = sqlx::query(&mysql_snapshot_indexes_query(schema))
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("indexes query failed: {e}"))?;
for r in rows {
let tname = mysql_row_string(&r, 0);
let iname = mysql_row_string(&r, 1);
let unique = mysql_row_string(&r, 2) == "0";
let col = mysql_row_string(&r, 3);
let Some(entry) = tables.get_mut(&tname) else { continue };
if let Some(ix) = entry.indexes.iter_mut().find(|i| i.name == iname) {
ix.columns.push(col);
} else {
entry.indexes.push(SnapshotIndex {
name: iname,
columns: vec![col],
unique,
});
}
}
let rows = sqlx::query(&mysql_snapshot_views_query(schema))
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("views query failed: {e}"))?;
for r in rows {
views.push(SnapshotView {
schema: mysql_row_string(&r, 0),
name: mysql_row_string(&r, 1),
definition: mysql_row_string(&r, 2),
materialized: false,
});
}
let mut snap = SchemaSnapshot {
engine: engine.to_string(),
schema: schema.to_string(),
tables: tables.into_values().collect(),
views,
sequences: Vec::new(),
enums: Vec::new(),
object_count: 0,
truncated: false,
};
apply_snapshot_cap(&mut snap);
Ok(snap)
}
}
/// Double-quote an identifier (PG/SQLite) with embedded-quote doubling.
pub(crate) fn quote_double(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
// ---------------------------------------------------------------------------
// Pure diff engine (spec §4/§5A)
// ---------------------------------------------------------------------------
// Destructive classification (binding): DROP TABLE/VIEW/INDEX/SEQUENCE/TYPE/
// CONSTRAINT, DROP COLUMN, and ALTER COLUMN TYPE are destructive (copy-only);
// everything else (CREATE, ADD COLUMN, ADD CONSTRAINT, RENAME, SET/DROP
// DEFAULT, SET/DROP NOT NULL) is stageable.
pub(crate) fn engine_family(engine: &str) -> &'static str {
match engine {
"mysql" | "mariadb" => "mysql",
"sqlite" => "sqlite",
_ => "postgresql",
}
}
fn quote(family: &str, name: &str) -> String {
match family {
"mysql" => format!("`{}`", name.replace('`', "``")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
fn qualified(family: &str, schema: &str, name: &str) -> String {
match family {
"mysql" => format!("`{}`.`{}`", schema.replace('`', "``"), name.replace('`', "``")),
"sqlite" => quote(family, name),
_ => format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), name.replace('"', "\"\"")),
}
}
fn detail_line(label: &str, old: &str, new: &str) -> DiffDetailLine {
DiffDetailLine { label: label.into(), old: old.into(), new: new.into() }
}
fn item(ty: &str, name: &str, kind: &str, detail: Vec<DiffDetailLine>, sql: Vec<String>, destructive: bool) -> DiffItem {
DiffItem {
object_type: ty.into(),
name: name.into(),
kind: kind.into(),
detail,
sync_sql: sql,
destructive,
}
}
/// Diff two snapshots into a report. Sync SQL is in the TARGET's dialect.
pub fn diff_snapshots(source: &SchemaSnapshot, target: &SchemaSnapshot) -> DiffReport {
let family = engine_family(&target.engine);
let schema = &target.schema;
let mut items: Vec<DiffItem> = Vec::new();
// ── Enums (PG) — must come first (tables may depend on them) ──────────
for (s_enum, t_enum) in enum_pairs(&source.enums, &target.enums) {
match (s_enum, t_enum) {
(Some(s), None) => items.push(item(
"enum", &s.name, "added", vec![],
vec![format!(
"CREATE TYPE {}.{} AS ENUM ({});",
quote(family, &s.schema),
quote(family, &s.name),
s.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect::<Vec<_>>().join(", ")
)],
false,
)),
(None, Some(t)) => items.push(item(
"enum", &t.name, "removed", vec![],
vec![format!("DROP TYPE {}.{};", quote(family, &t.schema), quote(family, &t.name))],
true,
)),
(Some(s), Some(t)) => {
let mut added = Vec::new();
let mut removed_or_reordered = false;
for l in &s.labels {
if !t.labels.contains(l) { added.push(l.clone()); }
}
if t.labels.iter().any(|l| !s.labels.contains(l)) || !common_prefix(&s.labels, &t.labels) {
removed_or_reordered = true;
}
if !added.is_empty() {
if removed_or_reordered {
items.push(item("enum", &s.name, "changed", vec![detail_line("labels", &t.labels.join(", "), &s.labels.join(", "))], vec![], true));
} else {
items.push(item(
"enum", &s.name, "changed",
vec![detail_line("added labels", "", &added.join(", "))],
added.iter().map(|l| format!("ALTER TYPE {}.{} ADD VALUE '{}';", quote(family, &s.schema), quote(family, &s.name), l.replace('\'', "''"))).collect(),
false,
));
}
}
}
(None, None) => {}
}
}
// ── Sequences (PG) ─────────────────────────────────────────────────────
for (s_seq, t_seq) in pair_by(&source.sequences, &target.sequences, |s| s.name.clone()) {
match (s_seq, t_seq) {
(Some(s), None) => items.push(item(
"sequence", &s.name, "added", vec![],
vec![format!(
"CREATE SEQUENCE {q} START WITH {start} INCREMENT BY {inc} MINVALUE {min} MAXVALUE {max} {cycle};",
q = qualified(family, schema, &s.name),
start = s.start, inc = s.increment, min = s.minimum, max = s.maximum,
cycle = if s.cycle { "CYCLE" } else { "NO CYCLE" }
)],
false,
)),
(None, Some(t)) => items.push(item(
"sequence", &t.name, "removed", vec![],
vec![format!("DROP SEQUENCE {};", qualified(family, schema, &t.name))],
true,
)),
(Some(s), Some(t)) => {
let mut sql = String::new();
let mut detail = Vec::new();
for (label, sv, tv) in [
("start", &s.start, &t.start),
("increment", &s.increment, &t.increment),
("minimum", &s.minimum, &t.minimum),
("maximum", &s.maximum, &t.maximum),
] {
if sv != tv {
detail.push(detail_line(label, tv, sv));
if !sql.is_empty() { sql.push(' '); }
match label {
"start" => sql.push_str(&format!("START WITH {sv}")),
"increment" => sql.push_str(&format!("INCREMENT BY {sv}")),
"minimum" => sql.push_str(&format!("MINVALUE {sv}")),
"maximum" => sql.push_str(&format!("MAXVALUE {sv}")),
_ => {}
}
}
}
if s.cycle != t.cycle {
detail.push(detail_line("cycle", &t.cycle.to_string(), &s.cycle.to_string()));
if !sql.is_empty() { sql.push(' '); }
sql.push_str(if s.cycle { "CYCLE" } else { "NO CYCLE" });
}
if !sql.is_empty() {
items.push(item("sequence", &s.name, "changed", detail, vec![format!("ALTER SEQUENCE {} {};", qualified(family, schema, &s.name), sql)], false));
}
}
(None, None) => {}
}
}
// ── Tables ─────────────────────────────────────────────────────────────
for (s_t, t_t) in pair_by(&source.tables, &target.tables, |t| t.name.clone()) {
match (s_t, t_t) {
(Some(s), None) => {
let mut defs: Vec<String> = s.columns.iter().map(|c| {
let mut d = format!("{} {}", quote(family, &c.name), c.data_type);
if !c.nullable { d.push_str(" NOT NULL"); }
if let Some(def) = &c.default_value { d.push_str(&format!(" DEFAULT {def}")); }
d
}).collect();
if !s.primary_key.is_empty() {
defs.push(format!("PRIMARY KEY ({})", s.primary_key.iter().map(|p| quote(family, p)).collect::<Vec<_>>().join(", ")));
}
items.push(item(
"table", &s.name, "added", vec![],
vec![format!("CREATE TABLE {} ({});", qualified(family, schema, &s.name), defs.join(", "))],
false,
));
push_constraint_items(&mut items, family, schema, s, "added");
}
(None, Some(t)) => items.push(item(
"table", &t.name, "removed", vec![],
vec![format!("DROP TABLE {};", qualified(family, schema, &t.name))],
true,
)),
(Some(s), Some(t)) => diff_table(&mut items, family, schema, s, t),
(None, None) => {}
}
}
// ── Views ──────────────────────────────────────────────────────────────
for (s_v, t_v) in pair_by(&source.views, &target.views, |v| v.name.clone()) {
match (s_v, t_v) {
(Some(s), None) => items.push(item(
"view", &s.name, "added", vec![],
vec![format!("CREATE {}VIEW {} AS {};", if s.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))],
false,
)),
(None, Some(t)) => items.push(item(
"view", &t.name, "removed", vec![],
vec![format!("DROP {}VIEW {};", if t.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &t.name))],
true,
)),
(Some(s), Some(t)) if s.definition.trim() != t.definition.trim() => {
if family == "postgresql" || family == "mysql" {
if s.materialized || t.materialized {
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
vec![format!("DROP MATERIALIZED VIEW {q}; CREATE MATERIALIZED VIEW {q} AS {d};", q = qualified(family, schema, &s.name), d = s.definition.trim_end_matches(';'))], true));
} else {
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
vec![format!("CREATE OR REPLACE VIEW {} AS {};", qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))], false));
}
} else {
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![], true));
}
}
_ => {}
}
}
DiffReport {
items,
source_label: format!("{} ({})", source.schema, source.engine),
target_label: format!("{} ({})", target.schema, target.engine),
truncated: source.truncated || target.truncated,
}
}
/// Items safe to stage: non-destructive AND carrying SQL.
pub fn stageable_items(report: &DiffReport) -> Vec<&DiffItem> {
report.items.iter().filter(|i| !i.destructive && !i.sync_sql.is_empty()).collect()
}
fn common_prefix(source: &[String], target: &[String]) -> bool {
let mut ti = 0usize;
for l in target {
if source.contains(l) {
if ti >= source.len() || &source[ti] != l {
return false;
}
ti += 1;
}
}
true
}
/// Pair two keyed lists into (source, target) options by key.
fn pair_by<'a, T, K: Ord + Clone + Eq>(src: &'a [T], tgt: &'a [T], key: impl Fn(&T) -> K) -> Vec<(Option<&'a T>, Option<&'a T>)> {
use std::collections::BTreeMap;
let mut map: BTreeMap<K, (Option<&T>, Option<&T>)> = BTreeMap::new();
for s in src { map.entry(key(s)).or_insert((None, None)).0 = Some(s); }
for t in tgt { map.entry(key(t)).or_insert((None, None)).1 = Some(t); }
map.into_values().collect()
}
fn enum_pairs<'a>(src: &'a [SnapshotEnum], tgt: &'a [SnapshotEnum]) -> Vec<(Option<&'a SnapshotEnum>, Option<&'a SnapshotEnum>)> {
pair_by(src, tgt, |e| e.name.clone())
}
fn push_constraint_items(items: &mut Vec<DiffItem>, family: &str, schema: &str, table: &SnapshotTable, kind: &str) {
let qtn = qualified(family, schema, &table.name);
for fk in &table.foreign_keys {
items.push(item(
"constraint", &format!("{}.{}", table.name, fk.name), kind, vec![],
vec![constraint_fk_sql(family, schema, &qtn, fk)],
false,
));
}
for u in &table.uniques {
let cols = u.definition.split(", ").map(|c| quote(family, c.trim())).collect::<Vec<_>>().join(", ");
items.push(item(
"constraint", &format!("{}.{}", table.name, u.name), kind, vec![],
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &u.name), cols = cols)],
false,
));
}
for c in &table.checks {
items.push(item(
"constraint", &format!("{}.{}", table.name, c.name), kind, vec![],
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
false,
));
}
for ix in &table.indexes {
let cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
items.push(item(
"index", &format!("{}.{}", table.name, ix.name), kind, vec![],
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = quote(family, &ix.name), cols = cols)],
false,
));
}
}
fn constraint_fk_sql(family: &str, _schema: &str, qtn: &str, fk: &SnapshotFk) -> String {
let cols = fk.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
let ref_cols = fk.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
let ref_qualified = match family {
"mysql" => format!("`{}`", fk.ref_table.replace('`', "``")),
"sqlite" => quote(family, &fk.ref_table),
_ => qualified(family, &fk.ref_schema, &fk.ref_table),
};
format!(
"ALTER TABLE {qtn} ADD CONSTRAINT {n} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
n = quote(family, &fk.name), cols = cols, r = ref_qualified, rc = ref_cols,
od = fk.on_delete, ou = fk.on_update
)
}
fn diff_table(items: &mut Vec<DiffItem>, family: &str, schema: &str, s: &SnapshotTable, t: &SnapshotTable) {
let qtn = qualified(family, schema, &s.name);
for (s_c, t_c) in pair_by(&s.columns, &t.columns, |c| c.name.clone()) {
match (s_c, t_c) {
(Some(c), None) => {
let mut sql = format!("ALTER TABLE {qtn} ADD COLUMN {n} {ty}", n = quote(family, &c.name), ty = c.data_type);
if !c.nullable {
if family == "sqlite" && c.default_value.is_none() {
items.push(item("column", &format!("{}.{}", s.name, c.name), "added",
vec![detail_line("nullable", "false", "true (SQLite cannot ADD COLUMN NOT NULL without DEFAULT)")],
vec![format!("{sql};")], false));
continue;
}
sql.push_str(" NOT NULL");
}
if let Some(d) = &c.default_value { sql.push_str(&format!(" DEFAULT {d}")); }
items.push(item("column", &format!("{}.{}", s.name, c.name), "added", vec![], vec![format!("{sql};")], false));
}
(None, Some(c)) => items.push(item(
"column", &format!("{}.{}", s.name, c.name), "removed", vec![],
vec![format!("ALTER TABLE {qtn} DROP COLUMN {n};", n = quote(family, &c.name))],
true,
)),
(Some(sc), Some(tc)) => {
if sc.data_type != tc.data_type {
let sql = match family {
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} TYPE {ty} USING {n}::{ty};", n = quote(family, &sc.name), ty = sc.data_type),
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
_ => String::new(),
};
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
vec![detail_line("type", &tc.data_type, &sc.data_type)],
if sql.is_empty() { vec![] } else { vec![sql] }, true));
}
if sc.nullable != tc.nullable {
let sql = match family {
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} {op};", n = quote(family, &sc.name), op = if sc.nullable { "DROP NOT NULL" } else { "SET NOT NULL" }),
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
_ => String::new(),
};
if !sql.is_empty() {
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
vec![detail_line("nullable", &tc.nullable.to_string(), &sc.nullable.to_string())],
vec![sql], false));
}
}
if sc.default_value != tc.default_value {
let sql = match family {
"postgresql" => match &sc.default_value {
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
},
"mysql" => match &sc.default_value {
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
},
_ => String::new(),
};
if !sql.is_empty() {
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
vec![detail_line("default", &tc.default_value.as_deref().unwrap_or_default(), &sc.default_value.clone().unwrap_or_default())],
vec![sql], false));
}
}
}
(None, None) => {}
}
}
if s.primary_key != t.primary_key {
items.push(item("table", &s.name, "changed",
vec![detail_line("primary key", &t.primary_key.join(", "), &s.primary_key.join(", "))],
vec![], true));
}
for (s_c, t_c) in pair_by(&s.uniques, &t.uniques, |c| c.name.clone()) {
push_constraint_delta(items, family, s, "UNIQUE", s_c, t_c);
}
for (s_c, t_c) in pair_by(&s.checks, &t.checks, |c| c.name.clone()) {
push_constraint_delta(items, family, s, "CHECK", s_c, t_c);
}
for (s_f, t_f) in pair_by(&s.foreign_keys, &t.foreign_keys, |f| f.name.clone()) {
let qtn2 = qualified(family, schema, &s.name);
match (s_f, t_f) {
(Some(f), None) => {
if family == "sqlite" {
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added",
vec![detail_line("note", "", "SQLite cannot add foreign keys to existing tables — table rebuild required")],
vec![], false));
} else {
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added", vec![],
vec![constraint_fk_sql(family, schema, &qtn2, f)], false));
}
}
(None, Some(f)) => items.push(item(
"constraint", &format!("{}.{}", s.name, f.name), "removed", vec![],
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n};", n = quote(family, &f.name))],
true,
)),
(Some(sf), Some(tf)) if sf.definition_differs(tf) => {
items.push(item("constraint", &format!("{}.{}", s.name, sf.name), "changed",
vec![detail_line("definition", &tf.on_delete, &sf.on_delete)],
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n}; ALTER TABLE {qtn2} ADD CONSTRAINT {n2} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
n = quote(family, &sf.name), n2 = quote(family, &sf.name),
cols = sf.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
r = qualified(family, &sf.ref_schema, &sf.ref_table),
rc = sf.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
od = sf.on_delete, ou = sf.on_update)],
true));
}
_ => {}
}
}
for (s_i, t_i) in pair_by(&s.indexes, &t.indexes, |i| i.name.clone()) {
match (s_i, t_i) {
(Some(ix), None) => items.push(item(
"index", &format!("{}.{}", s.name, ix.name), "added", vec![],
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = qualified(family, schema, &ix.name), cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
false,
)),
(None, Some(ix)) => items.push(item(
"index", &format!("{}.{}", s.name, ix.name), "removed", vec![],
vec![format!("DROP INDEX {n};", n = quote(family, &ix.name))],
true,
)),
(Some(si), Some(ti)) if si.columns != ti.columns || si.unique != ti.unique => {
items.push(item("index", &format!("{}.{}", s.name, si.name), "changed",
vec![detail_line("columns", &ti.columns.join(", "), &si.columns.join(", "))],
vec![format!("DROP INDEX {n}; CREATE {u}INDEX {n} ON {qtn} ({cols});", n = qualified(family, schema, &si.name), u = if si.unique { "UNIQUE " } else { "" }, cols = si.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
true));
}
_ => {}
}
}
}
fn push_constraint_delta(items: &mut Vec<DiffItem>, family: &str, table: &SnapshotTable, kind: &str,
s_c: Option<&SnapshotConstraint>, t_c: Option<&SnapshotConstraint>) {
let qtn = qualified(family, &table.schema, &table.name);
match (s_c, t_c) {
(Some(c), None) if kind == "UNIQUE" => items.push(item(
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &c.name), cols = c.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))],
false,
)),
(Some(c), None) if kind == "CHECK" => items.push(item(
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
false,
)),
(Some(c), None) if family == "sqlite" => items.push(item(
"constraint", &format!("{}.{}", table.name, c.name), "added",
vec![detail_line("note", "", "SQLite cannot add constraints to existing tables — table rebuild required")],
vec![], false)),
(None, Some(c)) => items.push(item(
"constraint", &format!("{}.{}", table.name, c.name), "removed", vec![],
vec![format!("ALTER TABLE {qtn} DROP CONSTRAINT {n};", n = quote(family, &c.name))],
true,
)),
(Some(sc), Some(tc)) if sc.definition.trim() != tc.definition.trim() => {
let sql = if kind == "UNIQUE" {
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});",
n = quote(family, &sc.name), cols = sc.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))
} else {
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});",
n = quote(family, &sc.name), d = sc.definition.trim())
};
items.push(item("constraint", &format!("{}.{}", table.name, sc.name), "changed",
vec![detail_line("definition", &tc.definition, &sc.definition)],
vec![sql], true));
}
_ => {}
}
}
impl SnapshotFk {
fn definition_differs(&self, other: &SnapshotFk) -> bool {
self.columns != other.columns
|| self.ref_table != other.ref_table
|| self.ref_columns != other.ref_columns
|| self.on_delete != other.on_delete
|| self.on_update != other.on_update
}
}
#[cfg(test)]
#[path = "schema_diff.test.rs"]
mod schema_diff_tests;
@@ -0,0 +1,362 @@
use super::*;
// ── Pure helpers (PG constraint row mapping) ────────────────────────
#[test]
fn pg_fk_action_maps_codes() {
assert_eq!(pg_fk_action("a"), "NO ACTION");
assert_eq!(pg_fk_action("r"), "RESTRICT");
assert_eq!(pg_fk_action("c"), "CASCADE");
assert_eq!(pg_fk_action("n"), "SET NULL");
assert_eq!(pg_fk_action("d"), "SET DEFAULT");
assert_eq!(pg_fk_action("x"), "NO ACTION");
}
#[test]
fn snapshot_cap_sets_truncated() {
// 5,001 objects → truncated report, never silent (spec §2)
let mut snap = SchemaSnapshot::default();
snap.engine = "postgresql".into();
snap.schema = "s".into();
for i in 0..5_001 {
snap.tables.push(SnapshotTable {
schema: "s".into(),
name: format!("t{i}"),
columns: vec![],
primary_key: vec![],
uniques: vec![],
checks: vec![],
indexes: vec![],
foreign_keys: vec![],
});
}
apply_snapshot_cap(&mut snap);
assert!(snap.truncated);
assert!(snap.object_count <= 5_000);
assert_eq!(snap.tables.len(), snap.object_count);
}
#[test]
fn query_builders_target_the_right_catalogs() {
let cols = crate::db::introspection::pg_diff_columns_query();
assert!(cols.contains("pg_attribute") && cols.contains("format_type"));
let cons = crate::db::introspection::pg_diff_constraints_query();
assert!(cons.contains("pg_constraint") && cons.contains("contype"));
let idx = crate::db::introspection::pg_diff_indexes_query();
assert!(idx.contains("pg_index") && idx.contains("indisunique"));
let views = crate::db::introspection::pg_diff_views_query();
assert!(views.contains("pg_views") && views.contains("pg_matviews"));
let seqs = crate::db::introspection::pg_diff_sequences_query();
assert!(seqs.contains("information_schema.sequences"));
let enums = crate::db::introspection::pg_diff_enums_query();
assert!(enums.contains("pg_type") && enums.contains("pg_enum"));
}
// ── SQLite capture (in-memory, real, NOT ignored) ──────────────────
#[test]
fn sqlite_capture_reads_pragmas() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT 'x', email TEXT);
CREATE UNIQUE INDEX idx_users_email ON users(email);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE);
CREATE VIEW v_users AS SELECT id, name FROM users;",
)
.unwrap();
let snap = capture_sqlite(&conn).expect("capture");
assert_eq!(snap.engine, "sqlite");
assert_eq!(snap.tables.len(), 2);
let users = snap.tables.iter().find(|t| t.name == "users").unwrap();
assert_eq!(users.primary_key, vec!["id"]);
assert_eq!(users.columns.len(), 3);
assert!(users.columns.iter().any(|c| c.name == "name" && !c.nullable));
assert!(users.columns.iter().any(|c| c.name == "email" && c.nullable));
assert!(users.indexes.iter().any(|i| i.name == "idx_users_email" && i.unique));
let orders = snap.tables.iter().find(|t| t.name == "orders").unwrap();
assert_eq!(orders.foreign_keys.len(), 1);
let fk = &orders.foreign_keys[0];
assert!(fk.ref_table.contains("users"));
assert_eq!(fk.on_delete, "CASCADE");
assert_eq!(snap.views.len(), 1);
assert!(snap.views[0].definition.contains("SELECT"));
}
/// Live PG capture — mirror the env/client conventions of the live PG tests in
/// `commands/objects.test.rs`. Create two diverged tables in a temp schema,
/// assert capture shape (columns with defaults, PK, FK with actions, indexes,
/// views, a sequence, an enum).
#[tokio::test]
#[ignore]
async fn pg_capture_live() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move { let _ = conn.await; });
let schema = "gridline_diff_test";
client
.batch_execute(&format!(
"DROP SCHEMA IF EXISTS {schema} CASCADE; CREATE SCHEMA {schema};"
))
.await
.expect("create temp schema");
// Two diverged tables: one with PK/FK/index/default, one plain.
client
.batch_execute(&format!(
"CREATE TABLE {schema}.parent (id serial PRIMARY KEY, code text UNIQUE);
CREATE TABLE {schema}.child (
id serial PRIMARY KEY,
parent_id integer REFERENCES {schema}.parent(id) ON DELETE CASCADE ON UPDATE RESTRICT,
name text NOT NULL DEFAULT 'x',
email text
);
CREATE INDEX idx_child_email ON {schema}.child(email);
CREATE VIEW {schema}.v_child AS SELECT id, name FROM {schema}.child;
CREATE SEQUENCE {schema}.seq_test START 5 INCREMENT 2;
CREATE TYPE {schema}.mood AS ENUM ('happy', 'sad');"
))
.await
.expect("create objects");
let snap = capture_pg(&client, "postgresql", schema).await.expect("capture");
assert_eq!(snap.engine, "postgresql");
assert_eq!(snap.schema, schema);
assert_eq!(snap.tables.len(), 2);
let child = snap.tables.iter().find(|t| t.name == "child").expect("child table");
assert_eq!(child.primary_key, vec!["id"]);
assert!(child.columns.iter().any(|c| c.name == "name" && !c.nullable && c.default_value.is_some()));
assert!(child.columns.iter().any(|c| c.name == "email" && c.nullable));
assert!(child.indexes.iter().any(|i| i.name == "idx_child_email"));
assert_eq!(child.foreign_keys.len(), 1);
let fk = &child.foreign_keys[0];
assert!(fk.ref_table.contains("parent"));
assert_eq!(fk.on_delete, "CASCADE");
assert_eq!(fk.on_update, "RESTRICT");
let parent = snap.tables.iter().find(|t| t.name == "parent").expect("parent table");
assert!(parent.uniques.iter().any(|u| u.kind == "unique"));
assert!(snap.views.iter().any(|v| v.name == "v_child"));
assert!(snap.sequences.iter().any(|s| s.name == "seq_test" && s.start == "5"));
assert!(snap.enums.iter().any(|e| e.name == "mood" && e.labels == vec!["happy", "sad"]));
client
.batch_execute(&format!("DROP SCHEMA {schema} CASCADE;"))
.await
.expect("drop temp schema");
let _ = handle;
}
// ── Task 14: pure diff engine + sync-SQL generation ─────────────────
fn col(name: &str, ty: &str, nullable: bool, default: Option<&str>) -> SnapshotColumn {
SnapshotColumn {
name: name.into(),
data_type: ty.into(),
nullable,
default_value: default.map(|d| d.into()),
}
}
fn table(name: &str, columns: Vec<SnapshotColumn>) -> SnapshotTable {
SnapshotTable {
schema: "s".into(),
name: name.into(),
columns,
primary_key: vec![],
uniques: vec![],
checks: vec![],
indexes: vec![],
foreign_keys: vec![],
}
}
fn pg_snap(tables: Vec<SnapshotTable>) -> SchemaSnapshot {
let object_count = tables.len();
SchemaSnapshot {
engine: "postgresql".into(),
schema: "s".into(),
tables,
views: vec![],
sequences: vec![],
enums: vec![],
object_count,
truncated: false,
}
}
#[test]
fn added_table_generates_create_with_pk_and_is_stageable() {
let mut src = pg_snap(vec![]);
let mut t = table("users", vec![col("id", "integer", false, None), col("email", "text", true, Some("''"))]);
t.primary_key = vec!["id".into()];
src.tables.push(t);
let tgt = pg_snap(vec![]);
let r = diff_snapshots(&src, &tgt);
let item = r.items.iter().find(|i| i.object_type == "table" && i.name == "users").unwrap();
assert_eq!(item.kind, "added");
assert!(!item.destructive);
assert_eq!(item.sync_sql.len(), 1);
let sql = &item.sync_sql[0];
assert!(sql.starts_with("CREATE TABLE \"s\".\"users\""), "got: {sql}");
assert!(sql.contains("\"id\" integer NOT NULL"));
assert!(sql.contains("\"email\" text DEFAULT ''"));
assert!(sql.contains("PRIMARY KEY (\"id\")"));
}
#[test]
fn removed_table_is_destructive_copy_only() {
let tgt = pg_snap(vec![table("old", vec![col("id", "integer", false, None)])]);
let src = pg_snap(vec![]);
let r = diff_snapshots(&src, &tgt);
let item = r.items.iter().find(|i| i.name == "old").unwrap();
assert_eq!(item.kind, "removed");
assert!(item.destructive);
assert_eq!(item.sync_sql, vec!["DROP TABLE \"s\".\"old\";"]);
}
#[test]
fn column_diffs_classify_exactly_per_spec() {
let mut src = pg_snap(vec![table(
"t",
vec![
col("kept", "int", true, None),
col("added", "int", true, None),
col("retyped", "bigint", true, None),
col("tightened", "int", false, None),
col("defchanged", "int", true, Some("2")),
],
)]);
src.tables[0].name = "t".into();
let tgt = pg_snap(vec![table(
"t",
vec![
col("kept", "int", true, None),
col("gone", "int", true, None),
col("retyped", "int", true, None),
col("tightened", "int", true, None),
col("defchanged", "int", true, Some("1")),
],
)]);
let r = diff_snapshots(&src, &tgt);
let add = r.items.iter().find(|i| i.name == "t.added").unwrap();
assert_eq!((add.kind.as_str(), add.destructive), ("added", false));
assert_eq!(add.sync_sql[0], "ALTER TABLE \"s\".\"t\" ADD COLUMN \"added\" int;");
let gone = r.items.iter().find(|i| i.name == "t.gone").unwrap();
assert_eq!((gone.kind.as_str(), gone.destructive), ("removed", true));
assert_eq!(gone.sync_sql[0], "ALTER TABLE \"s\".\"t\" DROP COLUMN \"gone\";");
let retyped = r.items.iter().find(|i| i.name == "t.retyped").unwrap();
assert!(retyped.destructive, "ALTER COLUMN TYPE is destructive per spec");
assert!(retyped.sync_sql[0].contains("ALTER COLUMN \"retyped\" TYPE bigint"));
let tight = r.items.iter().find(|i| i.name == "t.tightened").unwrap();
assert!(!tight.destructive);
assert!(tight.sync_sql[0].contains("SET NOT NULL"));
let def = r.items.iter().find(|i| i.name == "t.defchanged").unwrap();
assert!(!def.destructive);
assert!(def.sync_sql[0].contains("SET DEFAULT 2"));
}
#[test]
fn constraint_index_fk_view_sequence_enum_diffs() {
let mut src = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
let mut tgt = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
src.tables[0].foreign_keys.push(SnapshotFk {
name: "fk_t_ref".into(),
columns: vec!["id".into()],
ref_schema: "s".into(),
ref_table: "other".into(),
ref_columns: vec!["id".into()],
on_delete: "CASCADE".into(),
on_update: "NO ACTION".into(),
});
src.tables[0].indexes.push(SnapshotIndex { name: "ix_t".into(), columns: vec!["id".into()], unique: false });
src.views.push(SnapshotView { schema: "s".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
src.sequences.push(SnapshotSequence { schema: "s".into(), name: "seq".into(), start: "1".into(), increment: "1".into(), minimum: "1".into(), maximum: "100".into(), cycle: false });
src.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into(), "green".into()] });
tgt.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into()] });
let r = diff_snapshots(&src, &tgt);
let fk = r.items.iter().find(|i| i.object_type == "constraint" && i.name == "t.fk_t_ref").unwrap();
assert!(!fk.destructive);
assert!(fk.sync_sql[0].contains("ADD CONSTRAINT \"fk_t_ref\" FOREIGN KEY (\"id\")"));
assert!(fk.sync_sql[0].contains("REFERENCES \"s\".\"other\" (\"id\")"));
assert!(fk.sync_sql[0].contains("ON DELETE CASCADE"));
let ix = r.items.iter().find(|i| i.object_type == "index" && i.name == "t.ix_t").unwrap();
assert_eq!(ix.sync_sql[0], "CREATE INDEX \"s\".\"ix_t\" ON \"s\".\"t\" (\"id\");");
let view = r.items.iter().find(|i| i.object_type == "view" && i.name == "v").unwrap();
assert!(view.sync_sql[0].starts_with("CREATE VIEW \"s\".\"v\" AS"));
let seq = r.items.iter().find(|i| i.object_type == "sequence" && i.name == "seq").unwrap();
assert!(seq.sync_sql[0].starts_with("CREATE SEQUENCE \"s\".\"seq\""));
let en = r.items.iter().find(|i| i.object_type == "enum" && i.name == "color").unwrap();
assert!(!en.destructive);
assert!(en.sync_sql[0].contains("ALTER TYPE \"s\".\"color\" ADD VALUE 'green'"));
}
#[test]
fn mysql_family_uses_backticks() {
let mut src = SchemaSnapshot { engine: "mariadb".into(), schema: "app".into(), ..Default::default() };
src.tables.push(SnapshotTable {
schema: "app".into(),
name: "users".into(),
columns: vec![SnapshotColumn { name: "id".into(), data_type: "int".into(), nullable: false, default_value: None }],
primary_key: vec!["id".into()],
uniques: vec![],
checks: vec![],
indexes: vec![],
foreign_keys: vec![],
});
let tgt = SchemaSnapshot { engine: "mysql".into(), schema: "app".into(), ..Default::default() };
let r = diff_snapshots(&src, &tgt);
let item = r.items.iter().find(|i| i.name == "users").unwrap();
assert!(item.sync_sql[0].starts_with("CREATE TABLE `app`.`users`"), "got: {}", item.sync_sql[0]);
}
#[test]
fn sqlite_changed_view_is_destructive_note_only() {
let mut src = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
src.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 2".into(), materialized: false });
let mut tgt = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
tgt.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
let r = diff_snapshots(&src, &tgt);
let item = r.items.iter().find(|i| i.object_type == "view").unwrap();
assert_eq!(item.kind, "changed");
assert!(item.destructive);
assert!(item.sync_sql.is_empty(), "SQLite cannot CREATE OR REPLACE a view");
}
#[test]
fn identical_snapshots_diff_empty_and_stageable_filter() {
let snap = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
let r = diff_snapshots(&snap, &snap);
assert!(r.items.is_empty());
assert!(stageable_items(&r).is_empty());
let mut src2 = pg_snap(vec![]);
src2.tables.push(table("new", vec![col("id", "int", false, None)]));
src2.tables.push(table("dropme", vec![col("id", "int", false, None)]));
let r2 = diff_snapshots(&src2, &snap);
assert!(stageable_items(&r2).iter().all(|i| !i.destructive && !i.sync_sql.is_empty()));
}
+284
View File
@@ -0,0 +1,284 @@
//! Pure SQL statement splitter shared by multi-result execution and the
//! streaming export (single-statement validation).
//!
//! A hand-rolled state machine (no new dependencies). It understands:
//! single/double/backtick-quoted strings with doubled-quote escapes,
//! `--` line comments, `/* */` block comments, and PostgreSQL dollar-quoted
//! bodies (`$$…$$`, `$tag$…$tag$`). Conservative by design: anything it
//! cannot confidently close is kept inside the current statement rather
//! than split — a naive semicolon split that could execute fragmentary SQL
//! is unacceptable (spec §2).
//!
//! Statement classification (SELECT vs DML vs DDL) happens at execution
//! time, not here.
/// Split `sql` into ordered statement texts. Comment-only and empty
/// fragments are dropped. Statement text keeps its comments verbatim.
pub fn split_statements(sql: &str) -> Vec<String> {
let chars: Vec<char> = sql.chars().collect();
let mut stmts: Vec<String> = Vec::new();
let mut current = String::new();
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() {
// 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 at_delimiter = starts_with(&chars, i, &delimiter);
match c {
'\'' | '"' | '`' => {
copy_quoted(&chars, &mut i, &mut current, c);
}
'-' if peek(&chars, i + 1) == Some('-') => {
copy_until(&chars, &mut i, &mut current, |ch| ch == '\n');
}
'/' if peek(&chars, i + 1) == Some('*') => {
copy_block_comment(&chars, &mut i, &mut current);
}
_ if at_delimiter => {
if has_executable_text(&current) {
stmts.push(current.trim().to_string());
}
current.clear();
i += delimiter.len();
}
'$' => {
if let Some(end) = dollar_quote_end(&chars, i) {
for _ in i..end {
if i < chars.len() {
current.push(chars[i]);
i += 1;
}
}
} else {
current.push(c);
i += 1;
}
}
_ => {
current.push(c);
i += 1;
}
}
}
if has_executable_text(&current) {
stmts.push(current.trim().to_string());
}
stmts
}
fn peek(chars: &[char], idx: usize) -> Option<char> {
chars.get(idx).copied()
}
/// Copy a quoted region starting at the opening `quote` char. Doubled quotes
/// are escapes and stay inside the region. Unterminated quotes consume the
/// rest of the input (conservative).
fn copy_quoted(chars: &[char], i: &mut usize, out: &mut String, quote: char) {
out.push(chars[*i]);
*i += 1;
while *i < chars.len() {
let c = chars[*i];
out.push(c);
*i += 1;
if c == quote {
if peek(chars, *i) == Some(quote) {
// doubled quote — escaped, keep it and continue
out.push(quote);
*i += 1;
} else {
return;
}
}
}
}
/// Copy characters until `stop` matches (inclusive for `\n` handled by the
/// caller's predicate) or input ends.
fn copy_until(chars: &[char], i: &mut usize, out: &mut String, stop: impl Fn(char) -> bool) {
while *i < chars.len() {
let c = chars[*i];
out.push(c);
*i += 1;
if stop(c) {
return;
}
}
}
/// Copy a `/* … */` block comment (no nesting per the SQL standard).
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
out.push(chars[*i]); // '/'
*i += 1;
if *i < chars.len() {
out.push(chars[*i]); // '*'
*i += 1;
}
while *i < chars.len() {
let c = chars[*i];
out.push(c);
*i += 1;
if c == '*' && peek(chars, *i) == Some('/') {
out.push('/');
*i += 1;
return;
}
}
}
/// At `chars[start] == '$'`, try to match `$[A-Za-z_][A-Za-z0-9_]*$` and find
/// the matching closing delimiter. Returns the index one past the closing
/// delimiter, or None if this is not a well-formed (and closable) dollar
/// quote — in which case the `$` is ordinary text.
fn dollar_quote_end(chars: &[char], start: usize) -> Option<usize> {
let mut j = start + 1;
if j < chars.len() && chars[j] == '$' {
// bare $$ body
let tag = String::new();
return find_dollar_close(chars, j + 1, &tag);
}
let mut tag = String::new();
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
tag.push(chars[j]);
j += 1;
}
if tag.is_empty() || j >= chars.len() || chars[j] != '$' {
return None; // not $tag$
}
find_dollar_close(chars, j + 1, &tag)
}
fn find_dollar_close(chars: &[char], from: usize, tag: &str) -> Option<usize> {
let closing: Vec<char> = format!("${tag}$").chars().collect();
let mut j = from;
while j + closing.len() <= chars.len() {
if chars[j..j + closing.len()] == closing[..] {
return Some(j + closing.len());
}
j += 1;
}
None
}
/// True if the fragment contains anything other than whitespace and comments
/// (comments are preserved in statement text, but a fragment that is ONLY
/// comments/whitespace is not a statement).
pub fn has_executable_text(stmt: &str) -> bool {
let chars: Vec<char> = stmt.chars().collect();
let mut i = 0usize;
while i < chars.len() {
let c = chars[i];
match c {
'\'' | '"' | '`' => {
let mut probe = String::new();
let mut j = i;
copy_quoted(&chars, &mut j, &mut probe, c);
return true; // a quoted region IS executable text
}
'-' if peek(&chars, i + 1) == Some('-') => {
skip_until(&chars, &mut i, |ch| ch == '\n');
}
'/' 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);
}
c if c.is_whitespace() => i += 1,
_ => return true,
}
}
false
}
fn skip_until(chars: &[char], i: &mut usize, stop: impl Fn(char) -> bool) {
while *i < chars.len() {
let c = chars[*i];
*i += 1;
if stop(c) {
return;
}
}
}
fn skip_block_comment(chars: &[char], i: &mut usize) {
*i += 2; // "/*"
while *i < chars.len() {
if chars[*i] == '*' && peek(chars, *i + 1) == Some('/') {
*i += 2;
return;
}
*i += 1;
}
}
/// 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)]
#[path = "sql_split.test.rs"]
mod sql_split_tests;
+141
View File
@@ -0,0 +1,141 @@
use super::*;
#[test]
fn splits_two_statements() {
assert_eq!(
split_statements("SELECT 1; SELECT 2"),
vec!["SELECT 1", "SELECT 2"]
);
}
#[test]
fn no_trailing_semicolon_is_fine() {
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
}
#[test]
fn semicolon_inside_string_is_not_a_boundary() {
assert_eq!(split_statements("SELECT 'a;b'"), vec!["SELECT 'a;b'"]);
assert_eq!(
split_statements("SELECT 'a;b'; SELECT 2"),
vec!["SELECT 'a;b'", "SELECT 2"]
);
}
#[test]
fn doubled_quote_escapes() {
assert_eq!(
split_statements("SELECT 'it''s'; SELECT 2"),
vec!["SELECT 'it''s'", "SELECT 2"]
);
}
#[test]
fn double_quoted_and_backtick_identifiers() {
assert_eq!(
split_statements("SELECT \"a;b\"; SELECT `x;y`"),
vec!["SELECT \"a;b\"", "SELECT `x;y`"]
);
assert_eq!(
split_statements("SELECT `a``b`; SELECT 2"),
vec!["SELECT `a``b`", "SELECT 2"]
);
}
#[test]
fn line_comments_do_not_break_statements() {
assert_eq!(
split_statements("SELECT 1 -- ; not a boundary\n; SELECT 2"),
vec!["SELECT 1 -- ; not a boundary", "SELECT 2"]
);
}
#[test]
fn block_comments_do_not_break_statements() {
assert_eq!(split_statements("SELECT /* ; */ 1"), vec!["SELECT /* ; */ 1"]);
}
#[test]
fn dollar_quoted_bodies() {
assert_eq!(
split_statements("SELECT $$a;b$$; SELECT 2"),
vec!["SELECT $$a;b$$", "SELECT 2"]
);
assert_eq!(
split_statements("SELECT $fn$x;y$fn$; SELECT 2"),
vec!["SELECT $fn$x;y$fn$", "SELECT 2"]
);
}
#[test]
fn unclosed_dollar_is_literal() {
assert_eq!(split_statements("SELECT a$b"), vec!["SELECT a$b"]);
assert_eq!(split_statements("SELECT 1$"), vec!["SELECT 1$"]);
}
#[test]
fn comment_only_statements_are_skipped() {
assert_eq!(split_statements("-- nothing\n; SELECT 1"), vec!["SELECT 1"]);
assert_eq!(split_statements("/* c */"), Vec::<String>::new());
}
#[test]
fn empty_and_whitespace_input() {
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");
}
+75
View File
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
} }
} }
/// Host suffixes of managed providers that reject plaintext connections with
/// `connection is insecure (try using 'sslmode=require')`.
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
/// Resolve the SSL mode to use for a connection, defaulting these managed
/// providers to `require` when the connection carries no explicit mode.
///
/// Connections saved before the UI persisted `ssl_mode` (or imported without
/// it) would otherwise connect with TLS disabled and be refused by the server.
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
/// and the inferred mode only encrypts — it never verifies certificates.
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
if let Some(mode) = ssl_mode {
return Some(mode.to_string());
}
let host = host.to_ascii_lowercase();
if MANAGED_SSL_HOST_SUFFIXES
.iter()
.any(|suffix| host.ends_with(suffix))
{
return Some("require".to_string());
}
None
}
/// TLS decision for a connection, applying the managed-provider default from
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
}
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable. /// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth. /// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
pub fn build_tls_config( pub fn build_tls_config(
@@ -197,6 +228,50 @@ mod tests {
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable)); assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
} }
#[test]
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
// Saved connections that predate the form storing `ssl_mode` still work.
for host in [
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
"db.abcdefghijklmnopqrst.supabase.co",
"xxxx.us-east-2.psdb.cloud",
"EP-X-POOLER.EU.AWS.NEON.TECH",
] {
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
}
}
#[test]
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
assert_eq!(resolve_ssl_mode("localhost", None), None);
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
// An explicit mode always wins — a deliberate `disable` is never overridden.
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
Some("disable")
);
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
Some("verify-full")
);
}
#[test]
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", None),
TlsDecision::Require
));
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
TlsDecision::Disable
));
assert!(matches!(
tls_decision_for("db.example.com", None),
TlsDecision::Disable
));
}
#[test] #[test]
fn build_tls_disable_returns_none() { fn build_tls_disable_returns_none() {
assert!(build_tls_config(TlsDecision::Disable, None, None, None) assert!(build_tls_config(TlsDecision::Disable, None, None, None)
+415
View File
@@ -0,0 +1,415 @@
//! Unified, headless external-client-tool resolver shared by the PostgreSQL
//! and MySQL backup/restore/sync paths.
//!
//! Fixes issue #44: bundled tools live under `resource_dir/resources/<subdir>/`
//! (Tauri preserves the `resources/` component from `tauri.conf.json`), not
//! directly under `resource_dir/`. System lookup is ordered — trusted absolute
//! directories first, then the inherited PATH, then bundled — and probes both
//! `mariadb-dump`/`mysqldump` style names.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// The nested directory component Tauri injects when copying
/// `bundle.resources`. Pinned by `bundled_prefix_matches_tauri_config_resources`.
pub const BUNDLED_RESOURCE_PREFIX: &str = "resources";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
/// Found in a fixed, trusted absolute directory.
TrustedDir,
/// Found on the inherited `PATH`.
Path,
/// Found in the app's bundled resource directory.
Bundled,
}
impl ToolSource {
/// The value surfaced to the UI (matches the pre-existing
/// `"system" | "bundled"` strings).
pub fn as_str(self) -> &'static str {
match self {
ToolSource::TrustedDir | ToolSource::Path => "system",
ToolSource::Bundled => "bundled",
}
}
}
/// A successfully resolved tool: the runnable program plus its provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub program: String,
pub source: ToolSource,
/// The binary name that resolved (`mariadb-dump`, `mysqldump`, …).
pub name: String,
}
/// One engine + tool role, e.g. the MySQL dump tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolRequest {
/// Resource subdirectory name, e.g. `mysql_tools` / `pg_tools`.
pub subdir: &'static str,
/// Candidate binary names, in preference order.
pub names: Vec<&'static str>,
}
impl ToolRequest {
pub fn new(subdir: &'static str, names: &[&'static str]) -> Self {
Self { subdir, names: names.to_vec() }
}
/// The bundled binary that ships for this role.
pub fn bundled_name(&self) -> &'static str {
self.names[0]
}
/// Stable cache key for this role.
pub fn cache_key(&self) -> String {
format!("{}::{}", self.subdir, self.names.join(","))
}
}
/// Platform executable suffix.
pub fn exe_suffix() -> &'static str {
if cfg!(windows) { ".exe" } else { "" }
}
/// `pg_dump` -> `pg_dump` (POSIX) / `pg_dump.exe` (Windows).
pub fn bundled_bin_name(bin: &str) -> String {
format!("{bin}{}", exe_suffix())
}
/// Fixed, trusted absolute directories probed before the inherited `PATH`.
/// Windows deliberately returns an empty list (spec §9 Q5): PATH + bundled only.
pub fn trusted_dirs() -> Vec<PathBuf> {
let raw: &[&str] = if cfg!(target_os = "macos") {
&[
"/opt/homebrew/bin",
"/opt/homebrew/opt/mysql-client/bin",
"/opt/homebrew/opt/mariadb/bin",
"/opt/homebrew/opt/libpq/bin",
"/usr/local/bin",
"/usr/local/opt/mysql-client/bin",
"/usr/local/opt/mariadb/bin",
"/usr/local/opt/libpq/bin",
"/opt/local/bin",
"/Applications/Postgres.app/Contents/Versions/latest/bin",
]
} else if cfg!(target_os = "linux") {
&["/usr/bin", "/usr/local/bin", "/usr/local/mysql/bin", "/opt/mysql/bin"]
} else {
&[]
};
raw.iter().map(PathBuf::from).collect()
}
pub fn pg_dump_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_dump"]) }
pub fn pg_restore_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_restore"]) }
pub fn psql_request() -> ToolRequest { ToolRequest::new("pg_tools", &["psql"]) }
/// Maps a PostgreSQL tool name to its request; `None` for unknown names.
pub fn pg_request(tool: &str) -> Option<ToolRequest> {
match tool {
"pg_dump" => Some(pg_dump_request()),
"pg_restore" => Some(pg_restore_request()),
"psql" => Some(psql_request()),
_ => None,
}
}
/// MySQL dump role: system `mariadb-dump` -> system `mysqldump` -> bundled.
pub fn mysql_dump_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb-dump", "mysqldump"])
}
/// MySQL client role (used by sync as the restore target).
pub fn mysql_client_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb", "mysql"])
}
/// Maps a MySQL tool name to its request; `None` for unknown names.
pub fn mysql_request(tool: &str) -> Option<ToolRequest> {
match tool {
"mariadb-dump" | "mysqldump" => Some(mysql_dump_request()),
"mariadb" | "mysql" => Some(mysql_client_request()),
_ => None,
}
}
/// The bundled binary path for a role. Note the nested
/// `BUNDLED_RESOURCE_PREFIX` component — this is the issue #44 fix.
pub fn bundled_tool_path(resource_dir: &Path, subdir: &str, bin: &str) -> PathBuf {
resource_dir
.join(BUNDLED_RESOURCE_PREFIX)
.join(subdir)
.join(bundled_bin_name(bin))
}
/// Locate `name` on a `PATH`-shaped value without invoking a shell.
pub fn path_lookup_in(name: &str, path_var: &OsStr) -> Option<PathBuf> {
let file = bundled_bin_name(name);
for dir in std::env::split_paths(path_var) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
/// Locate `name` on the inherited `PATH`.
pub fn path_lookup(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
path_lookup_in(name, &path_var)
}
/// Per-probe timeout. `--version` returns in milliseconds; this only guards a
/// pathological binary.
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
/// Aggregate deadline for one detection pass across all candidates.
pub const AGGREGATE_DEADLINE: Duration = Duration::from_secs(3);
/// Run `<program> --version` with a bounded wait. The child's stdio is
/// discarded — output is read separately by `version_of`.
pub fn run_version(program: &Path, timeout: Duration) -> bool {
let mut cmd = Command::new(program);
cmd.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else {
return false;
};
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => {
let _ = child.kill();
return false;
}
}
}
}
/// A tool candidate counts only when it is a regular file AND runs.
pub fn probe(program: &Path) -> bool {
program.is_file() && run_version(program, PROBE_TIMEOUT)
}
/// A probe that stops running anything once the aggregate deadline is crossed.
pub fn probe_within(start: Instant, deadline: Duration, program: &Path) -> bool {
if start.elapsed() >= deadline {
return false;
}
probe(program)
}
/// Ordered, injectable resolution.
///
/// 1. every trusted absolute directory, for each candidate name in order;
/// 2. the inherited `PATH`, for each candidate name in order;
/// 3. the bundled binary.
///
/// `probe_fn` decides whether a candidate is runnable, so tests never touch
/// the real filesystem. The aggregate deadline is enforced by the caller's
/// `probe_fn` (see `probe_within`).
pub fn resolve_with<F>(
request: &ToolRequest,
trusted: &[PathBuf],
path_lookup: &dyn Fn(&str) -> Option<PathBuf>,
bundled: Option<&Path>,
mut probe_fn: F,
) -> Option<Resolution>
where
F: FnMut(&Path) -> bool,
{
for dir in trusted {
for name in &request.names {
let candidate = dir.join(bundled_bin_name(name));
if probe_fn(&candidate) {
return Some(Resolution {
program: candidate.to_string_lossy().to_string(),
source: ToolSource::TrustedDir,
name: (*name).to_string(),
});
}
}
}
for name in &request.names {
if let Some(found) = path_lookup(name) {
if probe_fn(&found) {
return Some(Resolution {
program: found.to_string_lossy().to_string(),
source: ToolSource::Path,
name: (*name).to_string(),
});
}
}
}
if let Some(bundled) = bundled {
if probe_fn(bundled) {
let name = bundled
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| request.bundled_name().to_string());
return Some(Resolution {
program: bundled.to_string_lossy().to_string(),
source: ToolSource::Bundled,
name,
});
}
}
None
}
/// Read the first non-empty line of `<program> --version` (stdout, else stderr).
pub fn version_of(program: &str) -> Option<String> {
let out = Command::new(program).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
return Some(stdout);
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if stderr.is_empty() { None } else { Some(stderr) }
}
/// Lenient major-version extraction. Scans for numeric groups, unwraps the
/// legacy MariaDB `5.5.5-<real>-MariaDB` compatibility prefix, and returns
/// `None` when there is no number at all.
pub fn parse_major(version: &str) -> Option<u32> {
let mut nums: Vec<u32> = Vec::new();
let mut cur = String::new();
for ch in version.chars() {
if ch.is_ascii_digit() {
cur.push(ch);
continue;
}
if !cur.is_empty() {
nums.push(cur.parse::<u32>().ok()?);
cur.clear();
// `6` is the only magic number here: it caps how many groups an
// adversarial/advertising version string can make us collect
// (we only ever read the first four).
if nums.len() == 6 {
break;
}
}
}
if !cur.is_empty() {
if let Ok(n) = cur.parse::<u32>() {
nums.push(n);
}
}
if nums.is_empty() {
return None;
}
if nums.len() >= 4 && nums[0] == 5 && nums[1] == 5 && nums[2] == 5 {
return Some(nums[3]);
}
Some(nums[0])
}
/// Client/server version relationship. Advisory only — never an error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compat {
Ok,
/// The client's major version is below the server's.
ClientOlder,
/// Either version could not be parsed.
Unknown,
}
pub fn compat(client: Option<u32>, server: Option<u32>) -> Compat {
match (client, server) {
(Some(c), Some(s)) if s > c => Compat::ClientOlder,
(Some(_), Some(_)) => Compat::Ok,
_ => Compat::Unknown,
}
}
/// Non-blocking warning text, or `None` when there is nothing to say.
pub fn warning_for(client_version: &str, server_version: &str, client_name: &str) -> Option<String> {
match compat(parse_major(client_version), parse_major(server_version)) {
Compat::ClientOlder => Some(format!(
"Client/server version mismatch: the resolved dump tool `{client_name}` reports {client_version}, \
but the server is {server_version}. The backup may fail or omit data — installing a matching MySQL client is recommended."
)),
Compat::Ok | Compat::Unknown => None,
}
}
/// Everything one detection pass knows about a tool role.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolResolution {
pub resolved: Option<Resolution>,
pub bundled_path: Option<PathBuf>,
/// Whether the bundled file exists — regardless of whether it runs. Lets
/// the UI say "ships with the app but could not run" instead of "absent".
pub bundled_available: bool,
pub version: Option<String>,
}
impl ToolResolution {
pub fn none() -> Self {
Self { resolved: None, bundled_path: None, bundled_available: false, version: None }
}
}
/// Whether a bundled tool file exists at `path`.
pub fn bundled_present(path: &Path) -> bool {
path.is_file()
}
/// Shape the existing resolver call sites expect: `(command, source)`.
pub fn adapt_resolution(res: &ToolResolution, fallback_name: &str) -> (String, Option<String>) {
match &res.resolved {
Some(r) => (r.program.clone(), Some(r.source.as_str().to_string())),
None => (fallback_name.to_string(), None),
}
}
// Cache is keyed by `ToolRequest::cache_key()` and holds no secrets.
static CACHE: OnceLock<Mutex<HashMap<String, ToolResolution>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, ToolResolution>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn cache_get(key: &str) -> Option<ToolResolution> {
cache().lock().ok()?.get(key).cloned()
}
pub fn cache_put(key: &str, value: ToolResolution) {
if let Ok(mut map) = cache().lock() {
map.insert(key.to_string(), value);
}
}
pub fn cache_clear() {
if let Ok(mut map) = cache().lock() {
map.clear();
}
}
#[cfg(test)]
#[path = "tool_resolver.test.rs"]
mod tests;
@@ -0,0 +1,403 @@
use super::*;
#[test]
fn bundled_bin_name_appends_exe_only_on_windows() {
let expected = if cfg!(windows) { "pg_dump.exe" } else { "pg_dump" };
assert_eq!(bundled_bin_name("pg_dump"), expected);
}
#[test]
fn pg_request_maps_known_tool_names() {
assert_eq!(pg_request("pg_dump").unwrap(), ToolRequest::new("pg_tools", &["pg_dump"]));
assert_eq!(pg_request("pg_restore").unwrap(), ToolRequest::new("pg_tools", &["pg_restore"]));
assert_eq!(pg_request("psql").unwrap(), ToolRequest::new("pg_tools", &["psql"]));
assert!(pg_request("not_a_tool").is_none());
}
#[test]
fn mysql_dump_request_prefers_mariadb_dump_then_mysqldump() {
let req = mysql_dump_request();
assert_eq!(req.subdir, "mysql_tools");
assert_eq!(req.names, vec!["mariadb-dump", "mysqldump"]);
assert_eq!(req.bundled_name(), "mariadb-dump");
}
#[test]
fn mysql_client_request_prefers_mariadb_then_mysql() {
let req = mysql_client_request();
assert_eq!(req.names, vec!["mariadb", "mysql"]);
assert_eq!(req.bundled_name(), "mariadb");
}
#[test]
fn cache_key_is_stable_and_name_ordered() {
assert_eq!(mysql_dump_request().cache_key(), "mysql_tools::mariadb-dump,mysqldump");
assert_eq!(pg_dump_request().cache_key(), "pg_tools::pg_dump");
}
#[test]
fn tool_source_maps_to_ui_string() {
assert_eq!(ToolSource::TrustedDir.as_str(), "system");
assert_eq!(ToolSource::Path.as_str(), "system");
assert_eq!(ToolSource::Bundled.as_str(), "bundled");
}
#[cfg(target_os = "macos")]
#[test]
fn trusted_dirs_include_homebrew_and_postgres_app_on_macos() {
let dirs = trusted_dirs();
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/opt/mysql-client/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/usr/local/opt/libpq/bin")), "{dirs:?}");
assert!(dirs.iter().any(|d| d.ends_with("Postgres.app/Contents/Versions/latest/bin")), "{dirs:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn trusted_dirs_include_usr_bin_on_linux() {
assert!(trusted_dirs().contains(&PathBuf::from("/usr/bin")));
}
#[cfg(windows)]
#[test]
fn trusted_dirs_are_empty_on_windows() {
assert!(trusted_dirs().is_empty());
}
#[test]
fn bundled_tool_path_includes_the_nested_resources_prefix() {
// THE BUG FROM ISSUE #44: the shipped app stores the tool at
// <resource_dir>/resources/mysql_tools/mariadb-dump, never at
// <resource_dir>/mysql_tools/mariadb-dump.
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "mysql_tools", "mariadb-dump");
assert_eq!(
p,
PathBuf::from("/app/Contents/Resources/resources/mysql_tools").join(bundled_bin_name("mariadb-dump"))
);
assert!(p.to_string_lossy().ends_with("resources/mysql_tools/mariadb-dump"));
}
#[test]
fn bundled_tool_path_works_for_postgres_tools_too() {
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "pg_tools", "pg_dump");
assert!(p.to_string_lossy().ends_with("resources/pg_tools/pg_dump"));
}
#[test]
fn bundled_prefix_matches_tauri_config_resources() {
let conf = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json"))
.expect("tauri.conf.json must be readable");
let json: serde_json::Value = serde_json::from_str(&conf).unwrap();
let resources = json["bundle"]["resources"]
.as_array()
.expect("bundle.resources must be an array");
assert!(!resources.is_empty(), "bundle.resources must not be empty");
for entry in resources {
let glob = entry.as_str().unwrap();
let top = Path::new(glob).components().next().unwrap();
assert_eq!(
top.as_os_str().to_str().unwrap(),
BUNDLED_RESOURCE_PREFIX,
"resource glob `{glob}` must start with `{BUNDLED_RESOURCE_PREFIX}/`"
);
}
}
#[test]
fn path_lookup_finds_an_executable_by_name() {
let dir = unique_dir();
let exe = dir.join(bundled_bin_name("mysqldump"));
std::fs::write(&exe, b"").unwrap();
let path_var = std::env::join_paths([&dir]).unwrap();
assert_eq!(path_lookup_in("mysqldump", &path_var), Some(exe));
}
#[test]
fn path_lookup_skips_empty_entries_and_missing_names() {
let dir = unique_dir();
let path_var = std::env::join_paths([PathBuf::from(""), dir.clone()]).unwrap();
assert_eq!(path_lookup_in("definitely-absent-tool", &path_var), None);
}
#[test]
fn probe_rejects_a_missing_file_and_a_directory() {
let dir = unique_dir();
assert!(!probe(&dir.join("absent-tool")));
assert!(!probe(&dir), "a directory must never be reported as a runnable tool");
}
#[cfg(unix)]
#[test]
fn run_version_is_true_for_zero_exit_and_false_for_nonzero() {
let ok = unique_dir().join("ok_tool");
write_exe(&ok, "#!/bin/sh\nexit 0\n");
assert!(run_version(&ok, Duration::from_secs(1)));
let bad = unique_dir().join("bad_tool");
write_exe(&bad, "#!/bin/sh\nexit 3\n");
assert!(!run_version(&bad, Duration::from_secs(1)));
}
#[cfg(unix)]
#[test]
fn run_version_kills_a_hung_process_at_the_timeout() {
let slow = unique_dir().join("slow_tool");
write_exe(&slow, "#!/bin/sh\nsleep 5\n");
assert!(!run_version(&slow, Duration::from_millis(50)));
}
#[cfg(unix)]
#[test]
fn probe_accepts_a_runnable_script() {
let tool = unique_dir().join(bundled_bin_name("mariadb-dump"));
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\nexit 0\n");
assert!(probe(&tool));
}
#[cfg(unix)]
#[test]
fn version_of_reads_the_version_from_stdout() {
let tool = unique_dir().join("versioned_tool");
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\n");
let v = version_of(tool.to_str().unwrap()).unwrap();
assert!(v.contains("11.4.5-MariaDB"), "got {v}");
}
#[cfg(unix)]
#[test]
fn probe_within_returns_false_once_the_aggregate_deadline_passed() {
let tool = unique_dir().join("ok_tool");
write_exe(&tool, "#!/bin/sh\nexit 0\n");
let past = Instant::now() - Duration::from_secs(10);
assert!(!probe_within(past, Duration::from_secs(3), &tool));
assert!(probe_within(Instant::now(), Duration::from_secs(3), &tool));
}
#[test]
fn resolve_prefers_a_trusted_dir_and_the_first_candidate_name() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, |_p: &Path| true).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, format!("/trusted/{}", bundled_bin_name("mariadb-dump")));
}
#[test]
fn resolve_falls_through_to_the_inherited_path() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |name: &str| -> Option<PathBuf> {
if name == "mysqldump" { Some(PathBuf::from("/usr/bin/mysqldump")) } else { None }
};
let probe = |p: &Path| p == Path::new("/usr/bin/mysqldump");
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.source, ToolSource::Path);
assert_eq!(r.name, "mysqldump");
assert_eq!(r.program, "/usr/bin/mysqldump");
}
#[test]
fn resolve_prefers_any_system_tool_over_the_bundled_one() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let probe = |p: &Path| p != bundled; // only the trusted candidate runs
let r = resolve_with(&req, &trusted, &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_uses_the_bundled_binary_when_no_system_tool_runs() {
let req = mysql_dump_request();
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let probe = |p: &Path| p == bundled;
let r = resolve_with(&req, &[], &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::Bundled);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, "/rd/resources/mysql_tools/mariadb-dump");
}
#[test]
fn resolve_skips_a_candidate_that_fails_the_probe() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let target = PathBuf::from("/trusted").join(bundled_bin_name("mysqldump"));
let probe = move |p: &Path| p == target.as_path();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.name, "mysqldump");
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_returns_none_when_nothing_runs() {
let req = mysql_dump_request();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &[PathBuf::from("/trusted")], &path_lookup, Some(Path::new("/rd/x")), |_p: &Path| false);
assert!(r.is_none());
}
#[cfg(unix)]
fn write_exe(path: &Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, body).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn unique_dir() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir()
.join(format!("gridline_tool_resolver_{}_{}", std::process::id(), nanos));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_major_handles_common_version_strings() {
assert_eq!(parse_major("8.0.36"), Some(8));
assert_eq!(parse_major("8.4.0"), Some(8));
assert_eq!(parse_major("11.4.5-MariaDB"), Some(11));
assert_eq!(parse_major("5.7.44-log"), Some(5));
assert_eq!(parse_major("26.7.0"), Some(26));
assert_eq!(parse_major("mariadb-dump from 11.4.5-MariaDB, client 10.19"), Some(11));
}
#[test]
fn parse_major_unwraps_the_legacy_mariadb_compat_prefix() {
// MariaDB historically reported 5.5.5-<real version>-MariaDB for compat
// with MySQL-era clients; the real major is the 4th group.
assert_eq!(parse_major("5.5.5-10.4.11-MariaDB"), Some(10));
assert_eq!(parse_major("5.5.5-10.11.6-MariaDB-log"), Some(10));
// A genuine 5.5.5 has only three groups and must stay 5.
assert_eq!(parse_major("5.5.5"), Some(5));
}
#[test]
fn parse_major_returns_none_for_garbage() {
assert_eq!(parse_major("no digits here"), None);
assert_eq!(parse_major(""), None);
}
#[test]
fn compat_flags_only_a_clearly_older_client() {
assert_eq!(compat(Some(8), Some(26)), Compat::ClientOlder);
assert_eq!(compat(Some(11), Some(8)), Compat::Ok);
assert_eq!(compat(Some(8), Some(8)), Compat::Ok);
assert_eq!(compat(None, Some(8)), Compat::Unknown);
assert_eq!(compat(Some(8), None), Compat::Unknown);
}
#[test]
fn warning_for_is_silent_unless_the_client_is_older() {
assert!(warning_for("mariadb-dump 11.4.5-MariaDB", "8.0.36", "mariadb-dump").is_none());
assert!(warning_for("unknown", "8.0.36", "mariadb-dump").is_none());
let w = warning_for("mariadb-dump 11.4.5-MariaDB", "26.7.0", "mariadb-dump").unwrap();
assert!(w.contains("11.4.5-MariaDB"), "{w}");
assert!(w.contains("26.7.0"), "{w}");
assert!(w.contains("mariadb-dump"), "{w}");
// The `\` continuation in the format literal must not leave stray leading
// indentation, so assert the exact single-line render (no newline, no run
// of consecutive spaces).
assert!(!w.contains('\n'), "warning must be one line: {w:?}");
assert!(!w.contains(" "), "warning must not contain double spaces: {w:?}");
assert_eq!(
w,
concat!(
"Client/server version mismatch: the resolved dump tool `mariadb-dump` reports ",
"mariadb-dump 11.4.5-MariaDB, but the server is 26.7.0. ",
"The backup may fail or omit data — installing a matching MySQL client is recommended."
)
);
}
#[test]
fn parse_major_never_panics_on_hostile_input() {
// A digit group that overflows u32 yields None, never a panic.
assert_eq!(parse_major("99999999999999999999.1.1"), None);
assert_eq!(parse_major(&"9".repeat(4096)), None);
// Non-ASCII digits, unicode and control characters are simply skipped.
assert_eq!(parse_major("\u{fc}nicode ٨.٠.٣٦"), None);
assert_eq!(parse_major("\u{1F600}"), None);
assert_eq!(parse_major("\u{0}\u{1}\u{2}"), None);
assert_eq!(parse_major("....."), None);
// Long adversarial input: the group collector is capped at six, and only
// the first group decides the major.
assert_eq!(parse_major(&"1.2.3.4.".repeat(1000)), Some(1));
// An overflowing *trailing* group is skipped by the final flush, so a
// genuine-looking `5.5.5` prefix still yields 5 rather than panicking.
// Harmless: no real tool prints such a version, and the result is advisory.
assert_eq!(parse_major("5.5.5-99999999999999999999"), Some(5));
}
#[test]
fn adapt_resolution_returns_program_and_source() {
let res = ToolResolution {
resolved: Some(Resolution {
program: "/x/mariadb-dump".into(),
source: ToolSource::Bundled,
name: "mariadb-dump".into(),
}),
bundled_path: Some(PathBuf::from("/x/mariadb-dump")),
bundled_available: true,
version: Some("11.4.5".into()),
};
assert_eq!(
adapt_resolution(&res, "mariadb-dump"),
("/x/mariadb-dump".to_string(), Some("bundled".to_string()))
);
}
#[test]
fn adapt_resolution_falls_back_to_the_requested_name() {
assert_eq!(
adapt_resolution(&ToolResolution::none(), "pg_dump"),
("pg_dump".to_string(), None)
);
}
#[test]
fn cache_round_trips_and_clears() {
cache_clear();
let res = ToolResolution {
resolved: Some(Resolution {
program: "/c/tool".into(),
source: ToolSource::Path,
name: "tool".into(),
}),
bundled_path: None,
bundled_available: false,
version: None,
};
assert!(cache_get("k_task6_unique").is_none());
cache_put("k_task6_unique", res.clone());
assert_eq!(cache_get("k_task6_unique"), Some(res));
cache_clear();
assert!(cache_get("k_task6_unique").is_none());
}
#[test]
fn bundled_present_is_true_only_for_a_regular_file() {
let dir = unique_dir();
assert!(!bundled_present(&dir), "a directory is not a present bundled tool");
let file = dir.join("mariadb-dump");
std::fs::write(&file, b"x").unwrap();
assert!(bundled_present(&file));
}
#[test]
fn mysql_request_maps_known_tool_names() {
assert_eq!(mysql_request("mariadb-dump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mysqldump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mariadb").unwrap(), mysql_client_request());
assert_eq!(mysql_request("mysql").unwrap(), mysql_client_request());
assert!(mysql_request("mysqladmin").is_none());
}
+396
View File
@@ -0,0 +1,396 @@
//! Keychain ACL normalization and repair for stored secrets (macOS).
//!
//! # Why this exists
//!
//! Generic-password items stored via the legacy keychain API are guarded by an
//! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
//! trusted application as **the path of the executable** — not a code
//! requirement — so the item stays readable across rebuilds *as long as the
//! binary keeps living at that path*. A properly signed bundle is recorded as a
//! code requirement plus a `cdhash` partition list, which is stable across
//! versions and even moves.
//!
//! Two failure modes follow from that:
//!
//! 1. **The path changes** (monorepo move `src-tauri/` → `desktop/src-tauri/`,
//! app reinstalled elsewhere, release vs. dev binary). The stored paths no
//! longer match the running binary, so macOS treats it as a *foreign* app and
//! asks for the keychain password on **every** access — per secret, and with
//! no "Always Allow", because the item's ACL lists only other apps.
//! 2. **A differently-signed build wrote the item** and the pin never ran again.
//!
//! Pinning only after writes (`after_store`) can't repair either case: writing
//! to an item we can't access already needs the same authorization, so the ACL
//! is never corrected. This module therefore also *repairs before reads*.
//!
//! # How the repair works
//!
//! [`ensure_access`] first asks, **without prompting**, whether this process is
//! already represented in the item's ACL (see [`is_item_trusted`]). Only when it
//! is not does it call `SecKeychainItemSetAccess` with an ACL whose trusted list
//! is exactly this app. That call is what macOS gates behind the authorization
//! dialog — so a stale item costs the user **one** authorization, and every
//! later access (including after future rebuilds at the same path) is silent.
//! Items that are already trusted are left alone, so the ACL never accumulates
//! duplicate entries.
//!
//! All operations are best-effort: they log to stderr and never fail the
//! caller's keychain read/write.
#[cfg(target_os = "macos")]
mod macos_ffi {
use core_foundation::base::{CFTypeID, CFTypeRef};
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
/// security-framework crate exposes no high-level API for it.
#[repr(C)]
pub struct OpaqueSecTrustedApplication {
_priv: [u8; 0],
}
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
use core_foundation::{declare_TCFType, impl_TCFType};
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
impl_TCFType!(
SecTrustedApplication,
SecTrustedApplicationRef,
SecTrustedApplicationGetTypeID
);
unsafe impl Send for SecTrustedApplication {}
unsafe impl Sync for SecTrustedApplication {}
extern "C" {
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
// but remain fully functional (the legacy keychain store this app uses
// via tauri-plugin-keyring-store relies on the same API family).
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
pub fn SecTrustedApplicationCreateFromPath(
path: CFTypeRef,
app: *mut SecTrustedApplicationRef,
) -> i32;
pub fn SecTrustedApplicationCopyData(
app: CFTypeRef,
data: *mut core_foundation::data::CFDataRef,
) -> i32;
pub fn SecAccessCreate(
descriptor: CFTypeRef,
trustedlist: core_foundation::array::CFArrayRef,
access: *mut security_framework_sys::base::SecAccessRef,
) -> i32;
pub fn SecKeychainItemCopyAccess(
item: security_framework_sys::base::SecKeychainItemRef,
access: *mut security_framework_sys::base::SecAccessRef,
) -> i32;
pub fn SecAccessCopyACLList(
access: security_framework_sys::base::SecAccessRef,
list: *mut core_foundation::array::CFArrayRef,
) -> i32;
pub fn SecACLCopyContents(
acl: CFTypeRef,
apps: *mut core_foundation::array::CFArrayRef,
desc: *mut core_foundation::string::CFStringRef,
prompt: *mut u32,
) -> i32;
pub fn SecKeychainItemSetAccess(
item: security_framework_sys::base::SecKeychainItemRef,
access: security_framework_sys::base::SecAccessRef,
) -> i32;
}
}
#[cfg(target_os = "macos")]
use core_foundation::base::TCFType;
#[cfg(target_os = "macos")]
use macos_ffi::{
SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
SecTrustedApplicationCreateFromPath,
};
/// Best-effort ACL normalization after a successful keychain write.
pub fn after_store(app: &tauri::AppHandle, account: &str) {
ensure_access(app, account);
}
/// Best-effort ACL repair *before* reading a secret.
///
/// A stale ACL is what makes macOS ask for the keychain password on every
/// access, so this runs before reads: it authorizes/repairs the item once and
/// leaves it silent from then on. Prompt-free (and cheap) when the item is
/// already trusted or does not exist.
pub fn before_read(app: &tauri::AppHandle, account: &str) {
ensure_access(app, account);
}
/// Resolve the keyring service name (the Tauri bundle identifier) and repair the
/// item's ACL when this app is not already trusted. Never fails the caller.
fn ensure_access(app: &tauri::AppHandle, account: &str) {
let service = app.config().identifier.clone();
if let Err(e) = ensure_item_acl(&service, account) {
eprintln!("[keychain_acl] skipped for {account}: {e}");
}
}
/// Make sure this app can read `account` without an authorization prompt.
///
/// Idempotent and cheap when the item is already trusted (the common case):
/// the trust check is a metadata-only, prompt-free lookup. Returns `Ok(())` when
/// the item exists and now trusts this app, or when there is nothing to do.
#[cfg(target_os = "macos")]
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
use security_framework::os::macos::access::SecAccess;
use security_framework_sys::base::SecAccessRef;
// Locate the item WITHOUT reading its secret: a ref-only lookup
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
// prompt, so merely probing an item we can't yet access is safe.
let item = match item_ref(service, account)? {
Some(item) => item,
// No item (yet) — nothing to normalize.
None => return Ok(()),
};
// Already trusted → leave the ACL alone (repairing again would only
// authorize-and-append a duplicate entry).
if is_item_trusted(&item) {
return Ok(());
}
fn cvt(status: i32) -> Result<(), String> {
if status == 0 {
Ok(())
} else {
Err(format!("security framework error {status}"))
}
}
// Build an access object whose trusted list is exactly this app. A NULL
// path resolves the trusted application to the current process's identity
// (its executable path for ad-hoc dev builds, its code requirement for a
// signed bundle).
unsafe {
let mut trusted_app_ref = std::ptr::null_mut();
cvt(SecTrustedApplicationCreateFromPath(
std::ptr::null(),
&mut trusted_app_ref,
))?;
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
let trusted_list = core_foundation::array::CFArray::from_CFTypes(&[trusted_app]);
let descriptor = core_foundation::string::CFString::new("Gridline");
let mut access_ref: SecAccessRef = std::ptr::null_mut();
cvt(SecAccessCreate(
descriptor.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
trusted_list.as_concrete_TypeRef(),
&mut access_ref,
))?;
let access = SecAccess::wrap_under_create_rule(access_ref);
// Replace the item's ACL. Silent when this app already owns the item;
// the one-time authorization dialog otherwise (this is what makes a
// stale item permanently silent again).
cvt(SecKeychainItemSetAccess(
item.as_concrete_TypeRef(),
access.as_concrete_TypeRef(),
))
}
}
/// Ref-only lookup for a generic-password item. Prompt-free.
#[cfg(target_os = "macos")]
fn item_ref(
service: &str,
account: &str,
) -> Result<Option<security_framework::os::macos::keychain_item::SecKeychainItem>, String> {
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
let found = match ItemSearchOptions::new()
.class(ItemClass::generic_password())
.service(service)
.account(account)
.load_refs(true)
.load_data(false)
.load_attributes(false)
.search()
{
Ok(found) => found,
// `search()` reports "nothing matched" as an error, not an empty vec.
// That is the normal case for connections without a stored secret.
Err(e) if e.code() == security_framework_sys::base::errSecItemNotFound => return Ok(None),
Err(e) => return Err(format!("keychain item lookup failed: {e}")),
};
Ok(match found.first() {
Some(SearchResult::Ref(Reference::KeychainItem(item))) => Some(item.clone()),
// No item or an unexpected result shape — nothing to normalize.
_ => None,
})
}
/// This process's identity as a `SecTrustedApplication`, serialized.
///
/// For a signed bundle this is the code requirement; for a dev/ad-hoc binary it
/// is the resolved executable path (NUL-terminated). Either way, byte-comparing
/// it against an item's ACL entries tells us whether the ACL already admits us.
#[cfg(target_os = "macos")]
fn own_identity() -> Option<Vec<u8>> {
unsafe {
let mut app = std::ptr::null_mut();
if SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut app) != 0 || app.is_null() {
return None;
}
let app = SecTrustedApplication::wrap_under_create_rule(app);
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
if SecTrustedApplicationCopyData(
app.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
&mut data,
) != 0
|| data.is_null()
{
return None;
}
let bytes = std::slice::from_raw_parts(
core_foundation::data::CFDataGetBytePtr(data),
core_foundation::data::CFDataGetLength(data) as usize,
)
.to_vec();
core_foundation::base::CFRelease(data as core_foundation::base::CFTypeRef);
Some(bytes)
}
}
/// Whether this process is already admitted by the item's ACL — **prompt-free**.
///
/// True when an ACL entry's serialized trusted application is byte-identical to
/// this process's identity, or when the item carries a `cdhash:` partition list
/// (how macOS records access for a signed bundle — we leave those untouched).
#[cfg(target_os = "macos")]
fn is_item_trusted(item: &security_framework::os::macos::keychain_item::SecKeychainItem) -> bool {
use core_foundation::array::{CFArray, CFArrayRef};
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
let Some(own) = own_identity() else {
return false;
};
unsafe {
let mut access: security_framework_sys::base::SecAccessRef = std::ptr::null_mut();
if SecKeychainItemCopyAccess(item.as_concrete_TypeRef(), &mut access) != 0
|| access.is_null()
{
return false;
}
let mut acl_list: CFArrayRef = std::ptr::null();
if SecAccessCopyACLList(access, &mut acl_list) != 0 || acl_list.is_null() {
return false;
}
let acls = CFArray::<CFTypeRef>::wrap_under_create_rule(acl_list);
for acl in acls.iter() {
let mut apps: CFArrayRef = std::ptr::null();
let mut desc: CFStringRef = std::ptr::null();
let mut prompt: u32 = 0;
if SecACLCopyContents(*acl, &mut apps, &mut desc, &mut prompt) != 0 {
continue;
}
if !desc.is_null() {
// Signed-bundle items store a code-signing partition list here.
if CFString::wrap_under_create_rule(desc)
.to_string()
.contains("cdhash:")
{
return true;
}
}
if apps.is_null() {
continue;
}
let list = CFArray::<CFTypeRef>::wrap_under_create_rule(apps);
for app in list.iter() {
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
if SecTrustedApplicationCopyData(*app, &mut data) != 0 || data.is_null() {
continue;
}
let bytes = std::slice::from_raw_parts(
core_foundation::data::CFDataGetBytePtr(data),
core_foundation::data::CFDataGetLength(data) as usize,
);
if bytes == own.as_slice() {
return true;
}
}
}
false
}
}
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
#[cfg(not(target_os = "macos"))]
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use security_framework::os::macos::keychain::SecKeychain;
use security_framework::os::macos::passwords::find_generic_password;
/// A missing item is a silent no-op — no lookup errors, no prompts. This is
/// the path taken for every connection that never stored a password, so it
/// must stay cheap and quiet.
#[test]
fn ensure_item_acl_missing_item_is_noop() {
let service = "com.adrianbonpin.gridline.acl-test.missing";
let account = "definitely-not-stored";
ensure_item_acl(service, account).expect("no-op for a missing item");
}
/// Real-keychain integration test: creates a generic-password item the same
/// way the keyring store does, asserts the trust check, repairs the ACL,
/// verifies the item is now trusted (and that repairing again is a silent
/// no-op), reads the secret back, and cleans up.
///
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
/// keychain. Run explicitly with:
/// `cargo test -- --ignored keychain_acl`
#[test]
#[ignore = "touches the real login keychain"]
fn keychain_acl_normalizes_item() {
let keychain = SecKeychain::default().expect("default keychain");
let service = "com.adrianbonpin.gridline.acl-test";
let account = format!("acl-test-{}", std::process::id());
// Create the item exactly like apple-native-keyring-store does
// (legacy SecKeychain generic-password API).
keychain
.set_generic_password(service, &account, b"test-secret")
.expect("create test item");
// Fresh items are created with this build as the trusted app, so the
// trust check must already be true and the repair a no-op.
let item = item_ref(service, &account)
.expect("lookup")
.expect("item exists");
assert!(is_item_trusted(&item), "fresh item should already trust us");
ensure_item_acl(service, &account).expect("normalize ACL");
assert!(
is_item_trusted(&item),
"item must be trusted after repair"
);
// Idempotent: a second repair must not need authorization again.
ensure_item_acl(service, &account).expect("second repair");
// The item must remain readable with the correct secret.
let (password, item) =
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
assert_eq!(password.as_ref(), b"test-secret");
// Cleanup: delete the test item.
item.delete();
}
}
+61 -8
View File
@@ -5,6 +5,7 @@
mod cancel; mod cancel;
mod commands; mod commands;
mod db; mod db;
mod keychain_acl;
mod models; mod models;
mod store; mod store;
@@ -12,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>,
@@ -23,7 +24,7 @@ pub struct AppState {
use commands::{ use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query, backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
schema_graph, settings, tags, query_export, schema_diff, schema_graph, settings, tags,
}; };
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
@@ -38,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
@@ -58,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()),
@@ -135,6 +165,7 @@ pub fn run() {
db_viewer::get_extensions, db_viewer::get_extensions,
db_viewer::get_indexes, db_viewer::get_indexes,
db_viewer::get_constraints, db_viewer::get_constraints,
db_viewer::get_hypertables,
objects::create_schema, objects::create_schema,
objects::rename_schema, objects::rename_schema,
objects::drop_schema, objects::drop_schema,
@@ -171,8 +202,12 @@ pub fn run() {
settings::export_settings, settings::export_settings,
settings::import_settings, settings::import_settings,
schema_graph::get_schema_graph, schema_graph::get_schema_graph,
schema_diff::compare_schemas,
query::execute_query, query::execute_query,
query::execute_query_multi,
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,
@@ -183,16 +218,34 @@ pub fn run() {
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|app_handle, event| { .run(|app_handle, event| match event {
// macOS: Finder / LaunchServices "open with". File URLs only.
#[cfg(target_os = "macos")]
tauri::RunEvent::Opened { urls } => {
let pending = app_handle
.state::<crate::commands::open_files::PendingOpenFiles>();
let mut accepted = false;
for url in urls {
if let Ok(path) = url.to_file_path() {
if crate::commands::open_files::accept_path(
&pending,
&path.to_string_lossy(),
) {
accepted = true;
}
}
}
if accepted {
let _ = app_handle.emit("sql-file-opened", ());
}
}
// Close all SSH tunnels on exit: ExitRequested fires before the // Close all SSH tunnels on exit: ExitRequested fires before the
// event loop ends, Exit fires after it has. // event loop ends, Exit fires after it has.
if matches!( tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
event,
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
) {
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() { if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
mgr.close_all(); mgr.close_all();
} }
} }
_ => {}
}); });
} }
+25 -1
View File
@@ -42,6 +42,12 @@ pub struct PgToolStatus {
pub pg_restore_version: Option<String>, pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>, pub pg_restore_source: Option<String>,
/// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …).
pub pg_dump_resolved_name: Option<String>,
pub pg_restore_resolved_name: Option<String>,
/// Whether a bundled file exists at the expected path (runnable or not).
pub pg_dump_bundled_available: bool,
pub pg_restore_bundled_available: bool,
} }
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback). /// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
@@ -111,6 +117,12 @@ pub struct MySqlToolStatus {
pub mysql_version: Option<String>, pub mysql_version: Option<String>,
pub mysqldump_source: Option<String>, pub mysqldump_source: Option<String>,
pub mysql_source: Option<String>, pub mysql_source: Option<String>,
pub mysqldump_resolved_name: Option<String>,
pub mysql_resolved_name: Option<String>,
/// True when the dump tool that resolved is MariaDB's `mariadb-dump`.
pub mysqldump_is_mariadb: bool,
pub mysqldump_bundled_available: bool,
pub mysql_bundled_available: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -141,6 +153,9 @@ pub struct BackupProgressEvent {
pub progress: Option<f64>, pub progress: Option<f64>,
pub output_line: Option<String>, pub output_line: Option<String>,
pub error: Option<String>, pub error: Option<String>,
/// Non-fatal advisory message (e.g. a client/server version mismatch).
/// `None` for ordinary progress events.
pub warning: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -185,7 +200,11 @@ mod tests {
fn pg_tool_status_reports_source() { fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true, let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()), pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) }; pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()),
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false };
let json = serde_json::to_string(&s).unwrap(); let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\"")); assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\"")); assert!(json.contains("\"pg_restore_source\":\"bundled\""));
@@ -225,6 +244,11 @@ mod tests {
mysql_version: Some("mariadb 10.6".into()), mysql_version: Some("mariadb 10.6".into()),
mysqldump_source: Some("bundled".into()), mysqldump_source: Some("bundled".into()),
mysql_source: Some("system".into()), mysql_source: Some("system".into()),
mysqldump_resolved_name: Some("mariadb-dump".into()),
mysql_resolved_name: Some("mariadb".into()),
mysqldump_is_mariadb: true,
mysqldump_bundled_available: true,
mysql_bundled_available: true,
}; };
let json = serde_json::to_string(&s).unwrap(); let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"mysqldumpSource\":\"bundled\"")); assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
+152
View File
@@ -72,6 +72,22 @@ pub struct QueryResult {
pub execution_time_ms: Option<i64>, pub execution_time_ms: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatementNotice {
pub statement_index: usize,
/// "dml" | "ddl" | "error"
pub kind: String,
pub text: String,
pub affected: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiQueryResult {
pub result_sets: Vec<QueryResult>,
pub notices: Vec<StatementNotice>,
pub execution_time_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination { pub struct Pagination {
pub page: i64, pub page: i64,
@@ -132,6 +148,23 @@ pub struct ExtensionInfo {
pub comment: Option<String>, pub comment: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HypertableInfo {
pub name: String,
pub schema: String,
pub num_dimensions: i64,
pub compression_enabled: bool,
pub num_chunks: i64,
pub total_size_bytes: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HypertableListResponse {
pub available: bool,
pub reason: Option<String>,
pub items: Vec<HypertableInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit { pub struct ObjectSearchHit {
pub name: String, pub name: String,
@@ -312,6 +345,125 @@ pub struct Relationship {
pub cardinality: String, pub cardinality: String,
} }
// ---------------------------------------------------------------------------
// Schema diff (0.8.0)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotColumn {
pub name: String,
pub data_type: String,
pub nullable: bool,
pub default_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotIndex {
pub name: String,
pub columns: Vec<String>,
pub unique: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotFk {
pub name: String,
pub columns: Vec<String>,
pub ref_schema: String,
pub ref_table: String,
pub ref_columns: Vec<String>,
pub on_delete: String,
pub on_update: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotConstraint {
pub name: String,
/// "unique" | "check"
pub kind: String,
/// check: expression; unique: comma-joined columns
pub definition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotTable {
pub schema: String,
pub name: String,
pub columns: Vec<SnapshotColumn>,
pub primary_key: Vec<String>,
pub uniques: Vec<SnapshotConstraint>,
pub checks: Vec<SnapshotConstraint>,
pub indexes: Vec<SnapshotIndex>,
pub foreign_keys: Vec<SnapshotFk>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotView {
pub schema: String,
pub name: String,
pub definition: String,
pub materialized: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotSequence {
pub schema: String,
pub name: String,
pub start: String,
pub increment: String,
pub minimum: String,
pub maximum: String,
pub cycle: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SnapshotEnum {
pub schema: String,
pub name: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SchemaSnapshot {
/// "postgresql" | "mysql" | "mariadb" | "sqlite" — the CONNECTION's db_type
pub engine: String,
pub schema: String,
pub tables: Vec<SnapshotTable>,
pub views: Vec<SnapshotView>,
pub sequences: Vec<SnapshotSequence>,
pub enums: Vec<SnapshotEnum>,
pub object_count: usize,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DiffDetailLine {
pub label: String,
pub old: String,
pub new: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DiffItem {
/// "table" | "column" | "constraint" | "index" | "view" | "sequence" | "enum"
pub object_type: String,
/// For column/constraint/index items: "<table>.<name>"; else the object name
pub name: String,
/// "added" | "removed" | "changed"
pub kind: String,
pub detail: Vec<DiffDetailLine>,
/// Target-side statements (usually exactly one per item)
pub sync_sql: Vec<String>,
pub destructive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffReport {
pub items: Vec<DiffItem>,
pub source_label: String,
pub target_label: String,
pub truncated: bool,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+13 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.11", "version": "0.8.3",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -27,9 +27,20 @@
"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": false "hardenedRuntime": true,
"entitlements": "entitlements.plist"
}, },
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
+4
View File
@@ -9,7 +9,9 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree
import { ErrorBanner } from "./components/ui/ErrorBanner"; import { ErrorBanner } from "./components/ui/ErrorBanner";
import { ToastContainer } from "./components/ui/Toast"; import { ToastContainer } from "./components/ui/Toast";
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen"; import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
import { PendingSqlFileBanner } from "./components/layout/PendingSqlFileBanner";
import { useAppearance } from "./hooks/useAppearance"; import { useAppearance } from "./hooks/useAppearance";
import { useSqlFileOpen } from "./hooks/useSqlFileOpen";
import { isMacOS } from "./lib/platform"; import { isMacOS } from "./lib/platform";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -36,6 +38,7 @@ export default function App() {
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString); const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB"); useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
useSqlFileOpen();
useEffect(() => { useEffect(() => {
loadConnections(); loadConnections();
@@ -90,6 +93,7 @@ export default function App() {
className="h-7 shrink-0 bg-canvas select-none" className="h-7 shrink-0 bg-canvas select-none"
/> />
)} )}
<PendingSqlFileBanner />
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{connectionError && ( {connectionError && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
interface ConnectionMetadataRowProps { interface ConnectionMetadataRowProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
/** Shown when the label is empty — usually the name derived from a pasted
* connection string, which Test/Save uses as a fallback. */
namePlaceholder?: string;
} }
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) { export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm text-text mb-1.5">Connection Label</label> <label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input <Input
value={form.name} value={form.name}
onChange={(value) => onChange({ name: value })} onChange={(value) => onChange({ name: value })}
placeholder="My Production Database" placeholder={namePlaceholder || "My Production Database"}
aria-label="Connection Label" aria-label="Connection Label"
/> />
</div> </div>
@@ -8,15 +8,18 @@ import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps { export interface DetailedConnectionFormProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null; managedPreset?: "supabase" | "neon" | "planetscale" | null;
/** Placeholder for the Connection Label when the field is empty, e.g. the
* name derived from a pasted connection string. */
namePlaceholder?: string;
} }
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general"); const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ConnectionMetadataRow form={form} onChange={onChange} /> <ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
<div> <div>
<div className="flex gap-6 border-b border-border mb-4"> <div className="flex gap-6 border-b border-border mb-4">
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button> <button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps { export interface GeneralTabProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null; managedPreset?: "supabase" | "neon" | "planetscale" | null;
} }
const AUTH_OPTIONS = ["User & Password"]; const AUTH_OPTIONS = ["User & Password"];
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
)} )}
{managedPreset && ( {managedPreset && (
<p className="text-xs text-accent-muted mt-1.5"> <p className="text-xs text-accent-muted mt-1.5">
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL. {managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.
</p> </p>
)} )}
</div> </div>
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("User")).toHaveValue("u"); expect(screen.getByLabelText("User")).toHaveValue("u");
}); });
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
);
// Every field the URL can supply is filled, so no manual cleanup is needed
expect(screen.getByLabelText("Host")).toHaveValue(
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
);
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
// The label stays untouched but is suggested from the database name.
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
"placeholder",
"neondb",
);
// SSL mode is visible on the SSH / SSL tab...
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
await user.click(screen.getByRole("button", { name: "SSL" }));
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
// ...and reaches the save payload with no extra clicks.
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "neondb",
db_type: "postgresql",
ssl_mode: "require",
username: "neondb_owner",
password: "npg_secret",
database: "neondb",
}),
);
});
it("a typed label wins over the URL-derived suggestion", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "My DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "My DB" }),
);
});
it("clicking a provider tab reveals the form with that db_type", async () => { it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<NewConnectionScreen />); render(<NewConnectionScreen />);
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("Port")).toHaveValue(3306); expect(screen.getByLabelText("Port")).toHaveValue(3306);
}); });
it("NeonDB card preselects Require SSL in the save payload", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
);
});
it("SQLite tab swaps the URI input for a File Path input", async () => { it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<NewConnectionScreen />); render(<NewConnectionScreen />);
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
expect(notify).toHaveBeenCalledWith("Connection successful", "success"); expect(notify).toHaveBeenCalledWith("Connection successful", "success");
}); });
it("tests a pasted URL with its SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
);
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({ ssl_mode: "verify-full" }),
);
});
it("invokes onCancel when Back is clicked", async () => { it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCancel = vi.fn(); const onCancel = vi.fn();
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
import { import {
parseConnectionString, parseConnectionString,
detectProviderFromHost, detectProviderFromHost,
suggestConnectionLabel,
type ParsedConnectionString,
} from "../../lib/connectionString"; } from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers"; import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils"; import { validateConnectionInput } from "../../lib/utils";
@@ -31,6 +33,7 @@ type Stage = "entry" | "configured";
const FALLBACK_PORTS: Record<string, number> = { const FALLBACK_PORTS: Record<string, number> = {
postgresql: 5432, postgresql: 5432,
mysql: 3306, mysql: 3306,
mariadb: 3306,
redis: 6379, redis: 6379,
}; };
@@ -52,6 +55,10 @@ function createEmptyForm(
database: null, database: null,
use_keychain: true, use_keychain: true,
ssh_password: null, ssh_password: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
}; };
} }
@@ -63,6 +70,40 @@ function getDefaultPort(dbType: string): number {
); );
} }
type ManagedPreset = "supabase" | "neon" | "planetscale" | null;
/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */
function providerForParsed(parsed: ParsedConnectionString): ManagedPreset {
return parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
}
/**
* Everything a pasted connection string can populate, so the connection can be
* tested and saved with no manual cleanup: connection type, host, port,
* user/password/database, and the `?sslmode=` TLS mode.
*
* The URL's SSL mode wins when present; otherwise the mode the user already
* picked under SSH / SSL is kept. The label is left blank when the user has not
* typed one (the derived name is offered as a placeholder and used on save by
* `buildPayload`), so a suggestion never clobbers or gets appended to.
*/
function parsedFormPatch(
parsed: ParsedConnectionString,
prev: ConnectionFormData,
): Partial<ConnectionFormData> {
return {
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null,
};
}
export function NewConnectionScreen({ export function NewConnectionScreen({
defaultFolderId = null, defaultFolderId = null,
prefilledConnectionString = "", prefilledConnectionString = "",
@@ -72,9 +113,7 @@ export function NewConnectionScreen({
onCancel, onCancel,
}: NewConnectionScreenProps) { }: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry"); const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState< const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
"supabase" | "neon" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() => const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm( createEmptyForm(
defaultFolderId, defaultFolderId,
@@ -85,6 +124,12 @@ export function NewConnectionScreen({
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection); const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify); const notify = useNotificationStore((s) => s.notify);
// Name to use when the label is left blank: derived from the pasted URL
// (database name, else host/file stem). Shown as the Connection Label's
// placeholder and applied at save/test time, so a suggestion never
// clobbers or gets appended to typed text.
const parsedUri = parseConnectionString(form.connection_string);
const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : "";
const revealConfigured = useCallback( const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => { (updates: Partial<ConnectionFormData>) => {
@@ -96,26 +141,17 @@ export function NewConnectionScreen({
const handleConnectionStringChange = useCallback((value: string) => { const handleConnectionStringChange = useCallback((value: string) => {
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (!parsed) {
const provider = setManagedPreset(null);
parsed.db_type === "postgresql" setForm((prev) => ({ ...prev, connection_string: value }));
? detectProviderFromHost(parsed.host) return;
: null; }
setManagedPreset(provider); setManagedPreset(providerForParsed(parsed));
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
connection_string: value, connection_string: value,
db_type: parsed.db_type, ...parsedFormPatch(parsed, prev),
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
})); }));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -138,7 +174,9 @@ export function NewConnectionScreen({
(id: ProviderId) => { (id: ProviderId) => {
const provider = getProviderById(id)!; const provider = getProviderById(id)!;
setManagedPreset( setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null, provider.isManagedPreset
? (id as "supabase" | "neon" | "planetscale")
: null,
); );
revealConfigured({ revealConfigured({
db_type: provider.dbType, db_type: provider.dbType,
@@ -147,6 +185,15 @@ export function NewConnectionScreen({
? null ? null
: getDefaultPort(provider.dbType), : getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}), ...(provider.dbType === "sqlite" ? { host: "" } : {}),
// Managed providers refuse plaintext connections. Preselect
// their documented mode so Test/Save works on the first try.
// (PlanetScale/MySQL requires identity verification.)
...(id === "planetscale"
? { ssl_mode: "verify-full" as const }
: {}),
...(id === "supabase" || id === "neon"
? { ssl_mode: "require" as const }
: {}),
}); });
}, },
[revealConfigured], [revealConfigured],
@@ -162,22 +209,11 @@ export function NewConnectionScreen({
const value = updates.connection_string; const value = updates.connection_string;
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (parsed) {
const provider = setManagedPreset(providerForParsed(parsed));
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return { return {
...prev, ...prev,
...updates, ...updates,
db_type: parsed.db_type, ...parsedFormPatch(parsed, prev),
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}; };
} }
return { ...prev, ...updates }; return { ...prev, ...updates };
@@ -190,7 +226,7 @@ export function NewConnectionScreen({
const buildPayload = useCallback((): ConnectionInput => { const buildPayload = useCallback((): ConnectionInput => {
return { return {
name: form.name, name: form.name.trim() || suggestedName,
db_type: form.db_type, db_type: form.db_type,
host: form.host, host: form.host,
port: form.port, port: form.port,
@@ -209,8 +245,12 @@ export function NewConnectionScreen({
ssh_private_key_path: form.ssh_private_key ?? null, ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null, ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null, ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}; };
}, [form]); }, [form, suggestedName]);
const validate = useCallback((): string | null => { const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload()); const result = validateConnectionInput(buildPayload());
@@ -332,6 +372,7 @@ export function NewConnectionScreen({
<DetailedConnectionForm <DetailedConnectionForm
form={form} form={form}
onChange={updateForm} onChange={updateForm}
namePlaceholder={suggestedName || undefined}
/> />
</> </>
)} )}
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
import { SETUP_GUIDES } from "../../lib/providers"; import { SETUP_GUIDES } from "../../lib/providers";
interface ProviderSetupGuideProps { interface ProviderSetupGuideProps {
provider: "supabase" | "neon"; provider: "supabase" | "neon" | "planetscale";
} }
const SSL_NOTE: Record<"supabase" | "neon", string> = { const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = {
supabase: "SSL is required by Supabase.", supabase: "SSL is required by Supabase.",
neon: "Neon requires SSL.", neon: "Neon requires SSL.",
planetscale: "PlanetScale requires SSL with identity verification.",
}; };
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) { export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
@@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps
}`} }`}
> >
<span className="w-8 h-8 flex items-center justify-center"> <span className="w-8 h-8 flex items-center justify-center">
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />} {p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon" | "planetscale"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
</span> </span>
<span className="text-sm font-medium">{p.label}</span> <span className="text-sm font-medium">{p.label}</span>
{p.isManagedPreset && ( {p.isManagedPreset && (
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { SslFields } from "./SslFields"; import { SslFields } from "./SslFields";
const notify = vi.fn(); const notify = vi.fn();
@@ -35,4 +35,33 @@ describe("SslFields", () => {
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
}); });
it("writes certificate paths under the names the backend persists", () => {
// Regression guard: these used to be ssl_ca_cert / ssl_client_cert /
// ssl_client_key, which the Rust ConnectionInput silently ignores — the
// connection was then saved with TLS disabled.
const onChange = vi.fn();
render(
<SslFields
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
onChange={onChange}
/>,
);
expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem");
fireEvent.change(screen.getByLabelText("CA Certificate"), {
target: { value: "/tmp/root.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" });
fireEvent.change(screen.getByLabelText("Client Certificate"), {
target: { value: "/tmp/client.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" });
fireEvent.change(screen.getByLabelText("Client Key"), {
target: { value: "/tmp/client.key" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" });
});
}); });
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">CA Certificate</label> <label className="block text-sm text-text mb-1.5">CA Certificate</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_ca_cert as string) ?? ""} value={(values.ssl_ca_path as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_cert: value })} onChange={(value) => onChange({ ssl_ca_path: value })}
placeholder="/path/to/ca-cert.pem" placeholder="/path/to/ca-cert.pem"
aria-label="CA Certificate" aria-label="CA Certificate"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_ca_cert")} onClick={() => handlePickFile("ssl_ca_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Certificate</label> <label className="block text-sm text-text mb-1.5">Client Certificate</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_client_cert as string) ?? ""} value={(values.ssl_cert_path as string) ?? ""}
onChange={(value) => onChange({ ssl_client_cert: value })} onChange={(value) => onChange({ ssl_cert_path: value })}
placeholder="/path/to/client-cert.pem" placeholder="/path/to/client-cert.pem"
aria-label="Client Certificate" aria-label="Client Certificate"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_client_cert")} onClick={() => handlePickFile("ssl_cert_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Key</label> <label className="block text-sm text-text mb-1.5">Client Key</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_client_key as string) ?? ""} value={(values.ssl_key_path as string) ?? ""}
onChange={(value) => onChange({ ssl_client_key: value })} onChange={(value) => onChange({ ssl_key_path: value })}
placeholder="/path/to/client-key.pem" placeholder="/path/to/client-key.pem"
aria-label="Client Key" aria-label="Client Key"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_client_key")} onClick={() => handlePickFile("ssl_key_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -1,5 +1,6 @@
import type { DbType } from "../../lib/types"; import type { DbType } from "../../lib/types";
import type { Environment } from "./EnvironmentSelect"; import type { Environment } from "./EnvironmentSelect";
import type { SslMode } from "../../lib/connectionString";
export interface ConnectionFormData { export interface ConnectionFormData {
name: string; name: string;
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
ssh_private_key?: string | null; ssh_private_key?: string | null;
ssh_password: string | null; ssh_password: string | null;
ssh_passphrase?: string | null; ssh_passphrase?: string | null;
// SSL/TLS fields. These must live on the form data (and survive into the
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
// backend then connects with TLS disabled, which managed providers (Neon,
// Supabase, PlanetScale) reject with "connection is insecure".
// `null` = unset, which the backend treats as TLS disabled.
ssl_mode?: SslMode | null;
ssl_ca_path?: string | null;
ssl_cert_path?: string | null;
ssl_key_path?: string | null;
} }
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal"; import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { CopyButton } from "../ui/CopyButton";
import { Select } from "../ui/Select"; import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
@@ -108,6 +109,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]); }, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found; const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const installInstructions = getPlatformInstructions();
return ( return (
<AnimatedModal open={open} onClose={onClose}> <AnimatedModal open={open} onClose={onClose}>
@@ -124,10 +126,19 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
<p className="text-amber-200/80 text-xs"> <p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using: The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p> </p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()} {installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { BackupPage } from "./BackupPage"; import { BackupPage } from "./BackupPage";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -84,3 +84,104 @@ describe("BackupPage DB-aware", () => {
expect(screen.getByText("Custom Archive")).toBeTruthy(); expect(screen.getByText("Custom Archive")).toBeTruthy();
}); });
}); });
describe("BackupPage tool provenance and re-check", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
});
it("shows the resolved tool and its source", async () => {
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "mariadb-dump 11.4.5-MariaDB",
mysqlVersion: "mariadb 11.4.5-MariaDB",
mysqldumpSource: "bundled",
mysqlSource: "bundled",
mysqldumpResolvedName: "mariadb-dump",
mysqlResolvedName: "mariadb",
mysqldumpBundledAvailable: true,
mysqlBundledAvailable: true,
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText(/Using bundled mariadb-dump/)).toBeTruthy());
});
it("re-checks with force when Check again is clicked", async () => {
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
});
render(<BackupPage connectionId="c2" />);
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
});
it("explains when the bundled tool is present but could not run", async () => {
mockConnections.push({ id: "c3", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: true,
mysqlBundledAvailable: false,
});
render(<BackupPage connectionId="c3" />);
await waitFor(() =>
expect(screen.getByText(/bundled copy is present but could not run/i)).toBeTruthy(),
);
});
it("shows install instructions when nothing resolves at all", async () => {
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: false,
});
render(<BackupPage connectionId="c4" />);
await waitFor(() => expect(screen.getByText(/mysql-client/)).toBeTruthy());
});
it("offers a copy button for the install command", async () => {
mockConnections.push({ id: "c-copy", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
mysqldumpBundledAvailable: false,
});
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn() },
configurable: true,
writable: true,
});
render(<BackupPage connectionId="c-copy" />);
const btn = await screen.findByTitle("Copy to clipboard");
fireEvent.click(btn);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("mysql-client"),
);
});
});
+173 -156
View File
@@ -1,8 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Download, FolderOpen, HardDrive } from "lucide-react"; import { Download, FolderOpen, HardDrive } from "lucide-react";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button"; import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
inputClass,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
@@ -98,16 +104,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
} }
}, [activeJob, notify]); }, [activeJob, notify]);
useEffect(() => { const checkTools = useCallback(
async (force: boolean) => {
setCheckingTools(true); setCheckingTools(true);
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null); setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) { if (isPg) {
detectPgTools() try {
.then((status) => setPgToolStatus(status)) setPgToolStatus(await detectPgTools(force));
.catch(() => } catch {
setPgToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
@@ -115,17 +121,14 @@ export function BackupPage({ connectionId }: BackupPageProps) {
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null, pg_dump_source: null,
pg_restore_source: null, pg_restore_source: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) { } else if (isMysql) {
detectMysqlTools() try {
.then((status) => setMysqlToolStatus(status)) setMysqlToolStatus(await detectMysqlTools(force));
.catch(() => } catch {
setMysqlToolStatus({ setMysqlToolStatus({
mysqldumpFound: false, mysqldumpFound: false,
mysqlFound: false, mysqlFound: false,
@@ -133,17 +136,26 @@ export function BackupPage({ connectionId }: BackupPageProps) {
mysqlVersion: null, mysqlVersion: null,
mysqldumpSource: null, mysqldumpSource: null,
mysqlSource: null, mysqlSource: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else { } else {
setCheckingTools(false); setCheckingTools(false);
} }
}, [connectionId, isPg, isMysql]); },
[isPg, isMysql],
);
useEffect(() => {
setAvailableSchemas([]);
void checkTools(false);
if (isPg || isMysql) {
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}
}, [connectionId, isPg, isMysql, checkTools]);
const handlePickFile = useCallback(async () => { const handlePickFile = useCallback(async () => {
let defaultPath = "backup"; let defaultPath = "backup";
@@ -262,6 +274,23 @@ export function BackupPage({ connectionId }: BackupPageProps) {
? mysqlToolStatus?.mysqldumpSource === "bundled" ? mysqlToolStatus?.mysqldumpSource === "bundled"
: false; : false;
const dumpToolFound = isPg
? pgToolStatus?.pg_dump_found
: mysqlToolStatus?.mysqldumpFound;
const dumpSource = isPg
? pgToolStatus?.pg_dump_source
: mysqlToolStatus?.mysqldumpSource;
const dumpResolvedName = isPg
? pgToolStatus?.pg_dump_resolved_name
: mysqlToolStatus?.mysqldumpResolvedName;
const bundledPresent = isPg
? pgToolStatus?.pg_dump_bundled_available
: mysqlToolStatus?.mysqldumpBundledAvailable;
const dumpFallbackName = isPg ? "pg_dump" : "mysqldump";
const installInstructions = getPlatformInstructions(
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
);
const checkingMessage = isPg const checkingMessage = isPg
? "Checking for pg_dump..." ? "Checking for pg_dump..."
: isMysql : isMysql
@@ -275,128 +304,141 @@ export function BackupPage({ connectionId }: BackupPageProps) {
: "Create a database backup"; : "Create a database backup";
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<HardDrive size={14} className="text-accent" /> <HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span> <span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
{headerDescription} {headerDescription}
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{(isPg || isMysql) && (
<button
type="button"
onClick={() => void checkTools(true)}
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
>
Check again
</button>
)}
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartBackup()}
disabled={isRunning || !filePath}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_dump not found" : "mysqldump not found"} {isPg ? "pg_dump not found" : "mysqldump not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client
backup/restore operations. Install them using: tools. This build could not locate or run them, so a manual
install is needed:
</p>
{bundledPresent && (
<p className="text-amber-200/80 text-xs leading-relaxed">
A bundled copy is present but could not run — reinstall
Gridline, or install the tools below.
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)} )}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
{installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} {dumpToolFound && (
<div className="p-5 space-y-5"> <FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
{dumpResolvedName ?? dumpFallbackName}
</span>
</FormRow>
)}
{/* Format */} {/* Format */}
{isPg ? ( {isPg ? (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select <select
value={format} value={format}
onChange={(e) => onChange={(e) =>
setFormat( setFormat(e.target.value as BackupFormat)
e.target.value as BackupFormat,
)
} }
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" className={`${controlClass} mr-3`}
> >
<option value="custom"> <option value="custom">Custom Archive</option>
Custom Archive
</option>
<option value="plain">Plain SQL</option> <option value="plain">Plain SQL</option>
<option value="tar">Tarball</option> <option value="tar">Tarball</option>
<option value="directory"> <option value="directory">Directory</option>
Directory
</option>
</select> </select>
</div> </FormRow>
) : isMysql ? ( ) : isMysql ? (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <span className="px-3 font-heading text-xs text-text">
Format
</label>
<div className="text-sm text-text py-2">
Plain SQL Plain SQL
</div> </span>
</div> </FormRow>
) : null} ) : null}
{/* Output file */} {/* Output file */}
<div className="space-y-1 w-full"> <FormRow label="Output file">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Output File
</label>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={filePath} value={filePath}
onChange={(e) => onChange={(e) => setFilePath(e.target.value)}
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump" placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" className={inputClass}
/> />
<button <button
type="button" type="button"
onClick={handlePickFile} onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file" aria-label="Browse for file"
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
> >
<FolderOpen size={15} /> <FolderOpen size={13} />
</button> </button>
</div> </FormRow>
</div>
{/* Schema (optional) */} {/* Schema */}
{(isPg || isMysql) && ( {(isPg || isMysql) && (
<div className="space-y-1"> <FormRow label="Schema">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select <select
value={schema} value={schema}
onChange={(e) => onChange={(e) => setSchema(e.target.value)}
setSchema(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="">All schemas</option> <option value="">All schemas</option>
{availableSchemas.map((s) => ( {availableSchemas.map((s) => (
@@ -405,119 +447,106 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
)}
{(isPg || isMysql) && (
<FormSectionHeader label="Options" />
)} )}
{/* PostgreSQL: no-owner toggle */} {/* PostgreSQL: no-owner toggle */}
{isPg && ( {isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group"> <FormRow label="No owner">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={noOwner} checked={noOwner}
onChange={(e) => onChange={(e) => setNoOwner(e.target.checked)}
setNoOwner(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner --no-owner
</code> </code>
</span>
</label> </label>
</FormRow>
)} )}
{/* MySQL: option toggles */} {/* MySQL: option toggles */}
{isMysql && ( {isMysql && (
<div className="space-y-2"> <>
<label className="flex items-center gap-2.5 cursor-pointer group"> <FormRow label="Single transaction">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={singleTransaction} checked={singleTransaction}
onChange={(e) => onChange={(e) =>
setSingleTransaction(e.target.checked) setSingleTransaction(e.target.checked)
} }
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer" className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--single-transaction --single-transaction
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="No data">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={noData} checked={noData}
onChange={(e) => onChange={(e) => setNoData(e.target.checked)}
setNoData(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
No Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-data --no-data
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Routines">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={routines} checked={routines}
onChange={(e) => onChange={(e) => setRoutines(e.target.checked)}
setRoutines(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--routines --routines
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Triggers">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={triggers} checked={triggers}
onChange={(e) => onChange={(e) => setTriggers(e.target.checked)}
setTriggers(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--triggers --triggers
</code> </code>
</span>
</label> </label>
<label className="flex items-center gap-2.5 cursor-pointer group"> </FormRow>
<FormRow label="Events">
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={events} checked={events}
onChange={(e) => onChange={(e) => setEvents(e.target.checked)}
setEvents(e.target.checked) className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--events --events
</code> </code>
</span>
</label> </label>
</div> </FormRow>
</>
)} )}
</div>
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump" jobType="dump"
@@ -526,21 +555,9 @@ export function BackupPage({ connectionId }: BackupPageProps) {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartBackup}
disabled={isRunning || !filePath}
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -281,8 +281,8 @@ describe("DbViewerScreen", () => {
it("executes a non-destructive query when Run is clicked", async () => { it("executes a non-destructive query when Run is clicked", async () => {
const executeQuery = vi const executeQuery = vi
.spyOn(commands, "executeQuery") .spyOn(commands, "executeQueryMulti")
.mockResolvedValue(mockQueryResult as any); .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render( render(
<DbViewerScreen <DbViewerScreen
connectionId="c1" connectionId="c1"
@@ -306,13 +306,11 @@ describe("DbViewerScreen", () => {
it("shows a destructive-query confirmation dialog and executes on confirm", async () => { it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
const executeQuery = vi const executeQuery = vi
.spyOn(commands, "executeQuery") .spyOn(commands, "executeQueryMulti")
.mockResolvedValue({ .mockResolvedValue({
columns: [], result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }],
rows: [], notices: [],
total_rows: 0, execution_time_ms: 42,
page: 1,
page_size: 50,
} as any); } as any);
render( render(
<DbViewerScreen <DbViewerScreen
@@ -345,8 +343,8 @@ describe("DbViewerScreen", () => {
}); });
it("refreshes the schema tree after a DDL query runs", async () => { it("refreshes the schema tree after a DDL query runs", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue( vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
mockQueryResult as any, { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
); );
const getSchemas = vi const getSchemas = vi
.spyOn(commands, "getSchemas") .spyOn(commands, "getSchemas")
@@ -377,8 +375,8 @@ describe("DbViewerScreen", () => {
}); });
it("does not refresh the schema tree after a SELECT", async () => { it("does not refresh the schema tree after a SELECT", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue( vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
mockQueryResult as any, { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
); );
const getSchemas = vi const getSchemas = vi
.spyOn(commands, "getSchemas") .spyOn(commands, "getSchemas")
@@ -396,7 +394,7 @@ describe("DbViewerScreen", () => {
); );
fireEvent.change(textarea, { target: { value: "SELECT 1" } }); fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i })); fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled()); await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled());
expect(getSchemas).not.toHaveBeenCalled(); expect(getSchemas).not.toHaveBeenCalled();
}); });
@@ -423,8 +421,8 @@ describe("DbViewerScreen", () => {
it("runs the current query when the Cmd+Enter action fires", async () => { it("runs the current query when the Cmd+Enter action fires", async () => {
const executeQuery = vi const executeQuery = vi
.spyOn(commands, "executeQuery") .spyOn(commands, "executeQueryMulti")
.mockResolvedValue(mockQueryResult as any); .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render( render(
<DbViewerScreen <DbViewerScreen
connectionId="c1" connectionId="c1"
@@ -452,7 +450,7 @@ describe("DbViewerScreen", () => {
const pending = new Promise<unknown>((r) => { const pending = new Promise<unknown>((r) => {
resolveRun = r; resolveRun = r;
}); });
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any); vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any);
render( render(
<DbViewerScreen <DbViewerScreen
connectionId="c1" connectionId="c1"
@@ -469,7 +467,7 @@ describe("DbViewerScreen", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(), expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
); );
resolveRun(mockQueryResult); resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 });
await waitFor(() => await waitFor(() =>
expect(screen.queryByTestId("query-run-pulse")).toBeNull(), expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
); );
@@ -477,8 +475,8 @@ describe("DbViewerScreen", () => {
it("collapses and re-expands the query results via the caret", async () => { it("collapses and re-expands the query results via the caret", async () => {
const executeQuery = vi const executeQuery = vi
.spyOn(commands, "executeQuery") .spyOn(commands, "executeQueryMulti")
.mockResolvedValue(mockQueryResult as any); .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render( render(
<DbViewerScreen <DbViewerScreen
connectionId="c1" connectionId="c1"
@@ -505,8 +503,8 @@ describe("DbViewerScreen", () => {
}); });
it("resizes the results panel with a drag handle, clamped to min/max", async () => { it("resizes the results panel with a drag handle, clamped to min/max", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue( vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
mockQueryResult as any, { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
); );
render( render(
<DbViewerScreen <DbViewerScreen
@@ -930,7 +928,7 @@ describe("DbViewerScreen", () => {
}); });
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => { it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any); vi.spyOn(commands, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any);
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]); vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]); vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any); vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar"; import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar"; import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils"; import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery, cancelQuery } from "../../lib/commands"; import { executeQueryMulti, cancelQuery } from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities"; import { getCapabilities } from "../../lib/dbCapabilities";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
@@ -19,8 +19,11 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
import { ObjectFormTab } from "./objects/ObjectFormTab"; import { ObjectFormTab } from "./objects/ObjectFormTab";
import { TabBar } from "./TabBar"; import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { ResultSetPanels } from "./ResultSetPanels";
import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls"; import { TableControls } from "./TableControls";
import { StreamExportDialog } from "./StreamExportDialog";
import { qualifiedTableSql } from "../../lib/exportData";
import { EditConnectionModal } from "./EditConnectionModal"; import { EditConnectionModal } from "./EditConnectionModal";
import { PasswordPromptDialog } from "./PasswordPromptDialog"; import { PasswordPromptDialog } from "./PasswordPromptDialog";
import { useDbConnection } from "../../hooks/useDbConnection"; import { useDbConnection } from "../../hooks/useDbConnection";
@@ -177,6 +180,7 @@ export function DbViewerScreen({
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null); const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null); const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null);
const connections = useConnectionStore((s) => s.connections); const connections = useConnectionStore((s) => s.connections);
const currentConnection = const currentConnection =
connections.find((c) => c.id === connectionId) ?? null; connections.find((c) => c.id === connectionId) ?? null;
@@ -231,6 +235,7 @@ export function DbViewerScreen({
const changesQueue = useDbViewerStore((s) => s.changesQueue); const changesQueue = useDbViewerStore((s) => s.changesQueue);
const tables = useDbViewerStore((s) => s.tables); const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit); const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const updateChange = useDbViewerStore((s) => s.updateChange);
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading; const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
@@ -259,6 +264,7 @@ export function DbViewerScreen({
const setTabData = useDbViewerStore((s) => s.setTabData); const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError); const setTabError = useDbViewerStore((s) => s.setTabError);
const setTabMulti = useDbViewerStore((s) => s.setTabMulti);
const setTabLoading = useDbViewerStore((s) => s.setTabLoading); const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
const databases = useDbViewerStore((s) => s.databases); const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase); const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
@@ -310,8 +316,24 @@ export function DbViewerScreen({
if (!tab) return; if (!tab) return;
setTabLoading(tabId, true); setTabLoading(tabId, true);
try { try {
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result); const first = multi.result_sets[0] ?? null;
// Stamp the run's execution time onto the first set so the toolbar
// (TableControls reads activeTab.data.execution_time_ms) still shows it.
if (first) {
setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms });
} else {
// Lone DML/DDL run with no result set — keep the empty-state behavior.
setTabData(tabId, {
columns: [],
rows: [],
total_rows: 0,
page: tab.page,
page_size: tab.pageSize,
execution_time_ms: multi.execution_time_ms,
});
}
setTabMulti(tabId, multi);
useQueryStore.getState().invalidateHistory(connectionId); useQueryStore.getState().invalidateHistory(connectionId);
if (isSchemaModifyingQuery(sql)) { if (isSchemaModifyingQuery(sql)) {
const st = useDbViewerStore.getState(); const st = useDbViewerStore.getState();
@@ -339,6 +361,19 @@ export function DbViewerScreen({
[activeTab, stageCellEdit], [activeTab, stageCellEdit],
); );
const handleStageInsertCell = useCallback(
(changeId: string, column: string, value: string | null) => {
const change = useDbViewerStore
.getState()
.changesQueue.find((c) => c.id === changeId);
if (!change) return;
const newData = { ...(change.newData ?? {}) };
newData[column] = value;
updateChange(changeId, { newData });
},
[updateChange],
);
const handleOpenRowDetail = useCallback((rowIndex: number) => { const handleOpenRowDetail = useCallback((rowIndex: number) => {
setRowDetailIdx(rowIndex); setRowDetailIdx(rowIndex);
}, []); }, []);
@@ -832,6 +867,24 @@ export function DbViewerScreen({
// Data is already filtered and sorted server-side; no client-side transform needed. // Data is already filtered and sorted server-side; no client-side transform needed.
const processedRows = rawRows; const processedRows = rawRows;
// Pending insert rows (from the changes queue) are prepended to the grid as
// editable rows. Only pending inserts for the active table tab count.
const pendingInserts =
activeTab?.tabType === "table"
? changesQueue.filter(
(c) =>
c.type === "insert" &&
c.status === "pending" &&
c.schema === activeTab.schema &&
c.table === activeTab.table,
)
: [];
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
const pendingInsertRows = pendingInserts.map((c) =>
columns.map((col) => c.newData?.[col.name] ?? null),
);
const displayRows = [...pendingInsertRows, ...processedRows];
const onPanelResizeStart = useCallback( const onPanelResizeStart = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -985,6 +1038,48 @@ const onQueriesPanelResizeStart = useCallback(
) )
: {}; : {};
// Shared VirtualDataGrid props for the query-tab results region. The
// multi-result panels reuse these (overriding rows/columns per set);
// single-set runs render the grid directly with today's props.
const gridPropsForQueryTab = {
connectionId,
schema: activeSchema,
table: activeTable,
rows: displayRows,
columns,
hiddenColumns,
selectedRows,
dbType: currentConnection?.db_type ?? "postgresql",
tabType: activeTab?.tabType ?? "table",
getLocator,
onStageEdit: readOnlyTable ? undefined : handleStageEdit,
onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell,
onOpenRowDetail: handleOpenRowDetail,
readOnly: readOnlyTable,
enumValues: editorOptions?.enums,
fkOptions: editorOptions?.fks,
fkPlaceholders: editorOptions?.fkPlaceholders,
stagedValues,
pendingKeys,
pendingInsertChangeIds,
onToggleRow: (rowIndex: number) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(rowIndex)) next.delete(rowIndex);
else next.add(rowIndex);
return next;
});
},
onToggleAll: () => {
setSelectedRows((prev) => {
if (prev.size === processedRows.length && processedRows.length > 0) {
return new Set();
}
return new Set(processedRows.map((_, i) => i));
});
},
};
return ( return (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar onCommitted={handleCommitted} /> <TabBar onCommitted={handleCommitted} />
@@ -1177,72 +1272,31 @@ const onQueriesPanelResizeStart = useCallback(
} }
variant="query" variant="query"
isMatview={readOnlyTable} isMatview={readOnlyTable}
onExportToFile={() =>
setStreamExport({
sql:
activeTab?.tabType === "query"
? activeTab.query ?? ""
: qualifiedTableSql(
currentConnection?.db_type ?? "postgresql",
activeSchema,
activeTable,
),
})
}
/> />
)} )}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid {activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? (
connectionId={connectionId} <ResultSetPanels
schema={activeSchema} multi={activeTab.multi}
table={activeTable} renderGrid={(set) => (
rows={processedRows} <VirtualDataGrid {...gridPropsForQueryTab} rows={set.rows} columns={set.columns} />
columns={columns} )}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
const next =
new Set(
prev,
);
if (
next.has(
rowIndex,
)
)
next.delete(
rowIndex,
);
else
next.add(
rowIndex,
);
return next;
},
);
}}
onToggleAll={() => {
setSelectedRows(
(prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length >
0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) =>
i,
),
);
},
);
}}
/> />
) : (
<VirtualDataGrid {...gridPropsForQueryTab} />
)}
</div> </div>
</div> </div>
</> </>
@@ -1341,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback(
setSelectedRows(new Set()) setSelectedRows(new Set())
} }
isMatview={readOnlyTable} isMatview={readOnlyTable}
onExportToFile={() =>
setStreamExport({
sql:
activeTab?.tabType === "query"
? activeTab.query ?? ""
: qualifiedTableSql(
currentConnection?.db_type ?? "postgresql",
activeSchema,
activeTable,
),
})
}
/> />
)} )}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -1348,7 +1414,7 @@ const onQueriesPanelResizeStart = useCallback(
connectionId={connectionId} connectionId={connectionId}
schema={activeSchema} schema={activeSchema}
table={activeTable} table={activeTable}
rows={processedRows} rows={displayRows}
columns={columns} columns={columns}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
selectedRows={selectedRows} selectedRows={selectedRows}
@@ -1356,6 +1422,7 @@ const onQueriesPanelResizeStart = useCallback(
tabType={activeTab?.tabType ?? "table"} tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator} getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit} onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail} onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable} readOnly={readOnlyTable}
enumValues={editorOptions?.enums} enumValues={editorOptions?.enums}
@@ -1363,6 +1430,7 @@ const onQueriesPanelResizeStart = useCallback(
fkPlaceholders={editorOptions?.fkPlaceholders} fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues} stagedValues={stagedValues}
pendingKeys={pendingKeys} pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => { onToggleRow={(rowIndex) => {
setSelectedRows((prev) => { setSelectedRows((prev) => {
const next = new Set( const next = new Set(
@@ -1531,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
{capabilities.objects && ( {capabilities.objects && (
<ObjectSearchPalette connectionId={connectionId} /> <ObjectSearchPalette connectionId={connectionId} />
)} )}
{streamExport && (
<StreamExportDialog
connectionId={connectionId}
sql={streamExport.sql}
onClose={() => setStreamExport(null)}
/>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
})); }));
vi.mock("../../stores/connectionStore", () => ({ vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) => useConnectionStore: Object.assign(
(sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }), sel({ updateConnection, loadAll, folders: [], tags: [] }),
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
),
})); }));
vi.mock("../../lib/commands", async (importOriginal) => { vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>(); const actual = await importOriginal<typeof import("../../lib/commands")>();
return { return {
...actual, ...actual,
updateConnection: vi.fn(), updateConnection: vi.fn(),
testConnection: vi.fn(), testConnection: vi.fn().mockResolvedValue({ ok: true }),
saveConnectionPassword: vi.fn(), saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(), saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(), saveConnectionSshPassphrase: vi.fn(),
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
deleteConnectionPassword: vi.fn(), deleteConnectionPassword: vi.fn(),
}; };
}); });
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
); );
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled(); expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
}); });
it("persists the existing SSL settings on save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
"c1",
expect.objectContaining({
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}),
);
});
it("tests with the same SSL + SSH config it would save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_port: 22,
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
expect.objectContaining({
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}),
);
});
}); });
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm"; import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands"; import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain"; import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString"; import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types"; import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData"; import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -47,6 +47,10 @@ export function EditConnectionModal({
ssh_private_key: connection.ssh_private_key_path ?? null, ssh_private_key: connection.ssh_private_key_path ?? null,
ssh_password: null, ssh_password: null,
ssh_passphrase: null, ssh_passphrase: null,
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
ssl_ca_path: connection.ssl_ca_path ?? null,
ssl_cert_path: connection.ssl_cert_path ?? null,
ssl_key_path: connection.ssl_key_path ?? null,
})); }));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -75,6 +79,10 @@ export function EditConnectionModal({
ssh_private_key_path: form.ssh_private_key ?? null, ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null, ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null, ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}; };
const updated = await updateConnection(connection.id, input); const updated = await updateConnection(connection.id, input);
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {}); await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
@@ -104,6 +112,18 @@ export function EditConnectionModal({
if (!password) { if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null); password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
} }
// SSH secrets live in the keychain only, so a tunneled connection can be
// tested without re-entering them.
const sshPassword =
form.ssh_password ??
(form.ssh_host
? await getConnectionSshPassword(connection.id).catch(() => null)
: null);
const sshPassphrase =
form.ssh_passphrase ??
(form.ssh_host
? await getConnectionSshPassphrase(connection.id).catch(() => null)
: null);
const result = await testConnection({ const result = await testConnection({
name: form.name, name: form.name,
@@ -116,7 +136,19 @@ export function EditConnectionModal({
folder_id: form.folder_id, folder_id: form.folder_id,
environment: form.environment, environment: form.environment,
tag_ids: form.tag_ids, tag_ids: form.tag_ids,
ssh_password: form.ssh_password ?? null, // Test the same config Save would persist — a tunneled/SSL connection
// must be probed through its tunnel and TLS mode.
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: sshPassword,
ssh_passphrase: sshPassphrase,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}); });
if (result.ok) { if (result.ok) {
notify("Connection successful", "success"); notify("Connection successful", "success");
@@ -1,27 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
@@ -1,132 +0,0 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { HypertableDetail } from "./HypertableDetail";
import type { HypertableInfo } from "../../lib/types";
const item: HypertableInfo = {
name: "metrics",
schema: "public",
num_dimensions: 2,
compression_enabled: true,
num_chunks: 42,
total_size_bytes: 1048576,
};
describe("HypertableDetail", () => {
it("renders dimensions, compression, chunk count and human size", () => {
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
expect(screen.getByText("metrics")).toBeTruthy();
expect(screen.getByText(/2 dimensions/)).toBeTruthy();
expect(screen.getByText(/compression enabled/)).toBeTruthy();
expect(screen.getByText(/42 chunks/)).toBeTruthy();
expect(screen.getByText(/1\.0 MB/)).toBeTruthy();
});
it("shows the DDL/restore caveat", () => {
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
expect(screen.getByText(/create_hypertable/)).toBeTruthy();
});
it("opens the underlying table on click", () => {
const onOpenTable = vi.fn();
render(<HypertableDetail item={item} onOpenTable={onOpenTable} />);
fireEvent.click(screen.getByRole("button", { name: /view table/i }));
expect(onOpenTable).toHaveBeenCalledWith("public", "metrics");
});
it("marks compression as disabled when off", () => {
render(
<HypertableDetail item={{ ...item, compression_enabled: false }} onOpenTable={vi.fn()} />,
);
expect(screen.getByText(/compression disabled/)).toBeTruthy();
});
});
@@ -0,0 +1,48 @@
import { Table2 } from "lucide-react";
import type { HypertableInfo } from "../../lib/types";
function humanBytes(bytes: number | null): string {
if (bytes === null) return "unknown size";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
}
export function HypertableDetail({
item,
onOpenTable,
}: {
item: HypertableInfo;
onOpenTable: (schema: string, table: string) => void;
}) {
return (
<div className="mt-1 space-y-2">
<div className="text-sm text-text">{item.name}</div>
<div className="flex flex-wrap gap-2 text-xs text-text-muted">
<span className="rounded bg-surface-raised px-2 py-1">{item.num_dimensions} dimensions</span>
<span className="rounded bg-surface-raised px-2 py-1">
{item.compression_enabled ? "compression enabled" : "compression disabled"}
</span>
<span className="rounded bg-surface-raised px-2 py-1">{item.num_chunks} chunks</span>
<span className="rounded bg-surface-raised px-2 py-1">{humanBytes(item.total_size_bytes)}</span>
</div>
<button
type="button"
onClick={() => onOpenTable(item.schema, item.name)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
>
<Table2 size={13} /> View table
</button>
<p className="text-xs text-text-subtle">
Note: Copy DDL exports the table as plain SQL plus its create_hypertable call. A
full-fidelity restore needs TimescaleDB pre/post-restore hooks, which Gridline does
not run.
</p>
</div>
);
}
@@ -22,10 +22,6 @@ interface ObjectExplorerPageProps {
sidebarMode?: boolean; sidebarMode?: boolean;
} }
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
(t) => ({ value: t, label: TYPE_LABELS[t] }),
);
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */ /** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
function emptyPlural(type: ObjectType): string { function emptyPlural(type: ObjectType): string {
const singular = SINGULAR_LABELS[type]; const singular = SINGULAR_LABELS[type];
@@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string {
sequences: "sequence", sequences: "sequence",
enums: "enum", enums: "enum",
extensions: "extension", extensions: "extension",
hypertables: "table",
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
roles: "role", roles: "role",
@@ -125,6 +122,19 @@ export function ObjectExplorerPage({
const changesQueue = useDbViewerStore((s) => s.changesQueue); const changesQueue = useDbViewerStore((s) => s.changesQueue);
const openObjectTab = useDbViewerStore((s) => s.openObjectTab); const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
const refreshTree = useDbViewerStore((s) => s.refreshTree); const refreshTree = useDbViewerStore((s) => s.refreshTree);
const hypertablesAvailable = useDbViewerStore((s) => s.hypertablesAvailable);
const setHypertablesAvailable = useDbViewerStore((s) => s.setHypertablesAvailable);
// Build the dropdown from the full object-type union, gating hypertables
// on confirmed TimescaleDB availability (visibleObjectTypes in ObjectTree
// is scoped to that tree's smaller type set).
const objectTypeOptions = useMemo(() => {
const all = Object.keys(TYPE_LABELS) as ObjectType[];
const visible =
hypertablesAvailable === true
? all
: all.filter((t) => t !== "hypertables");
return visible.map((t) => ({ value: t, label: TYPE_LABELS[t] }));
}, [hypertablesAvailable]);
// Use store for persistence, but allow re-fetching when schema changes // Use store for persistence, but allow re-fetching when schema changes
const [items, setItems] = useState<AnyObject[] | null>(null); const [items, setItems] = useState<AnyObject[] | null>(null);
@@ -218,6 +228,23 @@ export function ObjectExplorerPage({
} }
}, [currentSchema, fetch]); }, [currentSchema, fetch]);
// Probe TimescaleDB availability once per connection so the hypertables
// object type only appears in the dropdown when it is actually supported.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await cmd.getHypertables(connectionId);
if (!cancelled) setHypertablesAvailable(res.available);
} catch {
if (!cancelled) setHypertablesAvailable(false);
}
})();
return () => {
cancelled = true;
};
}, [connectionId, setHypertablesAvailable]);
// After a commit containing ddl items succeeds, refetch the current // After a commit containing ddl items succeeds, refetch the current
// object-type list + refresh the schema tree so newly created/dropped // object-type list + refresh the schema tree so newly created/dropped
// objects show up without a manual refresh. // objects show up without a manual refresh.
@@ -325,7 +352,7 @@ export function ObjectExplorerPage({
<SelectDropdown <SelectDropdown
value={type} value={type}
onChange={(v) => handleTypeChange(v as ObjectType)} onChange={(v) => handleTypeChange(v as ObjectType)}
options={OBJECT_TYPE_OPTIONS} options={objectTypeOptions}
variant="ghost" variant="ghost"
aria-label="Object type" aria-label="Object type"
/> />
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { TYPE_LABELS, visibleObjectTypes } from "./ObjectTree";
describe("hypertables object type", () => {
it("registers label and stays in the type union", () => {
expect(TYPE_LABELS.hypertables).toBe("hypertables");
expect(Object.keys(TYPE_LABELS)).toContain("hypertables");
});
it("hides hypertables until availability is confirmed true", () => {
const base = Object.keys(TYPE_LABELS) as ReturnType<typeof visibleObjectTypes>;
expect(visibleObjectTypes(null)).not.toContain("hypertables");
expect(visibleObjectTypes(false)).not.toContain("hypertables");
expect(visibleObjectTypes(true)).toContain("hypertables");
expect(visibleObjectTypes(true).length).toBe(base.length);
});
});
@@ -1,22 +1,24 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react"; import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Layers, Search } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types"; import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types";
import { HypertableDetail } from "./HypertableDetail";
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions"; type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables";
interface ObjectTreeProps { interface ObjectTreeProps {
type: ObjectType; type: ObjectType;
connectionId: string; connectionId: string;
} }
const TYPE_LABELS: Record<ObjectType, string> = { export const TYPE_LABELS: Record<ObjectType, string> = {
functions: "functions", functions: "functions",
triggers: "triggers", triggers: "triggers",
sequences: "sequences", sequences: "sequences",
enums: "enums", enums: "enums",
extensions: "extensions", extensions: "extensions",
hypertables: "hypertables",
}; };
const ICONS: Record<ObjectType, React.ReactNode> = { const ICONS: Record<ObjectType, React.ReactNode> = {
@@ -25,8 +27,18 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />, sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />, enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />, extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
}; };
/** Object types visible in the dropdown. Hypertables only show when the
* connection confirmed TimescaleDB availability (available === true). */
export function visibleObjectTypes(hypertablesAvailable: boolean | null): ObjectType[] {
const all = Object.keys(TYPE_LABELS) as ObjectType[];
return hypertablesAvailable === true
? all
: all.filter((t) => t !== "hypertables");
}
function SourceCode({ source }: { source: string }) { function SourceCode({ source }: { source: string }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const maxLen = 500; const maxLen = 500;
@@ -59,11 +71,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
const sequences = useDbViewerStore((s) => s.sequences); const sequences = useDbViewerStore((s) => s.sequences);
const enums = useDbViewerStore((s) => s.enums); const enums = useDbViewerStore((s) => s.enums);
const extensions = useDbViewerStore((s) => s.extensions); const extensions = useDbViewerStore((s) => s.extensions);
const hypertables = useDbViewerStore((s) => s.hypertables);
const setFunctions = useDbViewerStore((s) => s.setFunctions); const setFunctions = useDbViewerStore((s) => s.setFunctions);
const setTriggers = useDbViewerStore((s) => s.setTriggers); const setTriggers = useDbViewerStore((s) => s.setTriggers);
const setSequences = useDbViewerStore((s) => s.setSequences); const setSequences = useDbViewerStore((s) => s.setSequences);
const setEnums = useDbViewerStore((s) => s.setEnums); const setEnums = useDbViewerStore((s) => s.setEnums);
const setExtensions = useDbViewerStore((s) => s.setExtensions); const setExtensions = useDbViewerStore((s) => s.setExtensions);
const setHypertables = useDbViewerStore((s) => s.setHypertables);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set()); const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
@@ -77,8 +91,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
case "sequences": return sequences; case "sequences": return sequences;
case "enums": return enums; case "enums": return enums;
case "extensions": return extensions; case "extensions": return extensions;
case "hypertables": return hypertables;
} }
}, [type, functions, triggers, sequences, enums, extensions]); }, [type, functions, triggers, sequences, enums, extensions, hypertables]);
const setter = useMemo(() => { const setter = useMemo(() => {
switch (type) { switch (type) {
@@ -87,8 +102,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
case "sequences": return setSequences; case "sequences": return setSequences;
case "enums": return setEnums; case "enums": return setEnums;
case "extensions": return setExtensions; case "extensions": return setExtensions;
case "hypertables": return setHypertables;
} }
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]); }, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
// Fetch on mount if not in store // Fetch on mount if not in store
useEffect(() => { useEffect(() => {
@@ -113,6 +129,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
} else if (type === "enums") { } else if (type === "enums") {
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined); const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result); if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
} else if (type === "hypertables") {
const res = await cmd.getHypertables(connectionId, currentSchema ?? undefined);
if (!cancelled) (setHypertables as (v: HypertableInfo[]) => void)(res.available ? res.items : []);
} }
} catch { } catch {
// Silently fail — store remains null, we show the empty state // Silently fail — store remains null, we show the empty state
@@ -123,7 +142,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
fetchData(); fetchData();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]); }, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
const q = search.toLowerCase().trim(); const q = search.toLowerCase().trim();
@@ -318,6 +337,16 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
</> </>
); );
})()} })()}
{type === "hypertables" && (() => {
const h = item as HypertableInfo;
return (
<HypertableDetail
item={h}
onOpenTable={(schema, table) => useDbViewerStore.getState().openTab(schema, table)}
/>
);
})()}
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal"; import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -94,6 +95,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
const toolsMissing = toolStatus && !toolStatus.pg_restore_found; const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !running; const canStart = filePath && confirmed && !running;
const installInstructions = getPlatformInstructions();
return ( return (
<AnimatedModal open={open} onClose={onClose}> <AnimatedModal open={open} onClose={onClose}>
@@ -110,10 +112,19 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
<p className="text-amber-200/80 text-xs"> <p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using: The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p> </p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">Install</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()} {installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
@@ -64,4 +64,41 @@ describe("RestorePage DB-aware", () => {
expect(screen.queryByText(/pg_restore/i)).toBeNull(); expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull(); expect(screen.queryByText(/mysqldump/i)).toBeNull();
}); });
it("renders the MySQL restore form even when no MySQL client is installed", async () => {
// Restore is in-process since Task 7, so a missing mariadb/mysql client must
// no longer hide the form.
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
});
render(<RestorePage connectionId="c4" />);
// The tools check is async; wait until the configuration card is actually up.
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/no MySQL client installation required/i)).toBeTruthy();
});
it("explains what Clean does for MySQL", async () => {
mockConnections.push({ id: "c5", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c5" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/only the objects this file defines/i)).toBeTruthy();
});
it("warns that a failed MySQL restore cannot be rolled back", async () => {
mockConnections.push({ id: "c6", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c6" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/can't be rolled back/i)).toBeTruthy();
});
}); });
+134 -123
View File
@@ -1,8 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react"; import { FolderOpen, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button"; import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
inputClass,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
@@ -10,11 +16,10 @@ import {
detectPgTools, detectPgTools,
pgRestore, pgRestore,
getSchemas, getSchemas,
detectMysqlTools,
mysqlRestore, mysqlRestore,
sqliteRestore, sqliteRestore,
} from "../../lib/commands"; } from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types"; import type { PgToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps { interface RestorePageProps {
connectionId: string; connectionId: string;
@@ -60,7 +65,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
const [schema, setSchema] = useState(""); const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null); const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true); const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]); const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -93,7 +97,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
setCheckingTools(true); setCheckingTools(true);
setConfirmed(false); setConfirmed(false);
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]); setAvailableSchemas([]);
if (isPg) { if (isPg) {
@@ -115,19 +118,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([])); .catch(() => setAvailableSchemas([]));
} else if (isMysql) { } else if (isMysql) {
detectMysqlTools() // MySQL restore runs in-process over the sqlx driver connection, so
.then((status) => setMysqlToolStatus(status)) // there is no client to probe for or wait on here (Backup/Sync
.catch(() => // still probe via detectMysqlTools for mysqldump).
setMysqlToolStatus({ setCheckingTools(false);
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId) getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
@@ -214,17 +208,19 @@ export function RestorePage({ connectionId }: RestorePageProps) {
runWithProgress, runWithProgress,
]); ]);
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
const toolsMissing = isPg const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found ? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false; : false;
const toolsBundled = isPg const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled" ? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false; : false;
const canStart = filePath && confirmed && !isRunning; const canStart = filePath && confirmed && !isRunning;
const installInstructions = getPlatformInstructions(
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
);
const checkingMessage = isPg const checkingMessage = isPg
? "Checking for pg_restore..." ? "Checking for pg_restore..."
@@ -238,118 +234,144 @@ export function RestorePage({ connectionId }: RestorePageProps) {
? "Restore a database from a SQL dump" ? "Restore a database from a SQL dump"
: "Restore a database from a backup file"; : "Restore a database from a backup file";
const cleanDisabled = isPg && format === "plain";
const restoreToolResolvedName =
pgToolStatus?.pg_restore_resolved_name ?? "pg_restore";
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<Upload size={14} className="text-accent" /> <Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span> <span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
{headerDescription} {headerDescription}
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartRestore()}
disabled={!canStart}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<Upload size={14} className="mr-1.5" />
{isRunning ? "Restoring..." : "Start Restore"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_restore not found" : "mysql client not found"} {isPg ? "pg_restore not found" : "mysql client not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
backup/restore operations. Install them using: client tools. This build could not locate or run them, so
a manual install is needed:
</p>
{pgToolStatus?.pg_restore_bundled_available && (
<p className="text-amber-200/80 text-xs leading-relaxed">
A bundled copy is present but could not run — reinstall
Gridline, or install the tools below.
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)} )}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">
Install
</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
{installInstructions}
</pre> </pre>
</div> </div>
</div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} {/* Restore tool */}
<div className="p-5 space-y-5"> {isPg && pgToolStatus?.pg_restore_found && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using{" "}
{pgToolStatus.pg_restore_source === "bundled"
? "bundled"
: "system"}{" "}
{restoreToolResolvedName}
</span>
</FormRow>
)}
{isMysql && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Restores run in-process over the driver connection —
no MySQL client installation required
</span>
</FormRow>
)}
{/* Format (PostgreSQL only) */} {/* Format (PostgreSQL only) */}
{isPg && ( {isPg && (
<div className="space-y-1"> <FormRow label="Format">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select <select
value={format} value={format}
onChange={(e) => onChange={(e) => setFormat(e.target.value)}
setFormat(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="custom"> <option value="custom">Custom Archive</option>
Custom Archive
</option>
<option value="plain">Plain SQL</option> <option value="plain">Plain SQL</option>
<option value="tar">Tarball</option> <option value="tar">Tarball</option>
<option value="directory"> <option value="directory">Directory</option>
Directory
</option>
</select> </select>
</div> </FormRow>
)} )}
{/* Backup file */} {/* Backup file */}
<div className="space-y-1 w-full"> <FormRow label="Backup file">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={filePath} value={filePath}
onChange={(e) => onChange={(e) => setFilePath(e.target.value)}
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump" placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" className={inputClass}
/> />
<button <button
type="button" type="button"
onClick={handlePickFile} onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file" aria-label="Browse for file"
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
> >
<FileSearch size={15} /> <FolderOpen size={13} />
</button> </button>
</div> </FormRow>
</div>
{/* Schema (optional) */} {/* Schema (optional) */}
{(isPg || isMysql) && ( {(isPg || isMysql) && (
<div className="space-y-1"> <FormRow label="Schema">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select <select
value={schema} value={schema}
onChange={(e) => onChange={(e) => setSchema(e.target.value)}
setSchema(e.target.value) className={`${controlClass} mr-3`}
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value="">All schemas</option> <option value="">All schemas</option>
{availableSchemas.map((s) => ( {availableSchemas.map((s) => (
@@ -358,14 +380,16 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
)} )}
<FormSectionHeader label="Options" />
{/* Clean toggle */} {/* Clean toggle */}
{(isPg || isMysql || isSqlite) && ( <FormRow label="Clean">
<label <label
className={`flex items-center gap-2.5 cursor-pointer group ${ className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
isPg && format === "plain" cleanDisabled
? "opacity-40 pointer-events-none" ? "opacity-40 pointer-events-none"
: "" : ""
}`} }`}
@@ -373,52 +397,53 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<input <input
type="checkbox" type="checkbox"
checked={clean} checked={clean}
onChange={(e) => onChange={(e) => setClean(e.target.checked)}
setClean(e.target.checked) disabled={cleanDisabled}
} className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
Clean{" "} {isMysql
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5"> ? "drops only the objects this file defines"
DROP before CREATE : "DROP before CREATE"}
</code> </code>
</span>
</label> </label>
)} </FormRow>
{isPg && format === "plain" && ( {isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3"> <div className="border-b border-border px-4 py-2 text-[11px] text-text-muted/70">
Plain SQL restores run via psql and don't Plain SQL restores run via psql and don't support
support DROP-before-CREATE. Use Custom DROP-before-CREATE. Use Custom Archive for clean
Archive for clean restores. restores.
</p>
)}
</div> </div>
)}
{/* Destructive confirmation */} {/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3"> <div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={confirmed} checked={confirmed}
onChange={(e) => onChange={(e) => setConfirmed(e.target.checked)}
setConfirmed(e.target.checked) className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox" data-testid="restore-confirm-checkbox"
/> />
<span className="text-sm text-red-300/90 leading-relaxed"> <span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on I understand this will overwrite data on the target
the target database. This action cannot database. This action cannot be undone.
be undone.
</span> </span>
</label> </label>
{isMysql && (
<p className="text-[11px] text-red-400/80">
MySQL DDL commits as it runs and can't be rolled
back — a failed restore may leave this database
partially applied.
</p>
)}
</div> </div>
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore" jobType="restore"
@@ -427,23 +452,9 @@ export function RestorePage({ connectionId }: RestorePageProps) {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ResultSetPanels } from "./ResultSetPanels";
import type { MultiQueryResult } from "../../lib/types";
const multi: MultiQueryResult = {
result_sets: [
{ columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 },
{ columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 },
],
notices: [
{ statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 },
{ statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null },
],
execution_time_ms: 12,
};
describe("ResultSetPanels", () => {
it("renders one panel header per result set with row counts", () => {
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
expect(screen.getByText(/Result 1/)).toBeTruthy();
expect(screen.getByText(/Result 2/)).toBeTruthy();
expect(screen.getByText(/2 rows/)).toBeTruthy();
});
it("renders notice cards for DML and errors", () => {
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
expect(screen.getByText(/UPDATE 3/)).toBeTruthy();
expect(screen.getByText(/3 rows affected/)).toBeTruthy();
expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy();
});
it("delegates each set to the grid renderer with its index", () => {
const renderGrid = vi.fn(() => <div />);
render(<ResultSetPanels multi={multi} renderGrid={renderGrid} />);
expect(renderGrid).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,42 @@
import { AlertCircle, Info } from "lucide-react";
import type { MultiQueryResult, QueryResult } from "../../lib/types";
export function ResultSetPanels({
multi,
renderGrid,
}: {
multi: MultiQueryResult;
renderGrid: (set: QueryResult, index: number) => React.ReactNode;
}) {
return (
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
{multi.result_sets.map((set, i) => (
<section key={i} className="border-b border-border flex flex-col min-h-0">
<header className="px-3 py-1.5 text-xs text-text-muted bg-surface-raised border-b border-border flex items-center gap-2 shrink-0">
<span className="font-medium text-text">Result {i + 1}</span>
<span>— {set.total_rows} row{set.total_rows === 1 ? "" : "s"}</span>
</header>
<div className="min-h-0" style={{ height: Math.min(Math.max(set.rows.length * 28 + 32, 160), 480) }}>
{renderGrid(set, i)}
</div>
</section>
))}
{multi.notices.map((n, i) => (
<div
key={i}
role={n.kind === "error" ? "alert" : "status"}
className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border ${
n.kind === "error" ? "text-red-400" : "text-text-muted"
}`}
>
{n.kind === "error" ? <AlertCircle size={13} /> : <Info size={13} />}
<span className="font-medium">Statement {n.statement_index + 1}</span>
<span className="truncate">{n.text}</span>
{n.affected !== null && n.affected !== undefined && (
<span>({n.affected} rows affected)</span>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
vi.mock("../../lib/commands", () => ({
compareSchemas: vi.fn(),
getSchemas: vi.fn().mockResolvedValue(["public"]),
getDatabases: vi.fn().mockResolvedValue(["app"]),
}));
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: unknown) => unknown) =>
sel({ connections: [{ id: "src1", name: "Source PG", db_type: "postgresql" }] }),
}));
import { SchemaDiffPage } from "./SchemaDiffPage";
import { compareSchemas } from "../../lib/commands";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DiffReport } from "../../lib/types";
const report: DiffReport = {
source_label: "public · src1",
target_label: "public · tgt",
truncated: false,
items: [
{
object_type: "table", name: "users", kind: "added", detail: [],
sync_sql: ['CREATE TABLE "users" (id int);'], destructive: false,
},
{
object_type: "table", name: "old", kind: "removed", detail: [],
sync_sql: ["DROP TABLE old;"], destructive: true,
},
],
};
describe("SchemaDiffPage", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().clearChanges();
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue(report);
});
it("runs the diff and renders grouped items with kind badges", async () => {
render(<SchemaDiffPage connectionId="tgt" />);
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
expect(screen.getByText(/added/i)).toBeTruthy();
expect(screen.getByText(/removed/i)).toBeTruthy();
expect(screen.getByText(/Destructive/i)).toBeTruthy();
});
it("stages only safe items into the queue", async () => {
render(<SchemaDiffPage connectionId="tgt" />);
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /stage 1 safe item/i }));
const queue = useDbViewerStore.getState().changesQueue;
expect(queue).toHaveLength(1);
expect(queue[0].sql).toContain("CREATE TABLE");
});
it("shows the identical empty state", async () => {
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue({
source_label: "a", target_label: "b", truncated: false, items: [],
});
render(<SchemaDiffPage connectionId="tgt" />);
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
await waitFor(() => expect(screen.getByText(/identical/i)).toBeTruthy());
});
});
@@ -0,0 +1,276 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands";
import { controlClass } from "./objects/formRow";
import { useConnectionStore } from "../../stores/connectionStore";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DiffItem, DiffReport } from "../../lib/types";
const TYPE_ORDER = ["enum", "sequence", "table", "column", "constraint", "index", "view"];
const KIND_BADGE: Record<string, string> = {
added: "text-emerald-400 border-emerald-400/40",
removed: "text-red-400 border-red-400/40",
changed: "text-amber-400 border-amber-400/40",
};
/** Threshold above which the diff report is rendered through a virtualizer
* (the snapshot cap can reach ~10k cards). Small diffs render plain, which
* keeps jsdom tests deterministic and behavior-neutral below the threshold. */
const VIRTUALIZE_THRESHOLD = 200;
function familyOf(dbType: string): string {
return dbType === "mysql" || dbType === "mariadb" ? "mysql" : dbType;
}
function SchemaDiffItemCard({ item }: { item: DiffItem }) {
return (
<div className="border border-border bg-surface p-3 text-xs">
<div className="flex items-center gap-2">
<span className={`border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
<span className="text-text">{item.name}</span>
{item.destructive && (
<span className="flex items-center gap-1 text-text-muted">
<AlertTriangle size={12} className="text-red-400" /> Destructive — copy only
</span>
)}
</div>
{item.detail.map((d, di) => (
<div key={di} className="mt-1 text-text-muted">
{d.label}: <span className="line-through">{d.old || "∅"}</span> → {d.new || "∅"}
</div>
))}
{item.sync_sql.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-text-muted">SQL</summary>
<pre className="mt-1 bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
className="mt-1 text-text-muted hover:text-text cursor-pointer"
>
Copy
</button>
</details>
)}
</div>
);
}
export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
const connections = useConnectionStore((s) => s.connections);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const currentConnection = connections.find((c) => c.id === connectionId);
const family = familyOf(currentConnection?.db_type ?? "postgresql");
const [sourceId, setSourceId] = useState<string>("");
const [sourceSchemas, setSourceSchemas] = useState<string[]>([]);
const [sourceSchema, setSourceSchema] = useState<string>("");
const [targetSchema, setTargetSchema] = useState<string>(currentSchema ?? "public");
const [report, setReport] = useState<DiffReport | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const sameFamilySources = useMemo(
() => connections.filter((c) => c.id !== connectionId && familyOf(c.db_type) === family),
[connections, connectionId, family],
);
const pickSource = async (id: string) => {
setSourceId(id);
setSourceSchema("");
setSourceSchemas([]);
if (!id) return;
// Set a synchronous default so "Run diff" works before the async schema
// fetch resolves (the source must already be connected). The fetch below
// refines it to the source's actual first schema.
setSourceSchema(family === "mysql" ? "" : family === "sqlite" ? "main" : "public");
try {
// The source must already be connected (opened in the DB viewer) for
// capture — getSchemas/getDatabases and compare_schemas error clearly
// if not. We do not auto-connect here.
const schemas =
family === "mysql"
? await getDatabases(id)
: family === "sqlite"
? ["main"]
: await getSchemas(id);
setSourceSchemas(schemas);
setSourceSchema(schemas[0] ?? "");
} catch (e) {
setError(`Source connection failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
// Auto-select the first same-family source on mount so "Run diff" works
// without manual selection (the source must already be connected).
useEffect(() => {
if (sourceId === "" && sameFamilySources.length > 0) {
void pickSource(sameFamilySources[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sameFamilySources, sourceId]);
const runDiff = async () => {
if (!sourceId || !sourceSchema || !targetSchema) return;
setRunning(true);
setError(null);
try {
const r = await compareSchemas(sourceId, sourceSchema, connectionId, targetSchema);
setReport(r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRunning(false);
}
};
const stageAll = () => {
if (!report) return;
useDbViewerStore.getState().addDiffItems(report.items);
};
const copyAll = () => {
if (!report) return;
const sql = report.items.flatMap((i) => i.sync_sql).join("\n");
void navigator.clipboard?.writeText(sql);
};
const grouped = useMemo(() => {
if (!report) return [] as [string, DiffItem[]][];
const byType = new Map<string, DiffItem[]>();
for (const it of report.items) {
const list = byType.get(it.object_type) ?? [];
list.push(it);
byType.set(it.object_type, list);
}
return [...byType.entries()].sort(
(a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]),
);
}, [report]);
const flattenedItems = report ? report.items : [];
const shouldVirtualize = flattenedItems.length > VIRTUALIZE_THRESHOLD;
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: flattenedItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 96,
overscan: 8,
});
const stageableCount = report
? report.items.filter((i) => !i.destructive && i.sync_sql.length > 0).length
: 0;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="border-b border-border px-4 py-2 shrink-0 space-y-2">
<div className="flex items-center gap-2 text-xs">
<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>
</div>
<div className="flex items-center gap-2 text-xs">
<select
value={sourceId}
onChange={(e) => void pickSource(e.target.value)}
className={`${controlClass} max-w-56`}
aria-label="Source connection"
>
<option value="">Source connection…</option>
{sameFamilySources.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<select
value={sourceSchema}
onChange={(e) => setSourceSchema(e.target.value)}
disabled={!sourceId}
className={`${controlClass} max-w-56 disabled:opacity-50 disabled:cursor-not-allowed`}
aria-label="Source schema"
>
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="text-text-muted">→</span>
<select
value={targetSchema}
onChange={(e) => setTargetSchema(e.target.value)}
className={`${controlClass} max-w-56`}
aria-label="Target schema"
>
<option value={targetSchema}>{targetSchema}</option>
</select>
<button
type="button"
onClick={() => void runDiff()}
disabled={running || !sourceId || !sourceSchema}
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"}
</button>
{report && stageableCount > 0 && (
<button
type="button"
onClick={stageAll}
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"}
</button>
)}
{report && report.items.length > 0 && (
<button
type="button"
onClick={copyAll}
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
</button>
)}
</div>
{report?.truncated && (
<div className="flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle size={13} /> Snapshot capped at 5,000 objects per side — report is truncated.
</div>
)}
{error && <div className="text-xs text-red-400" role="alert">{error}</div>}
</div>
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-4">
{report && report.items.length === 0 && (
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
)}
{shouldVirtualize ? (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vi.start}px)`,
}}
>
<SchemaDiffItemCard item={flattenedItems[vi.index]} />
</div>
))}
</div>
) : (
grouped.map(([type, items]) => (
<section key={type}>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">{type}s</h3>
<div className="space-y-1.5">
{items.map((it) => (
<SchemaDiffItemCard key={`${it.object_type}-${it.name}`} item={it} />
))}
</div>
</section>
))
)}
</div>
</div>
);
}
@@ -99,7 +99,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors"; "w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
return ( return (
<div className="flex items-center gap-1" ref={menuRef}> <div className="relative flex items-center gap-1" ref={menuRef}>
<button <button
aria-label="New schema" aria-label="New schema"
onClick={() => { onClick={() => {
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
)} )}
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg"> <div className="absolute left-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn().mockResolvedValue("/tmp/export.csv"),
}));
vi.mock("../../lib/commands", () => ({
exportQueryToFile: vi.fn().mockResolvedValue({ rows_written: 3 }),
cancelQuery: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn().mockResolvedValue(() => {}),
}));
import { StreamExportDialog } from "./StreamExportDialog";
import { save } from "@tauri-apps/plugin-dialog";
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
describe("StreamExportDialog", () => {
beforeEach(() => vi.clearAllMocks());
it("picks a path, invokes the export, and reports the row count", async () => {
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(save).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /export/i }));
await waitFor(() => expect(exportQueryToFile).toHaveBeenCalledWith("c1", "SELECT 1", "csv", "/tmp/export.csv"));
await waitFor(() => expect(screen.getByText(/3 rows written/i)).toBeTruthy());
});
it("cancels via the existing query-cancel wrapper", async () => {
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /export/i }));
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitFor(() => expect(cancelQuery).toHaveBeenCalledWith("c1"));
});
it("surfaces export errors", async () => {
(exportQueryToFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /export/i }));
await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy());
});
});
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { save } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
interface StreamExportDialogProps {
connectionId: string;
sql: string;
onClose: () => void;
}
const FORMAT_OPTIONS = [
{ value: "csv", label: "CSV" },
{ value: "jsonl", label: "JSON Lines" },
{ value: "json", label: "JSON" },
];
const EXTENSIONS: Record<string, string> = {
csv: "csv",
jsonl: "jsonl",
json: "json",
};
export function StreamExportDialog({ connectionId, sql, onClose }: StreamExportDialogProps) {
const [format, setFormat] = useState("csv");
const [path, setPath] = useState("");
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [done, setDone] = useState(false);
const [rowsWritten, setRowsWritten] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const jobIdRef = useRef<string | null>(null);
// Filter export-progress events to this job so concurrent exports don't
// clobber each other's progress bar.
useEffect(() => {
const unlisten = listen<{ job_id: string; percent: number }>("export-progress", (e) => {
if (e.payload.job_id === jobIdRef.current) {
setProgress(e.payload.percent);
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const handleChooseFile = async () => {
const ext = EXTENSIONS[format] ?? "csv";
const picked = await save({
defaultPath: `export.${ext}`,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (picked) setPath(picked);
};
const handleExport = async () => {
setRunning(true);
setError(null);
setDone(false);
setProgress(0);
try {
let target = path;
if (!target) {
const ext = EXTENSIONS[format] ?? "csv";
target = (await save({
defaultPath: `export.${ext}`,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
})) ?? "";
if (!target) {
setRunning(false);
return;
}
setPath(target);
}
jobIdRef.current = `export-${Date.now()}`;
const res = await exportQueryToFile(connectionId, sql, format, target);
setDone(true);
setProgress(100);
setRowsWritten(res.rows_written);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRunning(false);
}
};
const handleCancel = async () => {
try {
await cancelQuery(connectionId);
} catch {
// ignore — the export may already have finished
}
setRunning(false);
};
return (
<AnimatedModal open onClose={onClose}>
<div className="w-[420px] flex flex-col gap-4">
<h2 className="text-base font-semibold text-text">Export all rows to file</h2>
<div className="flex items-center gap-3">
<Select
label="Format"
value={format}
onChange={setFormat}
options={FORMAT_OPTIONS}
disabled={running}
/>
<Button variant="secondary" onClick={handleChooseFile} disabled={running}>
Choose file…
</Button>
</div>
{path && (
<p className="text-xs text-text-muted break-all" data-testid="export-path">
{path}
</p>
)}
{running && (
<div className="flex flex-col gap-1">
<div className="h-1.5 w-full rounded-full bg-surface-raised overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${Math.max(progress, 2)}%` }}
/>
</div>
<span className="text-xs text-text-muted">{Math.round(progress)}%</span>
</div>
)}
{done && rowsWritten !== null && (
<p className="text-xs text-emerald-400" data-testid="export-done">
{rowsWritten} rows written
</p>
)}
{error && (
<p className="text-xs text-red-400" data-testid="export-error">
{error}
</p>
)}
<div className="flex justify-end gap-2">
{running && (
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
)}
<Button onClick={handleExport} disabled={running}>
Export
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -60,3 +60,49 @@ describe("SyncPage DB-aware", () => {
expect(options).not.toContain("sq1"); expect(options).not.toContain("sq1");
}); });
}); });
describe("SyncPage tool re-check", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
});
it("re-checks MySQL tools with force when Check again is clicked", async () => {
mockConnections.push(
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
render(<SyncPage />);
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
});
it("lists the specific missing MySQL role", async () => {
mockConnections.push(
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: true,
mysqldumpVersion: null,
mysqlVersion: "8.0",
mysqldumpSource: null,
mysqlSource: "system",
});
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
render(<SyncPage />);
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
await waitFor(() => expect(screen.getByText(/mysqldump is missing/i)).toBeTruthy());
});
});
+114 -125
View File
@@ -1,7 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowLeftRight, Database } from "lucide-react"; import { ArrowLeftRight } from "lucide-react";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
@@ -59,16 +63,15 @@ export function SyncPage() {
}, [activeJob, notify]); }, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type // Tool detection: depends on the selected source connection's DB type
useEffect(() => { const checkTools = useCallback(
async (force: boolean) => {
setPgToolStatus(null); setPgToolStatus(null);
setMysqlToolStatus(null); setMysqlToolStatus(null);
setConfirmed(false);
if (isPg) { if (isPg) {
setCheckingTools(true); setCheckingTools(true);
detectPgTools() try {
.then((status) => setPgToolStatus(status)) setPgToolStatus(await detectPgTools(force));
.catch(() => } catch {
setPgToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
@@ -76,14 +79,15 @@ export function SyncPage() {
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null, pg_dump_source: null,
pg_restore_source: null, pg_restore_source: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
}
} else if (isMysql) { } else if (isMysql) {
setCheckingTools(true); setCheckingTools(true);
detectMysqlTools() try {
.then((status) => setMysqlToolStatus(status)) setMysqlToolStatus(await detectMysqlTools(force));
.catch(() => } catch {
setMysqlToolStatus({ setMysqlToolStatus({
mysqldumpFound: false, mysqldumpFound: false,
mysqlFound: false, mysqlFound: false,
@@ -91,11 +95,19 @@ export function SyncPage() {
mysqlVersion: null, mysqlVersion: null,
mysqldumpSource: null, mysqldumpSource: null,
mysqlSource: null, mysqlSource: null,
}), });
) } finally {
.finally(() => setCheckingTools(false)); setCheckingTools(false);
} }
}, [isPg, isMysql]); }
},
[isPg, isMysql],
);
useEffect(() => {
setConfirmed(false);
void checkTools(false);
}, [checkTools]);
// Fetch schemas from the source connection when it changes // Fetch schemas from the source connection when it changes
useEffect(() => { useEffect(() => {
@@ -195,31 +207,59 @@ export function SyncPage() {
? connections.filter((c) => c.db_type === dbType) ? connections.filter((c) => c.db_type === dbType)
: []; : [];
const sourceName =
connections.find((c) => c.id === sourceConnectionId)?.name ??
sourceConnectionId;
const targetName =
connections.find((c) => c.id === targetConnectionId)?.name ??
targetConnectionId;
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Toolbar header */} {/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<ArrowLeftRight size={14} className="text-accent" /> <ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span> <span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
Transfer data between databases via pipe Transfer data between databases via pipe
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{(isPg || isMysql) && (
<button
type="button"
onClick={() => void checkTools(true)}
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
>
Check again
</button>
)}
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartSync()}
disabled={!canStart}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<ArrowLeftRight size={14} className="mr-1.5" />
{isRunning ? "Syncing..." : "Start Sync"}
</button>
)}
</div>
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && checkingMessage && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="px-4 py-3 text-sm text-text-muted">
<p className="text-sm text-text-muted">
{checkingMessage} {checkingMessage}
</p>
</div> </div>
)} )}
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
{isPg {isPg
? "PostgreSQL tools not found" ? "PostgreSQL tools not found"
@@ -242,58 +282,45 @@ export function SyncPage() {
{isMysql && !mysqlToolStatus?.mysqlFound && ( {isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li> <li>mysql is missing.</li>
)} )}
{isMysql && mysqlToolStatus?.mysqlResolvedName && (
<li className="text-amber-200/50">
Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName}
</li>
)}
</ul> </ul>
</div> </div>
)} )}
{!checkingTools && !toolsMissing && ( {!checkingTools && !toolsMissing && (
<> <>
{/* Configuration card */} <FormSectionHeader label="Connections" />
<div className="p-5 space-y-5">
{/* Source & Target connection pickers */} {/* Source connection */}
<div className="grid grid-cols-2 gap-4"> <FormRow label="Source">
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Source
</label>
<select <select
value={sourceConnectionId} value={sourceConnectionId}
onChange={(e) => { onChange={(e) => {
setSourceConnectionId( setSourceConnectionId(e.target.value);
e.target.value,
);
setTargetConnectionId(""); setTargetConnectionId("");
}} }}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" className={`${controlClass} mr-3`}
> >
<option value=""> <option value="">Select source...</option>
Select source...
</option>
{connections.map((c) => ( {connections.map((c) => (
<option <option key={c.id} value={c.id}>
key={c.id}
value={c.id}
>
{c.name} {c.name}
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1"> {/* Target connection — filtered to the source's db type */}
<Database size={11} /> <FormRow label="Target">
Target
</label>
<select <select
value={targetConnectionId} value={targetConnectionId}
onChange={(e) => onChange={(e) => setTargetConnectionId(e.target.value)}
setTargetConnectionId(
e.target.value,
)
}
disabled={!sourceConnectionId} disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
> >
<option value=""> <option value="">
{sourceConnectionId {sourceConnectionId
@@ -304,81 +331,58 @@ export function SyncPage() {
<option <option
key={c.id} key={c.id}
value={c.id} value={c.id}
disabled={ disabled={c.id === sourceConnectionId}
c.id === sourceConnectionId
}
> >
{c.name} {c.name}
</option> </option>
))} ))}
</select> </select>
</div> </FormRow>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Flow indicator */} {/* Flow indicator */}
{sourceConnectionId && targetConnectionId && ( {sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted"> <FormRow label="Flow">
<div className="flex items-center gap-3 px-3 py-2 text-[11px] text-text-muted">
<span className="font-medium text-text"> <span className="font-medium text-text">
{connections.find( {sourceName}
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
</span> </span>
<ArrowLeftRight <ArrowLeftRight
size={12} size={12}
className="text-accent shrink-0" className="text-accent shrink-0"
/> />
<span className="font-medium text-text"> <span className="font-medium text-text">
{connections.find( {targetName}
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
</span> </span>
</div> </div>
</FormRow>
)}
{/* Schema (optional) */}
{(isPg || isMysql) && (
<FormRow label="Schema">
<select
value={schema}
onChange={(e) => setSchema(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</FormRow>
)} )}
</div>
{/* Destructive confirmation */} {/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3"> <div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={confirmed} checked={confirmed}
onChange={(e) => onChange={(e) => setConfirmed(e.target.checked)}
setConfirmed(e.target.checked) className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="sync-confirm-checkbox" data-testid="sync-confirm-checkbox"
/> />
<span className="text-sm text-red-300/90 leading-relaxed"> <span className="text-sm text-red-300/90 leading-relaxed">
@@ -391,7 +395,7 @@ export function SyncPage() {
{/* Progress */} {/* Progress */}
{activeJob && ( {activeJob && (
<div className="px-4"> <div className="px-4 py-3">
<BackupProgress <BackupProgress
progress={activeJob.status === "completed" ? 100 : 50} progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync" jobType="sync"
@@ -400,24 +404,9 @@ export function SyncPage() {
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartSync}
disabled={!canStart}
>
<ArrowLeftRight
size={14}
className="mr-1.5"
/>
{isRunning ? "Syncing..." : "Start Sync"}
</Button>
</div>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
+1 -1
View File
@@ -251,7 +251,7 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2"> <div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
<button <button
type="button" type="button"
onClick={openQueryTab} onClick={() => openQueryTab()}
aria-label="New query tab" aria-label="New query tab"
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer" className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
> >
@@ -325,13 +325,115 @@ describe("TableControls", () => {
expect(screen.queryByLabelText(/insert row/i)).toBeNull(); expect(screen.queryByLabelText(/insert row/i)).toBeNull();
}); });
it("renders the FilterBuilder inside the filter popover", () => { it("stages an insert with only editable columns when Insert Row is clicked", () => {
const cols = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
];
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
renderControls({ columns: cols, rows: [] });
fireEvent.click(screen.getByLabelText(/insert row/i));
const queue = useDbViewerStore.getState().changesQueue;
expect(queue).toHaveLength(1);
expect(queue[0].type).toBe("insert");
expect(queue[0].newData).toEqual({ name: null, created_at: null });
});
it("renders the column filter popover with the + Add filter action", () => {
seed([makeTab()], "tab-1"); seed([makeTab()], "tab-1");
renderControls(); renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i)); fireEvent.click(screen.getByLabelText(/column filters/i));
expect( expect(screen.getByText("Column Filters")).toBeInTheDocument();
screen.getByText("Drop columns here to add filters"), expect(screen.getByText("+ Add filter")).toBeInTheDocument();
).toBeInTheDocument(); });
it("debounces filter value edits so a query only fires after the user stops typing", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onFilterChange = vi.fn();
renderControls({
onFilterChange,
filterRules: [
{ id: "r1", column: "id", operator: "contains", value: "" },
],
});
fireEvent.click(screen.getByLabelText(/column filters/i));
const input = screen.getByPlaceholderText("value");
fireEvent.change(input, { target: { value: "a" } });
fireEvent.change(input, { target: { value: "ab" } });
fireEvent.change(input, { target: { value: "abc" } });
// No commit yet — the debounce hasn't elapsed.
expect(onFilterChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(400);
});
// Only the final value is committed, once.
expect(onFilterChange).toHaveBeenCalledTimes(1);
expect(onFilterChange).toHaveBeenCalledWith([
expect.objectContaining({ value: "abc" }),
]);
});
it("closes the filter popover when the button is clicked while it is open", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.getByText("Column Filters")).toBeInTheDocument();
// Clicking the button while open closes it (plain toggle).
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
});
it("toggles the sort rules popover open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
});
it("toggles the auto-refresh dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.getByText("Off")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.queryByText("Off")).not.toBeInTheDocument();
});
it("toggles the show/hide columns dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.getByText("Visible columns")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
});
it("toggles the export dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
}); });
it("export dropdown includes the Excel option", () => { it("export dropdown includes the Excel option", () => {
@@ -6,7 +6,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
@@ -49,22 +48,28 @@ function DropdownMenu({
open, open,
setOpen, setOpen,
align, align,
wrapRef,
children, children,
}: { }: {
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
align?: "left" | "right"; align?: "left" | "right";
/** When provided, outside-click detection uses this wrapper (which should
* contain both the toggle button and the menu) so the button never fights
* the outside-click handler. */
wrapRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); const container = wrapRef?.current ?? ref.current;
if (container && !container.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
return ( return (
@@ -79,28 +84,84 @@ function DropdownMenu({
); );
} }
/**
* Debounced free-text input for filter values. The value is committed to the
* rules (and thus triggers a server-side refetch) only after the user stops
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
* the final filter value can end up not being applied.
*/
const FILTER_DEBOUNCE_MS = 400;
function DebouncedValueInput({
value,
onCommit,
placeholder,
}: {
value: string;
onCommit: (v: string) => void;
placeholder?: string;
}) {
const [draft, setDraft] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the draft in sync when the committed value changes externally
// (e.g. a rule is replaced or removed while this input is mounted).
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setDraft(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
};
return (
<input
type="text"
value={draft}
onChange={handleChange}
placeholder={placeholder}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
);
}
function FilterModal({ function FilterModal({
columns, columns,
rules, rules,
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: FilterRule[]; rules: FilterRule[];
onChange: (rules: FilterRule[]) => void; onChange: (rules: FilterRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -116,17 +177,13 @@ function FilterModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span> <span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => ( {rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5"> <div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select <select
@@ -152,12 +209,10 @@ function FilterModal({
<option value="notnull">not null</option> <option value="notnull">not null</option>
</select> </select>
{rule.operator !== "null" && rule.operator !== "notnull" && ( {rule.operator !== "null" && rule.operator !== "notnull" && (
<input <DebouncedValueInput
type="text"
value={rule.value} value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })} onCommit={(v) => updateRule(rule.id, { value: v })}
placeholder="value" placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/> />
)} )}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer"> <button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
@@ -182,22 +237,27 @@ function SortModal({
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: SortRule[]; rules: SortRule[];
onChange: (rules: SortRule[]) => void; onChange: (rules: SortRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -213,10 +273,7 @@ function SortModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span> <span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
onClearSelection: () => void; onClearSelection: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const addChange = useDbViewerStore((s) => s.addChange); const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
}; };
return ( return (
<div className="relative"> <div className="relative" ref={wrapRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
<span className="text-xs font-medium">Actions</span> <span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} /> <ChevronDown size={12} />
</button> </button>
<DropdownMenu open={open} setOpen={setOpen} align="right"> <DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
<button <button
type="button" type="button"
onClick={handleCopyJSON} onClick={handleCopyJSON}
@@ -409,6 +467,8 @@ interface TableControlsProps {
isMatview?: boolean; isMatview?: boolean;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */ /** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query"; variant?: "table" | "query";
/** Open the full-result streamed export dialog. */
onExportToFile?: () => void;
} }
export function TableControls({ export function TableControls({
@@ -430,6 +490,7 @@ export function TableControls({
defaultRefreshRate = 0, defaultRefreshRate = 0,
isMatview = false, isMatview = false,
variant = "table", variant = "table",
onExportToFile,
}: TableControlsProps) { }: TableControlsProps) {
const isQuery = variant === "query"; const isQuery = variant === "query";
const notify = useNotificationStore((s) => s.notify); const notify = useNotificationStore((s) => s.notify);
@@ -445,11 +506,16 @@ export function TableControls({
// local state // local state
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const filterWrapRef = useRef<HTMLDivElement>(null);
const [sortOpen, setSortOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false);
const sortWrapRef = useRef<HTMLDivElement>(null);
const [columnMenuOpen, setColumnMenuOpen] = useState(false); const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate); const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false); const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
// Auto-refresh: a self-restarting timer that only counts down while the tab // Auto-refresh: a self-restarting timer that only counts down while the tab
// is idle. Fires a refresh, waits for it to complete (loading → false), // is idle. Fires a refresh, waits for it to complete (loading → false),
@@ -484,8 +550,14 @@ export function TableControls({
}; };
const handleInsertRow = () => { const handleInsertRow = () => {
// Build the insert template from editable columns only. PK / generated /
// identity columns are omitted so their defaults (serial, identity,
// generated) apply on commit — an explicit NULL would bypass them and
// fail with a NOT NULL violation.
const newData: Record<string, unknown> = {}; const newData: Record<string, unknown> = {};
columns.forEach((c) => { newData[c.name] = null; }); columns.forEach((c) => {
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
});
addChange({ addChange({
type: "insert", type: "insert",
schema, schema,
@@ -545,7 +617,7 @@ export function TableControls({
); );
const exportControl = ( const exportControl = (
<div className="relative"> <div className="relative" ref={exportWrapRef}>
<Tooltip content="Export" side="bottom"> <Tooltip content="Export" side="bottom">
<button <button
type="button" type="button"
@@ -556,7 +628,7 @@ export function TableControls({
<Download size={14} /> <Download size={14} />
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}> <DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
{EXPORT_FORMATS.map((fmt) => ( {EXPORT_FORMATS.map((fmt) => (
<button <button
key={fmt.ext} key={fmt.ext}
@@ -567,12 +639,27 @@ export function TableControls({
{fmt.label} {fmt.label}
</button> </button>
))} ))}
{onExportToFile && (
<>
<div className="my-1 h-px bg-border" />
<button
type="button"
onClick={() => {
setExportOpen(false);
onExportToFile();
}}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Export all rows to file…
</button>
</>
)}
</DropdownMenu> </DropdownMenu>
</div> </div>
); );
const columnsControl = ( const columnsControl = (
<div className="relative"> <div className="relative" ref={columnMenuWrapRef}>
<Tooltip content="Show/hide columns" side="bottom"> <Tooltip content="Show/hide columns" side="bottom">
<button <button
type="button" type="button"
@@ -587,6 +674,7 @@ export function TableControls({
open={columnMenuOpen} open={columnMenuOpen}
setOpen={setColumnMenuOpen} setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"} align={isQuery ? "left" : "right"}
wrapRef={columnMenuWrapRef}
> >
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider"> <div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns Visible columns
@@ -658,7 +746,7 @@ export function TableControls({
{refreshControl} {refreshControl}
{/* Auto-refresh */} {/* Auto-refresh */}
<div className="relative"> <div className="relative" ref={autoRefreshWrapRef}>
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom"> <Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button <button
type="button" type="button"
@@ -672,7 +760,7 @@ export function TableControls({
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>} {autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}> <DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
{AUTO_REFRESH_OPTIONS.map((opt) => ( {AUTO_REFRESH_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -692,7 +780,7 @@ export function TableControls({
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Filter */} {/* Filter */}
<div className="relative"> <div className="relative" ref={filterWrapRef}>
<Tooltip content="Column filters" side="bottom"> <Tooltip content="Column filters" side="bottom">
<button <button
type="button" type="button"
@@ -716,11 +804,12 @@ export function TableControls({
onChange={onFilterChange} onChange={onFilterChange}
open={filterOpen} open={filterOpen}
setOpen={setFilterOpen} setOpen={setFilterOpen}
wrapRef={filterWrapRef}
/> />
</div> </div>
{/* Sort */} {/* Sort */}
<div className="relative"> <div className="relative" ref={sortWrapRef}>
<Tooltip content="Sort rules" side="bottom"> <Tooltip content="Sort rules" side="bottom">
<button <button
type="button" type="button"
@@ -744,6 +833,7 @@ export function TableControls({
onChange={onSortChange} onChange={onSortChange}
open={sortOpen} open={sortOpen}
setOpen={setSortOpen} setOpen={setSortOpen}
wrapRef={sortWrapRef}
/> />
</div> </div>
@@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown";
import { BackupPage } from "./BackupPage"; import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage"; import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage"; import { SyncPage } from "./SyncPage";
import { SchemaDiffPage } from "./SchemaDiffPage";
type ToolOperation = "backup" | "restore" | "sync"; type ToolOperation = "backup" | "restore" | "sync" | "diff";
const OPERATION_OPTIONS = [ const OPERATION_OPTIONS = [
{ value: "backup", label: "Backup" }, { value: "backup", label: "Backup" },
{ value: "restore", label: "Restore" }, { value: "restore", label: "Restore" },
{ value: "sync", label: "DB Sync" }, { value: "sync", label: "DB Sync" },
{ value: "diff", label: "Schema Diff" },
]; ];
export function ToolsPage({ connectionId }: { connectionId: string }) { export function ToolsPage({ connectionId }: { connectionId: string }) {
@@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) {
{operation === "backup" && <BackupPage connectionId={connectionId} />} {operation === "backup" && <BackupPage connectionId={connectionId} />}
{operation === "restore" && <RestorePage connectionId={connectionId} />} {operation === "restore" && <RestorePage connectionId={connectionId} />}
{operation === "sync" && <SyncPage />} {operation === "sync" && <SyncPage />}
{operation === "diff" && <SchemaDiffPage connectionId={connectionId} />}
</div> </div>
</div> </div>
); );
@@ -6,6 +6,7 @@ import {
ListChecks, ListChecks,
ListOrdered, ListOrdered,
Puzzle, Puzzle,
Layers,
ShieldCheck, ShieldCheck,
SquareFunction, SquareFunction,
Tag, Tag,
@@ -29,6 +30,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
sequences: "Sequences", sequences: "Sequences",
enums: "Enums", enums: "Enums",
extensions: "Extensions", extensions: "Extensions",
hypertables: "Hypertables",
indexes: "Indexes", indexes: "Indexes",
constraints: "Constraints", constraints: "Constraints",
procedures: "Procedures", procedures: "Procedures",
@@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
sequences: "sequence", sequences: "sequence",
enums: "enum", enums: "enum",
extensions: "extension", extensions: "extension",
hypertables: "hypertable",
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
procedures: "procedure", procedures: "procedure",
@@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />, sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />, enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />, extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />, indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />, constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />, procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
@@ -9,6 +9,7 @@ import type { DbType } from "../../lib/types";
const DB_TYPE_LABELS: Record<DbType, string> = { const DB_TYPE_LABELS: Record<DbType, string> = {
postgresql: "PostgreSQL", postgresql: "PostgreSQL",
mysql: "MySQL", mysql: "MySQL",
mariadb: "MariaDB",
sqlite: "SQLite", sqlite: "SQLite",
redis: "Redis", redis: "Redis",
}; };
@@ -6,16 +6,18 @@ interface Props {
isJson: boolean; isJson: boolean;
isFk: boolean; isFk: boolean;
nullable: boolean; nullable: boolean;
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
isInsertRow?: boolean;
onCopy: () => void; onCopyJson: () => void; onEdit: () => void; onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
onSetNull: () => void; onOpenFk: () => void; onClose: () => void; onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
onViewRow: () => void; onSelectRow: () => void; onViewRow: () => void; onSelectRow: () => void;
} }
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) { export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [ const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true }, { label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson }, { label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true }, { label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true }, { label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable }, { label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable }, { label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk }, { label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia"); expect(onCommit).toHaveBeenCalledWith("Alicia");
}); });
it("renders a native date input for date columns", () => {
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15");
expect(input).toHaveAttribute("type", "date");
});
it("renders a datetime-local input for timestamp columns", () => {
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
expect(input).toHaveAttribute("type", "datetime-local");
});
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
});
it("renders a boolean select with true/false options", () => {
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
const select = screen.getByRole("combobox");
expect(select).toHaveValue("true");
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
});
it("renders a number input for integer columns", () => {
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("step", "1");
});
it("commits null when a nullable cell is emptied", () => { it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />); render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
it("blocks empty commits on non-nullable non-text columns and shows an error", () => { it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />); render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox"); const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled(); expect(onCommit).not.toHaveBeenCalled();
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
it("clears the validation error as soon as the user types again", () => { it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />); render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox"); const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument(); expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
+103 -1
View File
@@ -30,6 +30,45 @@ const controlClass =
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */ /** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
const TEXT_LIKE = ["char", "text", "uuid", "bit"]; const TEXT_LIKE = ["char", "text", "uuid", "bit"];
/** Map a column data type to a "smart" input kind. */
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
const NUMERIC_TYPES = new Set([
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
"double precision", "float", "float4", "float8", "money",
"serial", "bigserial", "smallserial",
]);
const INTEGER_TYPES = new Set([
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
]);
function inputKindForType(dataType: string): InputKind {
const t = dataType.toLowerCase().trim();
if (t === "date") return "date";
if (t.includes("timestamp") || t === "datetime") return "datetime";
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
if (t === "boolean" || t === "bool") return "boolean";
if (NUMERIC_TYPES.has(t)) return "number";
return "text";
}
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
function toDatetimeLocal(v: string): string {
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
return m ? `${m[1]}T${m[2]}` : v;
}
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
function fromDatetimeLocal(v: string): string {
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
return v;
}
export function CellEditor({ export function CellEditor({
initialValue, initialValue,
dataType, dataType,
@@ -40,7 +79,11 @@ export function CellEditor({
onCommit, onCommit,
onCancel, onCancel,
}: CellEditorProps) { }: CellEditorProps) {
const [value, setValue] = useState(initialValue); const kind = inputKindForType(dataType);
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
const [value, setValue] = useState(
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -73,6 +116,8 @@ export function CellEditor({
useEffect(() => { useEffect(() => {
if (enumValues && enumValues.length > 0) { if (enumValues && enumValues.length > 0) {
enumRef.current?.focus(); enumRef.current?.focus();
} else if (kind === "boolean") {
enumRef.current?.focus();
} else if (fkOptions && fkOptions.length > 0) { } else if (fkOptions && fkOptions.length > 0) {
searchRef.current?.focus(); searchRef.current?.focus();
searchRef.current?.select(); searchRef.current?.select();
@@ -258,6 +303,63 @@ export function CellEditor({
); );
} }
if (kind === "boolean") {
return (
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<select
ref={enumRef}
className={controlClass}
value={initialValue}
onChange={(e) => commitRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
<option value="">{nullable ? "NULL" : "—"}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
{errorBubble}
</div>
);
}
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
const inputType = kind === "datetime" ? "datetime-local" : kind;
return (
<div
ref={rootRef}
data-testid="cell-editor"
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
>
<input
ref={ref as any}
type={inputType}
className={inputClass}
value={value}
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{errorBubble}
</div>
);
}
const cls = large const cls = large
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5` ? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass; : inputClass;
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(screen.getByRole("textbox")).toBeInTheDocument();
}); });
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
const rows = [[null, null], ...mockRows];
mockGetTotalSize.mockReturnValue(rows.length * 36);
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
// Non-editable PK column in the insert row shows "auto" instead of NULL.
expect(screen.getByText("auto")).toBeInTheDocument();
// The insert row shows a "+" marker instead of a selection checkbox.
expect(screen.getByTitle("New row")).toBeInTheDocument();
// Real rows still render their checkboxes (2 real rows).
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
});
it("shows staged values passed from the parent + a pending outline", () => { it("shows staged values passed from the parent + a pending outline", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36); mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
+51 -16
View File
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
stagedValues?: Record<string, string | null>; stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */ /** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
pendingKeys?: Record<string, boolean>; pendingKeys?: Record<string, boolean>;
/** Ordered change ids of pending insert rows prepended to `rows`. */
pendingInsertChangeIds?: string[];
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
} }
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
fkPlaceholders, fkPlaceholders,
stagedValues, stagedValues,
pendingKeys, pendingKeys,
pendingInsertChangeIds,
onStageInsertCell,
}: VirtualDataGridProps) { }: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name)); const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const hasColumns = columns.length > 0; const hasColumns = columns.length > 0;
const allSelected = rows.length > 0 && selectedRows.size === rows.length; const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
const realRowCount = rows.length - pendingInsertCount;
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
const selectAllRef = useRef<HTMLInputElement>(null); const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection // Indeterminate state for partial selection
useEffect(() => { useEffect(() => {
if (selectAllRef.current) { if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length; selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
} }
}, [selectedRows.size, rows.length]); }, [selectedRows.size, realRowCount]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json"); const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false }; const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
const editable = isCellEditable(col, tabType, dbType, readOnly); const editable = isCellEditable(col, tabType, dbType, readOnly);
const isInsertRow = rowIndex < pendingInsertCount;
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex; const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const isPending = const isPending =
@@ -307,6 +317,9 @@ export function VirtualDataGrid({
// revert/display stays correct even after repeated edits of the same cell. // revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined; const dbValue = ci >= 0 ? row[ci] : undefined;
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) { if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
if (isInsertRow && insertChangeId) {
onStageInsertCell?.(insertChangeId, col.name, committed);
} else {
const locator = getLocator?.(row) ?? {}; const locator = getLocator?.(row) ?? {};
onStageEdit?.( onStageEdit?.(
cellToUpdateChange({ cellToUpdateChange({
@@ -325,6 +338,7 @@ export function VirtualDataGrid({
}, },
); );
} }
}
setPendingCellKey(cellKey); setPendingCellKey(cellKey);
setEditingCell(null); setEditingCell(null);
}; };
@@ -355,7 +369,9 @@ export function VirtualDataGrid({
} }
style={{ width: getWidth(col.name), flexShrink: 0 }} style={{ width: getWidth(col.name), flexShrink: 0 }}
title={ title={
isNull isInsertRow && !editable
? "auto (default)"
: isNull
? "NULL" ? "NULL"
: isFk : isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}` ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
onCancel={() => setEditingCell(null)} onCancel={() => setEditingCell(null)}
/> />
</div> </div>
) : isInsertRow && !editable ? (
<span className="italic text-text-muted/60">auto</span>
) : isNull ? ( ) : isNull ? (
<span className="italic text-text-muted">NULL</span> <span className="italic text-text-muted">NULL</span>
) : isJson ? ( ) : isJson ? (
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
</div> </div>
); );
}, },
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey], [activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
); );
// ── context menu helpers ────────────────────────────── // ── context menu helpers ──────────────────────────────
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
const ci = columns.findIndex((c) => c.name === column.name); const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci]; const value = rows[row]?.[ci];
if (value === null || value === undefined) return; if (value === null || value === undefined) return;
if (row < pendingInsertCount) {
const changeId = pendingInsertChangeIds?.[row];
if (changeId) onStageInsertCell?.(changeId, column.name, null);
return;
}
const locator = getLocator?.(rows[row]) ?? {}; const locator = getLocator?.(rows[row]) ?? {};
onStageEdit?.( onStageEdit?.(
cellToUpdateChange({ cellToUpdateChange({
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
}, },
); );
}, },
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns], [columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
); );
return ( return (
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
}} }}
> >
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]; const gridIndex = virtualRow.index;
const isSelected = selectedRows.has(virtualRow.index); const isInsertRow = gridIndex < pendingInsertCount;
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
const row = rows[gridIndex];
const isSelected = !isInsertRow && selectedRows.has(realIndex);
return ( return (
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={gridIndex}
className={`flex items-center border-b border-border ${ className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : "" isSelected ? "bg-accent/5" : ""
} hover:bg-surface/50`} } ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@@ -546,7 +572,15 @@ export function VirtualDataGrid({
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
> >
{hasColumns && ( {hasColumns &&
(isInsertRow ? (
<div
style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch"
>
<span className="text-[10px] text-accent/70" title="New row">+</span>
</div>
) : (
<div <div
style={{ width: 40, minWidth: 40 }} style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch" className="flex items-center justify-center border-r border-border self-stretch"
@@ -554,12 +588,12 @@ export function VirtualDataGrid({
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => onToggleRow(virtualRow.index)} onChange={() => onToggleRow(realIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/> />
</div> </div>
)} ))}
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))} {visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
</div> </div>
); );
})} })}
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"} isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
isFk={ctxCol.is_fk && ctxCol.fk_ref != null} isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
nullable={ctxCol.is_nullable} nullable={ctxCol.is_nullable}
isInsertRow={ctxMenu.row < pendingInsertCount}
onCopy={() => { onCopy={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col); void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null); setCtxMenu(null);
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
setCtxMenu(null); setCtxMenu(null);
}} }}
onViewRow={() => { onViewRow={() => {
onOpenRowDetail?.(ctxMenu.row); onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
setCtxMenu(null); setCtxMenu(null);
}} }}
onSelectRow={() => { onSelectRow={() => {
onToggleRow(ctxMenu.row); onToggleRow(ctxMenu.row - pendingInsertCount);
setCtxMenu(null); setCtxMenu(null);
}} }}
onEdit={() => { onEdit={() => {
@@ -7,6 +7,7 @@ import type { DbType } from "../../lib/types";
const DB_TYPES: { value: DbType; label: string }[] = [ const DB_TYPES: { value: DbType; label: string }[] = [
{ value: "postgresql", label: "PostgreSQL" }, { value: "postgresql", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" }, { value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
{ value: "sqlite", label: "SQLite" }, { value: "sqlite", label: "SQLite" },
{ value: "redis", label: "Redis" }, { value: "redis", label: "Redis" },
]; ];
@@ -0,0 +1,24 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { PendingSqlFileBanner } from "./PendingSqlFileBanner";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => {
useUiStore.setState({ pendingSqlFile: null });
});
describe("PendingSqlFileBanner", () => {
it("renders nothing when there is no pending file", () => {
const { container } = render(<PendingSqlFileBanner />);
expect(container).toBeEmptyDOMElement();
});
it("names the file and tells the user to open a connection", () => {
useUiStore.setState({
pendingSqlFile: { path: "/tmp/a.sql", name: "student_db.sql", content: "SELECT 1;" },
});
render(<PendingSqlFileBanner />);
expect(screen.getByText(/student_db\.sql/)).toBeInTheDocument();
expect(screen.getByText(/open a connection/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,34 @@
import { FileCode2, X } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
/**
* Shown when a `.sql` file was opened from the OS but no database connection is
* active. The file is already loaded in memory; picking a connection in the
* Home screen delivers it (see `useSqlFileOpen`).
*/
export function PendingSqlFileBanner() {
const pending = useUiStore((s) => s.pendingSqlFile);
const clear = useUiStore((s) => s.clearPendingSqlFile);
if (!pending) return null;
return (
<div
role="status"
className="flex items-center gap-2 border-b border-border bg-surface-raised px-3 py-1.5 text-xs text-text-muted"
>
<FileCode2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<span className="font-medium text-text">{pending.name}</span> is ready — open a connection
to use it.
</span>
<button
type="button"
onClick={clear}
aria-label="Dismiss pending SQL file"
className="ml-auto flex items-center rounded px-1 py-0.5 hover:bg-surface hover:text-text transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { CopyButton } from "./CopyButton";
function mockClipboard(writeText: (text: string) => Promise<void>) {
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
writable: true,
});
}
/**
* Capture Node-level unhandled rejections. jsdom does not dispatch
* `window.unhandledrejection` for rejections originating in the environment, so
* the process event is the only reliable signal that a failed clipboard write
* escaped the click handler.
*
* `match` narrows the capture to this test's own error. It is required, not a
* convenience: the listener is process-global, and Vitest runs several test
* files in the same worker process — without a filter another file's rejection
* would be recorded here and fail this test. Filtering keeps the assertion
* precise ("did *our* rejection escape?") instead of order-dependent.
*/
interface UnhandledRejectionEmitter {
on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
}
const nodeProcess = (
globalThis as unknown as { process?: UnhandledRejectionEmitter }
).process;
function captureUnhandledRejections(match: string) {
const seen: unknown[] = [];
const listener = (reason: unknown) => {
if (String(reason).includes(match)) seen.push(reason);
};
nodeProcess?.on("unhandledRejection", listener);
return {
seen,
stop: () => nodeProcess?.off("unhandledRejection", listener),
};
}
/** Let queued microtasks and Node's unhandled-rejection check drain. */
async function drain() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
describe("CopyButton", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockClipboard(vi.fn().mockResolvedValue(undefined));
});
afterEach(() => {
vi.useRealTimers();
});
it("copies the given text to the clipboard", async () => {
render(<CopyButton text="brew install mysql-client" />);
fireEvent.click(screen.getByTitle("Copy to clipboard"));
await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"brew install mysql-client",
),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
});
it("shows a transient Copied confirmation", async () => {
render(<CopyButton text="brew install libpq" />);
const btn = screen.getByTitle("Copy to clipboard");
expect(btn.textContent).toBe("Copy");
fireEvent.click(btn);
await waitFor(() => expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied"));
});
it("reverts to Copy after the confirmation delay", async () => {
vi.useFakeTimers();
render(<CopyButton text="brew install libpq" />);
const btn = screen.getByTitle("Copy to clipboard");
fireEvent.click(btn);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied");
await act(async () => {
vi.advanceTimersByTime(1500);
});
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
});
it("stays quiet when the clipboard rejects", async () => {
const rejected = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
mockClipboard(rejected);
const unhandled = captureUnhandledRejections("NotAllowedError");
try {
render(<CopyButton text="brew install libpq" />);
fireEvent.click(screen.getByTitle("Copy to clipboard"));
await waitFor(() => expect(rejected).toHaveBeenCalled());
await drain();
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
expect(unhandled.seen).toEqual([]);
} finally {
unhandled.stop();
}
});
it("stays quiet when the clipboard API is unavailable", async () => {
Object.defineProperty(navigator, "clipboard", {
value: undefined,
configurable: true,
writable: true,
});
const unhandled = captureUnhandledRejections("writeText");
try {
render(<CopyButton text="brew install libpq" />);
expect(() =>
fireEvent.click(screen.getByTitle("Copy to clipboard")),
).not.toThrow();
await drain();
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
expect(unhandled.seen).toEqual([]);
} finally {
unhandled.stop();
}
});
it("uses a custom label for the accessible name", () => {
render(<CopyButton text="x" label="Copy install command" />);
expect(screen.getByTitle("Copy install command")).toBeTruthy();
expect(screen.getByLabelText("Copy install command")).toBeTruthy();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps {
/** Text written to the clipboard. */
text: string;
/** Accessible name; also the tooltip. */
label?: string;
className?: string;
}
/**
* Small inline copy control with a transient "Copied" confirmation.
*
* Clipboard writes are best-effort: `navigator.clipboard` is absent in some
* webview contexts and can reject on permission grounds, so a failure leaves
* the label unchanged instead of surfacing an error the user cannot act on.
*/
export function CopyButton({
text,
label = "Copy to clipboard",
className = "",
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
},
[],
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
return;
}
setCopied(true);
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
}, [text]);
return (
<button
type="button"
onClick={() => void handleCopy()}
aria-label={label}
title={label}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors cursor-pointer text-text-muted hover:text-text ${className}`}
>
{copied ? (
<>
<Check size={11} className="text-emerald-400" />
Copied
</>
) : (
<>
<Copy size={11} />
Copy
</>
)}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More