12 Commits
Author SHA1 Message Date
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
105 changed files with 6953 additions and 802 deletions
+210 -15
View File
@@ -7,22 +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 is Developer-ID signed + notarized via # SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this # signed + notarized in CI when the Apple secrets below are configured (see
# GitHub workflow is NOT the active release path — Gitea Actions uses # README "macOS signing & notarization"). When the secrets are absent the mac
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers # jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
# remain unsigned. # remain unsigned.
#
# Required secrets for signed + notarized macOS builds:
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
# APPLE_CERTIFICATE_PASSWORD password for that .p12
# 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:
@@ -39,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'
@@ -131,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"
@@ -263,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
codesign --force --sign - "$dylib" 2>/dev/null || 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
fi
done done
done done
;; ;;
@@ -309,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.
@@ -318,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 }}
+18 -13
View File
@@ -167,17 +167,19 @@ 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.
### 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). Never push release tags to `origin`/Gitea; it exists only as a fallback copy of the code.
**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`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
@@ -237,19 +239,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 |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) | | DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts | | 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 +293,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 +349,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 +359,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 |
@@ -374,6 +378,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| 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 via resource_dir (v0.7.5) |
| 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/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
| 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
+47 -37
View File
@@ -18,34 +18,34 @@
<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://git.ranio.xyz/adrianbonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-Gitea-4183C4.svg?style=for-the-badge" alt="Open source on Gitea" /></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://git.ranio.xyz/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/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://git.ranio.xyz/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> <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.14**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.1**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | | **macOS** | Intel | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users): **macOS via Homebrew** (recommended for Mac users):
```bash ```bash
# one-time setup (the tap is hosted on Gitea, not GitHub): # one-time setup (the tap is hosted on GitHub):
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline brew trust adrianbonpin/gridline
# install / upgrade: # install / upgrade:
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.14 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.1
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -102,7 +102,11 @@ 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.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://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump. - **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
- **v0.7.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.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.
@@ -310,44 +314,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://git.ranio.xyz/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://git.ranio.xyz/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.
#### macOS signing & notarization #### macOS signing & notarization
Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI). 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.
**One-time setup** (only the repo owner needs to do this): **One-time setup** (only the repo owner needs to do this):
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`. 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`.
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**. 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. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed): 3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
```bash | Secret | Value |
APPLE_API_KEY=<Key ID> | :--------------------------- | :------------------------------------------------------------------------------------------------- |
APPLE_API_ISSUER=<Issuer ID> | `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8 | `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` |
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.) When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
`scripts/release-mac.sh` auto-detects the Developer ID identity from your keychain, signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only). `scripts/release-mac.sh` is the **local fallback** path (used before GitHub Actions took over macOS builds, and still handy for a signed build without burning CI minutes). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning. **Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.14**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.1**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.14_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.1_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.14-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.1-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**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.
@@ -357,11 +365,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.14 git tag v0.7.15
git push origin v0.7.14 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.
@@ -369,7 +379,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://git.ranio.xyz/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
@@ -485,7 +495,7 @@ Highlights of what's next:
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services. Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
- Open an issue via the **[template chooser](https://git.ranio.xyz/adrianbonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer. - Open an issue via the **[template chooser](https://github.com/AdrianBonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions. - Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
--- ---
+77 -54
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/restore via `mysqldump` (system-first) and 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
@@ -177,4 +200,4 @@ Deferred from 0.7.0, slated for this bucket:
- Full PostgreSQL object explorer + schema visualizer - Full PostgreSQL object explorer + schema visualizer
- Backup, restore, and DB-to-DB sync tools - Backup, restore, and DB-to-DB sync tools
- Settings redesign with theme, accent color, editor options - Settings redesign with theme, accent color, editor options
- Built-in **Gridline Demo (SQLite)** database - Built-in **Gridline Demo (SQLite)** database
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.7.14", "version": "0.8.1",
"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": {
+2 -1
View File
@@ -1783,12 +1783,13 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.14" version = "0.8.1"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"deadpool-postgres", "deadpool-postgres",
"futures-util",
"indexmap 2.14.0", "indexmap 2.14.0",
"redis", "redis",
"rusqlite", "rusqlite",
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.14" version = "0.8.1"
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"
@@ -32,6 +32,7 @@ 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"] }
bytes = "1" bytes = "1"
futures-util = "0.3"
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] } sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
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
@@ -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());
}
+78 -5
View File
@@ -5,8 +5,8 @@
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 bytes::BytesMut;
use std::collections::HashMap; use std::collections::HashMap;
@@ -566,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
@@ -997,7 +1005,7 @@ pub(crate) async fn run_mysql_connect(
// 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()),
via_tunnel, via_tunnel,
); );
match decision { match decision {
@@ -1076,7 +1084,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(
@@ -1190,7 +1198,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,
@@ -2632,6 +2640,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,
@@ -3102,6 +3162,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]
@@ -3620,3 +3689,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 {
@@ -25,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)
@@ -37,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)
@@ -71,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))
@@ -111,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))
@@ -135,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))
+2
View File
@@ -8,6 +8,8 @@ pub mod keychain;
pub mod maintenance; pub mod maintenance;
pub mod objects; pub mod objects;
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;
+506 -1
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;
@@ -708,6 +708,507 @@ 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.
const MAX_STATEMENTS: usize = 50;
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());
}
if statements.len() > MAX_STATEMENTS {
return Err(format!(
"Too many statements: {} (max {MAX_STATEMENTS})",
statements.len()
));
}
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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1378,3 +1879,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,134 @@
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;
}
@@ -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()
}
+2
View File
@@ -1,9 +1,11 @@
pub mod introspection; pub mod introspection;
pub mod mysql; pub mod mysql;
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 schema_diff;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+61 -1
View File
@@ -323,4 +323,64 @@ mod tests {
assert_eq!(mysql_default_sort(&["name".into()]), "name"); assert_eq!(mysql_default_sort(&["name".into()]), "name");
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('\'', "''")
)
}
+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()));
}
+212
View File
@@ -0,0 +1,212 @@
//! 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;
while i < chars.len() {
let c = chars[i];
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 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;
}
}
';' => {
if has_executable_text(&current) {
stmts.push(current.trim().to_string());
}
current.clear();
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('*') => {
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;
}
}
#[cfg(test)]
#[path = "sql_split.test.rs"]
mod sql_split_tests;
@@ -0,0 +1,86 @@
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());
}
+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)
+308 -110
View File
@@ -1,117 +1,45 @@
//! Keychain ACL normalization for stored secrets (macOS). //! Keychain ACL normalization and repair for stored secrets (macOS).
//! //!
//! # Why this exists //! # Why this exists
//! //!
//! Generic-password items stored via the legacy keychain API get a default ACL //! Generic-password items stored via the legacy keychain API are guarded by an
//! that grants access to "the application that created the item". When a Tauri //! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so //! trusted application as **the path of the executable** — not a code
//! the identity differs on every build), and the new binary no longer matches //! requirement — so the item stays readable across rebuilds *as long as the
//! the item's ACL — macOS then shows the "Gridline wants to access your //! binary keeps living at that path*. A properly signed bundle is recorded as a
//! keychain" prompt. //! code requirement plus a `cdhash` partition list, which is stable across
//! versions and even moves.
//! //!
//! The fix: after storing a secret, replace the item's ACL with an explicit //! Two failure modes follow from that:
//! `SecAccess` whose trusted-application list contains exactly *this app*,
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
//! process's code-signature requirement. For a Developer ID-signed release
//! build that requirement is stable across versions (anchored to the signing
//! certificate), so items stay accessible without prompts through upgrades.
//! //!
//! # Prompt behavior //! 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.
//! //!
//! - Item created by this build → the ACL set is silent. //! Pinning only after writes (`after_store`) can't repair either case: writing
//! - Item created by an older/differently-signed build → macOS shows the //! to an item we can't access already needs the same authorization, so the ACL
//! standard one-time authorization dialog; "Always Allow" replaces the ACL //! is never corrected. This module therefore also *repairs before reads*.
//! and the prompt never reappears (the same dialog the app would show on the
//! first read anyway — this just makes the result permanent and clean).
//! //!
//! All operations are best-effort: `after_store` swallows errors so keychain //! # How the repair works
//! writes never fail because of ACL normalization. //!
//! [`ensure_access`] first asks, **without prompting**, whether this process is
/// Best-effort ACL normalization after a successful keychain write. //! 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
/// Resolves the service name the same way `tauri-plugin-keyring-store` does //! is exactly this app. That call is what macOS gates behind the authorization
/// (the Tauri bundle identifier) and never fails the caller's operation. //! dialog — so a stale item costs the user **one** authorization, and every
pub fn after_store(app: &tauri::AppHandle, account: &str) { //! later access (including after future rebuilds at the same path) is silent.
let service = app.config().identifier.clone(); //! Items that are already trusted are left alone, so the ACL never accumulates
if let Err(e) = ensure_item_acl(&service, account) { //! duplicate entries.
eprintln!("[keychain_acl] skipped for {account}: {e}"); //!
} //! All operations are best-effort: they log to stderr and never fail the
} //! caller's keychain read/write.
/// Grant this app (by its code-signature requirement) access to a keychain item.
#[cfg(target_os = "macos")]
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
use core_foundation::array::CFArray;
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::CFString;
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
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 found = ItemSearchOptions::new()
.class(ItemClass::generic_password())
.service(service)
.account(account)
.load_refs(true)
.load_data(false)
.load_attributes(false)
.search()
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
let item = match found.first() {
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
// No item (yet) or an unexpected result shape — nothing to normalize.
_ => 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 code
// signature requirement.
unsafe {
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = 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 = CFArray::from_CFTypes(&[trusted_app]);
let descriptor = CFString::new("Gridline");
let mut access_ref: SecAccessRef = std::ptr::null_mut();
cvt(SecAccessCreate(
descriptor.as_concrete_TypeRef() as CFTypeRef,
trusted_list.as_concrete_TypeRef() as CFTypeRef,
&mut access_ref,
))?;
let access = SecAccess::wrap_under_create_rule(access_ref);
// Replace the item's ACL. Silent when we already own the item;
// one-time authorization dialog when the item was created by a
// differently-signed build.
cvt(SecKeychainItemSetAccess(
item.as_concrete_TypeRef(),
access.as_concrete_TypeRef(),
))
}
}
/// 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(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos_ffi { mod macos_ffi {
use core_foundation::base::{CFTypeRef, CFTypeID}; use core_foundation::base::{CFTypeID, CFTypeRef};
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the /// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
/// security-framework crate exposes no high-level API for it. /// security-framework crate exposes no high-level API for it.
@@ -140,11 +68,29 @@ mod macos_ffi {
path: CFTypeRef, path: CFTypeRef,
app: *mut SecTrustedApplicationRef, app: *mut SecTrustedApplicationRef,
) -> i32; ) -> i32;
pub fn SecTrustedApplicationCopyData(
app: CFTypeRef,
data: *mut core_foundation::data::CFDataRef,
) -> i32;
pub fn SecAccessCreate( pub fn SecAccessCreate(
descriptor: CFTypeRef, descriptor: CFTypeRef,
trustedlist: CFTypeRef, trustedlist: core_foundation::array::CFArrayRef,
access: *mut security_framework_sys::base::SecAccessRef, access: *mut security_framework_sys::base::SecAccessRef,
) -> i32; ) -> 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( pub fn SecKeychainItemSetAccess(
item: security_framework_sys::base::SecKeychainItemRef, item: security_framework_sys::base::SecKeychainItemRef,
access: security_framework_sys::base::SecAccessRef, access: security_framework_sys::base::SecAccessRef,
@@ -152,21 +98,260 @@ mod macos_ffi {
} }
} }
#[cfg(target_os = "macos")]
use core_foundation::base::TCFType;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use macos_ffi::{ use macos_ffi::{
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication, SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
SecTrustedApplicationCreateFromPath, 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"))] #[cfg(all(test, target_os = "macos"))]
mod tests { mod tests {
use super::*; use super::*;
use security_framework::os::macos::keychain::SecKeychain; use security_framework::os::macos::keychain::SecKeychain;
use security_framework::os::macos::passwords::find_generic_password; use security_framework::os::macos::passwords::find_generic_password;
/// Real-keychain integration test: creates a generic-password item the /// A missing item is a silent no-op — no lookup errors, no prompts. This is
/// same way the keyring store does, normalizes its ACL, verifies the /// the path taken for every connection that never stored a password, so it
/// secret is still readable, and cleans up. /// 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 /// Ignored by default (`cargo test`) so CI/dev runs never touch the login
/// keychain. Run explicitly with: /// keychain. Run explicitly with:
@@ -184,8 +369,21 @@ mod tests {
.set_generic_password(service, &account, b"test-secret") .set_generic_password(service, &account, b"test-secret")
.expect("create test item"); .expect("create test item");
// ACL normalization must succeed on an item we just created. // 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"); 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. // The item must remain readable with the correct secret.
let (password, item) = let (password, item) =
+5 -1
View File
@@ -24,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/
@@ -136,6 +136,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,
@@ -172,7 +173,10 @@ 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,
query::get_query_history, query::get_query_history,
query::clear_query_history, query::clear_query_history,
+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::*;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.14", "version": "0.8.1",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -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 =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}));
} else {
setManagedPreset(null); setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value })); setForm((prev) => ({ ...prev, connection_string: value }));
return;
} }
setManagedPreset(providerForParsed(parsed));
setForm((prev) => ({
...prev,
connection_string: value,
...parsedFormPatch(parsed, prev),
}));
}, []); }, []);
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;
} }
@@ -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"} <VirtualDataGrid {...gridPropsForQueryTab} />
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,
),
);
},
);
}}
/>
</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({ updateConnection, loadAll, folders: [], tags: [] }), (sel: (s: any) => any) =>
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>
@@ -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,275 @@
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 { 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="rounded-lg border border-border bg-surface p-2 text-xs">
<div className="flex items-center gap-2">
<span className={`rounded border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
<span className="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 rounded 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 flex-1 min-h-0 flex-col overflow-hidden">
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0 space-y-2">
<div className="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="rounded-lg bg-surface-raised border border-border px-2 py-1"
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="rounded-lg bg-surface-raised border border-border px-2 py-1 disabled:opacity-50"
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="rounded-lg bg-surface-raised border border-border px-2 py-1"
aria-label="Target schema"
>
<option value={targetSchema}>{targetSchema}</option>
</select>
<button
type="button"
onClick={() => void runDiff()}
disabled={running || !sourceId || !sourceSchema}
className="flex items-center gap-1 rounded-lg bg-accent px-2.5 py-1 text-accent-foreground disabled:opacity-50 cursor-pointer"
>
<Play size={13} /> {running ? "Diffing…" : "Run diff"}
</button>
{report && stageableCount > 0 && (
<button
type="button"
onClick={stageAll}
className="rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
>
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
</button>
)}
{report && report.items.length > 0 && (
<button
type="button"
onClick={copyAll}
className="flex items-center gap-1 rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
>
<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-3 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>
);
}
@@ -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 })));
+84 -49
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,23 +317,27 @@ 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)) {
const locator = getLocator?.(row) ?? {}; if (isInsertRow && insertChangeId) {
onStageEdit?.( onStageInsertCell?.(insertChangeId, col.name, committed);
cellToUpdateChange({ } else {
schema, const locator = getLocator?.(row) ?? {};
table, onStageEdit?.(
primaryKey: locator, cellToUpdateChange({
oldData: { [col.name]: dbValue }, schema,
newData: { [col.name]: committed }, table,
}) as { primaryKey: locator,
type: "update"; oldData: { [col.name]: dbValue },
schema: string; newData: { [col.name]: committed },
table: string; }) as {
primaryKey: Record<string, unknown>; type: "update";
oldData: Record<string, unknown>; schema: string;
newData: Record<string, unknown>; table: string;
}, primaryKey: Record<string, unknown>;
); oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
}
} }
setPendingCellKey(cellKey); setPendingCellKey(cellKey);
setEditingCell(null); setEditingCell(null);
@@ -355,13 +369,15 @@ export function VirtualDataGrid({
} }
style={{ width: getWidth(col.name), flexShrink: 0 }} style={{ width: getWidth(col.name), flexShrink: 0 }}
title={ title={
isNull isInsertRow && !editable
? "NULL" ? "auto (default)"
: isFk : isNull
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}` ? "NULL"
: isJson : isFk
? "Click to view JSON" ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
: String(displayCell) : isJson
? "Click to view JSON"
: String(displayCell)
} }
onClick={(e) => { onClick={(e) => {
setActiveCell({ row: rowIndex, col: colIndex }); setActiveCell({ row: rowIndex, col: colIndex });
@@ -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,20 +572,28 @@ export function VirtualDataGrid({
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
> >
{hasColumns && ( {hasColumns &&
<div (isInsertRow ? (
style={{ width: 40, minWidth: 40 }} <div
className="flex items-center justify-center border-r border-border self-stretch" style={{ width: 40, minWidth: 40 }}
> className="flex items-center justify-center border-r border-border self-stretch"
<input >
type="checkbox" <span className="text-[10px] text-accent/70" title="New row">+</span>
checked={isSelected} </div>
onChange={() => onToggleRow(virtualRow.index)} ) : (
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" <div
/> style={{ width: 40, minWidth: 40 }}
</div> className="flex items-center justify-center border-r border-border self-stretch"
)} >
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))} <input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(realIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
))}
{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" },
]; ];
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.14)", () => { describe("tauri bundle config (v0.8.1)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.14", () => { it("version is 0.8.1", () => {
expect(tauriConf.version).toBe("0.7.14"); expect(tauriConf.version).toBe("0.8.1");
}); });
}); });
+4
View File
@@ -45,6 +45,10 @@ describe("buildChangeSql", () => {
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any; const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')'); expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
}); });
it("insert with no columns falls back to DEFAULT VALUES", () => {
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: {} } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" DEFAULT VALUES');
});
it("update", () => { it("update", () => {
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any; const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5'); expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
+1
View File
@@ -33,6 +33,7 @@ export function buildChangeSql(item: QueueItem): string {
case "insert": { case "insert": {
const data = item.newData ?? {}; const data = item.newData ?? {};
const cols = Object.keys(data); const cols = Object.keys(data);
if (cols.length === 0) return `INSERT INTO ${t} DEFAULT VALUES`;
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`; return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
} }
case "update": { case "update": {
+38 -2
View File
@@ -1,7 +1,8 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
import type { DiffReport } from "./types";
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
export { type ObjectKind, type DdlParams }; export { type ObjectKind, type DdlParams };
@@ -226,6 +227,13 @@ export async function getExtensions(connectionId: string): Promise<ExtensionInfo
return invoke<ExtensionInfo[]>("get_extensions", { connectionId }); return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
} }
export async function getHypertables(
connectionId: string,
schema?: string,
): Promise<HypertableListResponse> {
return invoke<HypertableListResponse>("get_hypertables", { connectionId, schema });
}
export async function getSchemaGraph( export async function getSchemaGraph(
connectionId: string, connectionId: string,
schema?: string, schema?: string,
@@ -279,6 +287,24 @@ export async function executeQuery(
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize }); return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
} }
export async function executeQueryMulti(
connectionId: string,
query: string,
page: number,
pageSize: number,
): Promise<MultiQueryResult> {
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
}
export async function exportQueryToFile(
connectionId: string,
sql: string,
format: string,
path: string,
): Promise<{ rows_written: number; path: string }> {
return invoke("export_query_to_file", { connectionId, sql, format, path });
}
export async function getQueryHistory( export async function getQueryHistory(
connectionId: string | null, connectionId: string | null,
limit: number, limit: number,
@@ -393,4 +419,14 @@ export async function getTablespaces(connectionId: string): Promise<TablespaceIn
} }
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> { export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns }); return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
} }
export async function compareSchemas(
sourceConnectionId: string,
sourceSchema: string,
targetConnectionId: string,
targetSchema: string,
): Promise<DiffReport> {
return invoke<DiffReport>("compare_schemas", {
sourceConnectionId, sourceSchema, targetConnectionId, targetSchema,
});
}
+131 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString"; import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
import type { Connection } from "./types"; import type { Connection } from "./types";
function makeConn(overrides: Partial<Connection> = {}): Connection { function makeConn(overrides: Partial<Connection> = {}): Connection {
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "mydb", database: "mydb",
ssl_mode: null,
}); });
}); });
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
username: "root", username: "root",
password: null, password: null,
database: "app", database: "app",
ssl_mode: null,
}); });
}); });
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "0", database: "0",
ssl_mode: null,
}); });
}); });
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: null,
}); });
}); });
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: "require",
}); });
}); });
it("returns null for an empty string", () => { it("returns null for an empty string", () => {
expect(parseConnectionString("")).toBeNull(); expect(parseConnectionString("")).toBeNull();
}); });
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
const result = parseConnectionString(
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
);
expect(result).toMatchObject({
db_type: "postgresql",
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
username: "neondb_owner",
password: "pw",
database: "neondb",
ssl_mode: "require",
});
});
it("lifts every supported sslmode value", () => {
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
expect(mode("disable")).toBe("disable");
expect(mode("require")).toBe("require");
expect(mode("verify-ca")).toBe("verify-ca");
expect(mode("verify-full")).toBe("verify-full");
expect(mode("VERIFY-FULL")).toBe("verify-full");
expect(mode("verify_full")).toBe("verify-full");
});
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
});
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
});
it("returns null for a non-URL string", () => { it("returns null for a non-URL string", () => {
expect(parseConnectionString("hello world")).toBeNull(); expect(parseConnectionString("hello world")).toBeNull();
@@ -122,6 +167,33 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: null,
});
});
it("parses mariadb:// with the 3306 default port", () => {
const result = parseConnectionString("mariadb://root:pw@localhost/app");
expect(result).toEqual({
db_type: "mariadb",
host: "localhost",
port: 3306,
username: "root",
password: "pw",
database: "app",
ssl_mode: null,
});
});
it("keeps mysql:// mapped to the mysql db_type", () => {
const result = parseConnectionString("mysql://root:pw@localhost:3307/app");
expect(result).toEqual({
db_type: "mysql",
host: "localhost",
port: 3307,
username: "root",
password: "pw",
database: "app",
ssl_mode: null,
}); });
}); });
@@ -134,6 +206,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
}); });
@@ -156,6 +229,33 @@ describe("looksLikeConnectionString", () => {
}); });
}); });
describe("suggestConnectionLabel", () => {
const label = (url: string) => {
const parsed = parseConnectionString(url)!;
return suggestConnectionLabel(parsed);
};
it("prefers the database name", () => {
expect(
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
).toBe("neondb");
});
it("falls back to the host when there is no database", () => {
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
});
it("uses the file stem for SQLite paths", () => {
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
expect(label("file:///tmp/notes.db")).toBe("notes");
});
it("caps the label at the 100-char validation limit", () => {
const host = `${"a".repeat(120)}.example.com`;
expect(label(`postgresql://u@${host}/`).length).toBe(100);
});
});
describe("detectProviderFromHost", () => { describe("detectProviderFromHost", () => {
it("detects Supabase by host suffix", () => { it("detects Supabase by host suffix", () => {
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase"); expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
@@ -170,6 +270,19 @@ describe("detectProviderFromHost", () => {
}); });
}); });
describe("detectProviderFromHost", () => {
it("detects PlanetScale access hosts", () => {
expect(detectProviderFromHost("xxxx.us-east-2.psdb.cloud")).toBe("planetscale");
expect(detectProviderFromHost("aws.connect.psdb.cloud")).toBe("planetscale");
});
it("still detects supabase and neon", () => {
expect(detectProviderFromHost("db.abc.supabase.co")).toBe("supabase");
expect(detectProviderFromHost("ep-1-pooler.eu.aws.neon.tech")).toBe("neon");
expect(detectProviderFromHost("example.com")).toBeNull();
});
});
describe("buildConnectionUrl", () => { describe("buildConnectionUrl", () => {
it("builds a PostgreSQL URL with password and sslmode", () => { it("builds a PostgreSQL URL with password and sslmode", () => {
const url = buildConnectionUrl( const url = buildConnectionUrl(
@@ -212,6 +325,22 @@ describe("buildConnectionUrl", () => {
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app"); expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
}); });
it("emits mariadb:// for a mariadb connection", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
"pw",
);
expect(url).toBe("mariadb://root:pw@127.0.0.1:3306/app");
});
it("omits the password in the mariadb no-password variant", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
null,
);
expect(url).toBe("mariadb://root@127.0.0.1:3306/app");
});
it("builds a Redis URL with password but no username", () => { it("builds a Redis URL with password but no username", () => {
const url = buildConnectionUrl( const url = buildConnectionUrl(
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }), makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
@@ -238,6 +367,7 @@ describe("buildConnectionUrl", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "mydb", database: "mydb",
ssl_mode: null,
}); });
}); });
}); });
+77 -1
View File
@@ -7,12 +7,60 @@ export interface ParsedConnectionString {
username: string | null; username: string | null;
password: string | null; password: string | null;
database: string | null; database: string | null;
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
* Supabase, …) working — they ship with `?sslmode=require` and the server
* rejects a plaintext connection with "connection is insecure". */
ssl_mode: SslMode | null;
}
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
/** Alternate spellings used by other clients, mapped onto our four modes:
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
* plaintext fallback, which our mode set cannot express, so they return
* `null` and the caller keeps its current/default mode. */
const SSL_MODE_ALIASES: Record<string, SslMode> = {
disable: "disable",
disabled: "disable",
require: "require",
required: "require",
"verify-ca": "verify-ca",
"verify-full": "verify-full",
// MySQL's strongest mode: chain + hostname verification.
"verify-identity": "verify-full",
};
/**
* Extract the TLS mode from a connection URL's query string.
*
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
* return `null` so the caller keeps its current/default mode rather than
* silently downgrading to plaintext.
*/
function parseSslMode(url: URL): SslMode | null {
let raw: string | null = null;
for (const [key, value] of url.searchParams) {
const k = key.toLowerCase();
if (k === "sslmode" || k === "ssl-mode") {
raw = value;
break;
}
}
if (!raw) return null;
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
return SSL_MODE_ALIASES[normalized] ?? null;
} }
const DB_PROTOCOLS: Record<string, DbType> = { const DB_PROTOCOLS: Record<string, DbType> = {
"postgresql:": "postgresql", "postgresql:": "postgresql",
"postgres:": "postgresql", "postgres:": "postgresql",
"mysql:": "mysql", "mysql:": "mysql",
"mariadb:": "mariadb",
"sqlite:": "sqlite", "sqlite:": "sqlite",
"file:": "sqlite", "file:": "sqlite",
"redis:": "redis", "redis:": "redis",
@@ -22,6 +70,7 @@ const DB_PROTOCOLS: Record<string, DbType> = {
const DEFAULT_PORTS: Record<DbType, number | null> = { const DEFAULT_PORTS: Record<DbType, number | null> = {
postgresql: 5432, postgresql: 5432,
mysql: 3306, mysql: 3306,
mariadb: 3306,
sqlite: null, sqlite: null,
redis: 6379, redis: 6379,
}; };
@@ -66,6 +115,9 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
username, username,
password, password,
database, database,
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
? parseSslMode(url)
: null,
}; };
} }
@@ -83,13 +135,35 @@ export function looksLikeConnectionString(input: string): boolean {
/** Detect a managed-PostgreSQL provider from a connection host, purely for /** Detect a managed-PostgreSQL provider from a connection host, purely for
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is * UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
* unaffected (both presets persist as `postgresql`). */ * unaffected (both presets persist as `postgresql`). */
export function detectProviderFromHost(host: string): "supabase" | "neon" | null { export function detectProviderFromHost(
host: string,
): "supabase" | "neon" | "planetscale" | null {
const h = host.toLowerCase(); const h = host.toLowerCase();
if (h.endsWith(".supabase.co")) return "supabase"; if (h.endsWith(".supabase.co")) return "supabase";
if (h.endsWith(".neon.tech")) return "neon"; if (h.endsWith(".neon.tech")) return "neon";
if (h.endsWith(".psdb.cloud")) return "planetscale";
return null; return null;
} }
/**
* Suggest a connection label for a freshly pasted connection string, so the
* connection can be saved without typing a name first.
*
* Preference order: the database name, then (for SQLite) the file stem, then
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
* rejects longer names.
*/
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
let label: string;
if (parsed.db_type === "sqlite") {
const file = parsed.host.split(/[\\/]/).pop() ?? "";
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
} else {
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
}
return label.trim().slice(0, 100);
}
/** /**
* Build a connection URL string from a saved connection, for copying into * Build a connection URL string from a saved connection, for copying into
* env vars / other tools. `password` is the decrypted keychain value (or * env vars / other tools. `password` is the decrypted keychain value (or
@@ -127,6 +201,8 @@ export function buildConnectionUrl(conn: Connection, password: string | null): s
} }
case "mysql": case "mysql":
return `mysql://${auth}${conn.host}${port}${db}`; return `mysql://${auth}${conn.host}${port}${db}`;
case "mariadb":
return `mariadb://${auth}${conn.host}${port}${db}`;
case "sqlite": case "sqlite":
// host stores the file path; `sqlite:///abs/path` round-trips through // host stores the file path; `sqlite:///abs/path` round-trips through
// parseConnectionString (pathname → host). // parseConnectionString (pathname → host).
+10
View File
@@ -23,6 +23,16 @@ describe("dbCapabilities", () => {
expect(c.visualizer).toBe(false); expect(c.visualizer).toBe(false);
}); });
it("gives MariaDB the same capability set as MySQL", () => {
expect(DB_CAPABILITIES.mariadb).toEqual(DB_CAPABILITIES.mysql);
});
it("covers exactly the five DbTypes", () => {
expect(Object.keys(DB_CAPABILITIES).sort()).toEqual(
["mariadb", "mysql", "postgresql", "redis", "sqlite"],
);
});
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => { it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
const c = DB_CAPABILITIES.sqlite; const c = DB_CAPABILITIES.sqlite;
expect(c.explorer).toBe(true); expect(c.explorer).toBe(true);
+1
View File
@@ -36,6 +36,7 @@ const ALL_FALSE: DbCapabilities = {
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = { export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true }, postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true }, mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
mariadb: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true }, sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
redis: { ...ALL_FALSE }, redis: { ...ALL_FALSE },
}; };
+8 -2
View File
@@ -1,10 +1,11 @@
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons";
import type { DbType } from "./types"; import type { DbType } from "./types";
// Brand colors from simple-icons // Brand colors from simple-icons
const DB_COLORS: Record<DbType, string> = { const DB_COLORS: Record<DbType, string> = {
postgresql: `#${siPostgresql.hex}`, postgresql: `#${siPostgresql.hex}`,
mysql: `#${siMysql.hex}`, mysql: `#${siMysql.hex}`,
mariadb: `#${siMariadb.hex}`,
redis: `#${siRedis.hex}`, redis: `#${siRedis.hex}`,
sqlite: `#${siSqlite.hex}`, sqlite: `#${siSqlite.hex}`,
}; };
@@ -13,6 +14,7 @@ const DB_COLORS: Record<DbType, string> = {
const DB_PATHS: Record<DbType, string> = { const DB_PATHS: Record<DbType, string> = {
postgresql: siPostgresql.path, postgresql: siPostgresql.path,
mysql: siMysql.path, mysql: siMysql.path,
mariadb: siMariadb.path,
redis: siRedis.path, redis: siRedis.path,
sqlite: siSqlite.path, sqlite: siSqlite.path,
}; };
@@ -20,6 +22,7 @@ const DB_PATHS: Record<DbType, string> = {
export const DB_LABELS: Record<DbType, string> = { export const DB_LABELS: Record<DbType, string> = {
postgresql: "PostgreSQL", postgresql: "PostgreSQL",
mysql: "MySQL", mysql: "MySQL",
mariadb: "MariaDB",
redis: "Redis", redis: "Redis",
sqlite: "SQLite", sqlite: "SQLite",
}; };
@@ -54,22 +57,25 @@ export function DbIcon({ type, size = 20, className }: DbIconProps) {
export const DB_ICONS: Record<DbType, string> = { export const DB_ICONS: Record<DbType, string> = {
postgresql: "🐘", postgresql: "🐘",
mysql: "🐬", mysql: "🐬",
mariadb: "🦭",
redis: "⚡", redis: "⚡",
sqlite: "🗄️", sqlite: "🗄️",
}; };
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ────────── // ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
// These are NOT DbType values; connections persist as db_type="postgresql". // These are NOT DbType values; connections persist as db_type="postgresql".
type ProviderIconId = "supabase" | "neon"; type ProviderIconId = "supabase" | "neon" | "planetscale";
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = { const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path }, supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
neon: { hex: `#${siNeon.hex}`, path: siNeon.path }, neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path },
}; };
export const PROVIDER_LABELS: Record<ProviderIconId, string> = { export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
supabase: "Supabase", supabase: "Supabase",
neon: "NeonDB", neon: "NeonDB",
planetscale: "PlanetScale",
}; };
interface ProviderIconProps { interface ProviderIconProps {
+11 -7
View File
@@ -3,8 +3,9 @@ import { describe, it, expect } from "vitest";
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean. // test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
import agents from "../../../AGENTS.md?raw"; import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
import landing from "../../../www/public/landing.js?raw";
describe("v0.7.14 docs coverage", () => { describe("v0.8.1 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +25,8 @@ describe("v0.7.14 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.14", () => { it("README declares v0.8.1", () => {
expect(readme).toContain("0.7.14"); expect(readme).toContain("0.8.1");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -64,9 +65,12 @@ describe("v0.7.14 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/); expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/); expect(agents).toMatch(/Settings export\/import \| ✅/);
}); });
it("README links to v0.7.14 assets in both download tables", () => { it("README links to v0.8.1 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.14/"); expect(readme).toContain("releases/download/v0.8.1/");
expect(readme).toContain("Gridline_0.7.14_aarch64.dmg"); expect(readme).toContain("Gridline_0.8.1_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.14-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.8.1-1.x86_64.rpm");
});
it("landing.js platform download links point to v0.8.1", () => {
expect(landing).toContain('const version = "0.8.1"');
}); });
}); });
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { exportData } from "./exportData"; import { exportData, csvCell, qualifiedTableSql } from "./exportData";
import type { ColumnInfo } from "./types"; import type { ColumnInfo } from "./types";
const columns: ColumnInfo[] = [ const columns: ColumnInfo[] = [
@@ -48,4 +48,26 @@ describe("exportData", () => {
globalThis.URL.revokeObjectURL = origRevokeObjectURL; globalThis.URL.revokeObjectURL = origRevokeObjectURL;
} }
}); });
});
describe("csvCell (client CSV parity with streamed export)", () => {
it("quotes and guards like the Rust writer", () => {
expect(csvCell("plain")).toBe("plain");
expect(csvCell("a,b")).toBe('"a,b"');
expect(csvCell('a"b')).toBe('"a""b"');
expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
expect(csvCell("+x")).toBe("'+x");
expect(csvCell("-1")).toBe("'-1");
expect(csvCell("@cmd")).toBe("'@cmd");
expect(csvCell(null)).toBe("");
});
});
describe("qualifiedTableSql", () => {
it("double-quotes for PG/SQLite and backticks for MySQL/MariaDB", () => {
expect(qualifiedTableSql("postgresql", "public", "users")).toBe('SELECT * FROM "public"."users"');
expect(qualifiedTableSql("sqlite", "main", "users")).toBe('SELECT * FROM "main"."users"');
expect(qualifiedTableSql("mysql", "app", "users")).toBe("SELECT * FROM `app`.`users`");
expect(qualifiedTableSql("mariadb", "app", "users")).toBe("SELECT * FROM `app`.`users`");
});
}); });
+23 -7
View File
@@ -1,6 +1,27 @@
import { buildXlsx } from "./xlsx"; import { buildXlsx } from "./xlsx";
import type { ColumnInfo } from "./types"; import type { ColumnInfo } from "./types";
/** Quote one CSV cell with formula-injection protection — mirrors the Rust
* `csv_cell` from the streaming export (Task 10) byte-for-byte. */
export function csvCell(value: unknown): string {
const s = value === null || value === undefined ? "" : String(value);
const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
if (/[,"\n\r]/.test(guarded)) {
return `"${guarded.replace(/"/g, '""')}"`;
}
return guarded;
}
/** SQL selecting every row of a table, dialect-quoted. */
export function qualifiedTableSql(dbType: string, schema: string, table: string): string {
if (dbType === "mysql" || dbType === "mariadb") {
const q = (s: string) => s.replace(/`/g, "``");
return `SELECT * FROM \`${q(schema)}\`.\`${q(table)}\``;
}
const q = (s: string) => s.replace(/"/g, '""');
return `SELECT * FROM "${q(schema)}"."${q(table)}"`;
}
export function exportData( export function exportData(
rows: unknown[][], rows: unknown[][],
columns: ColumnInfo[], columns: ColumnInfo[],
@@ -34,14 +55,9 @@ export function exportData(
break; break;
} }
case "csv": { case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")]; const csvRows = [headers.map((h) => csvCell(h)).join(",")];
for (const row of rows) { for (const row of rows) {
csvRows.push( csvRows.push(row.map((cell) => csvCell(cell)).join(","));
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
} }
content = csvRows.join("\n"); content = csvRows.join("\n");
mime = "text/csv"; mime = "text/csv";
+25 -2
View File
@@ -2,12 +2,19 @@ import { describe, it, expect } from "vitest";
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers"; import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
describe("providers", () => { describe("providers", () => {
it("defines exactly 6 provider tabs in order", () => { it("defines exactly 8 provider tabs in order", () => {
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([ expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon", "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale",
]); ]);
}); });
it("includes a plain (non-managed) MariaDB tab persisting db_type mariadb", () => {
const tab = getProviderById("mariadb")!;
expect(tab.dbType).toBe("mariadb");
expect(tab.isManagedPreset).toBe(false);
expect(tab.label).toBe("MariaDB");
});
it("marks only supabase and neon as managed presets mapping to postgresql", () => { it("marks only supabase and neon as managed presets mapping to postgresql", () => {
const pg = getProviderById("postgresql")!; const pg = getProviderById("postgresql")!;
expect(pg.dbType).toBe("postgresql"); expect(pg.dbType).toBe("postgresql");
@@ -33,4 +40,20 @@ describe("providers", () => {
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true); expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
expect(SETUP_GUIDES.neon.sslRequired).toBe(true); expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
}); });
});
describe("PlanetScale preset", () => {
it("registers as a managed MySQL preset persisting db_type mysql", () => {
const tab = PROVIDER_TABS.find((p) => p.id === "planetscale");
expect(tab).toBeDefined();
expect(tab!.dbType).toBe("mysql");
expect(tab!.isManagedPreset).toBe(true);
});
it("ships a setup guide requiring SSL with concrete steps", () => {
const guide = SETUP_GUIDES.planetscale;
expect(guide.sslRequired).toBe(true);
expect(guide.steps.length).toBeGreaterThanOrEqual(4);
expect(guide.steps.some((s) => s.detail.includes("psdb.cloud"))).toBe(true);
});
}); });
+15 -2
View File
@@ -1,6 +1,6 @@
import type { DbType } from "./types"; import type { DbType } from "./types";
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon"; export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale";
export interface ProviderTab { export interface ProviderTab {
id: ProviderId; id: ProviderId;
@@ -15,10 +15,12 @@ export interface ProviderTab {
export const PROVIDER_TABS: ProviderTab[] = [ export const PROVIDER_TABS: ProviderTab[] = [
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false }, { id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false }, { id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
{ id: "mariadb", label: "MariaDB", dbType: "mariadb", isManagedPreset: false },
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false }, { id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false }, { id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true }, { id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true }, { id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
{ id: "planetscale", label: "PlanetScale", dbType: "mysql", isManagedPreset: true },
]; ];
export function getProviderById(id: string): ProviderTab | undefined { export function getProviderById(id: string): ProviderTab | undefined {
@@ -39,7 +41,7 @@ export interface SetupGuide {
/** Static, researched setup instructions. No network calls at runtime. /** Static, researched setup instructions. No network calls at runtime.
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */ * Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = { export const SETUP_GUIDES: Record<"supabase" | "neon" | "planetscale", SetupGuide> = {
supabase: { supabase: {
blurb: "Managed PostgreSQL with a pooled connection string.", blurb: "Managed PostgreSQL with a pooled connection string.",
sslRequired: true, sslRequired: true,
@@ -65,6 +67,17 @@ export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
}, },
], ],
}, },
planetscale: {
blurb: "Managed MySQL (Vitess) with database branching.",
sslRequired: true,
steps: [
{ title: "Create a branch password", detail: "In the PlanetScale dashboard open your database → Connect → pick a branch → Create password. The password (pscale_pw_…) is shown ONCE and works only for that branch." },
{ title: "Copy the credentials", detail: "Note the Access Host URL (e.g. aws.connect.psdb.cloud or xxxx.us-east-2.psdb.cloud), the username, the password, and your database name." },
{ title: "Fill Host / Port / Database", detail: "Host is the access host, port is 3306, Database is your database name. Save stores the password in your OS keychain." },
{ title: "SSL is mandatory", detail: "Gridline pre-selects Verify Full — PlanetScale requires TLS with identity verification." },
{ title: "Vitess caveats", detail: "Passwords are branch-scoped; deleting one disconnects its clients within 5 minutes. With Safe Migrations ON, schema changes go through deploy requests, so the changes queue may be blocked on production branches. Foreign keys follow your database's foreign_key_constraint setting." },
],
},
neon: { neon: {
blurb: "Serverless Postgres with pooled or direct endpoints.", blurb: "Serverless Postgres with pooled or direct endpoints.",
sslRequired: true, sslRequired: true,
+52 -2
View File
@@ -1,4 +1,4 @@
export type DbType = "postgresql" | "mysql" | "sqlite" | "redis"; export type DbType = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis";
export type Theme = "dark" | "light" | "system"; export type Theme = "dark" | "light" | "system";
export type FontSize = "small" | "medium" | "large"; export type FontSize = "small" | "medium" | "large";
@@ -189,6 +189,19 @@ export interface QueryResult {
error?: string | null; error?: string | null;
} }
export interface StatementNotice {
statement_index: number;
kind: "dml" | "ddl" | "error" | string;
text: string;
affected: number | null;
}
export interface MultiQueryResult {
result_sets: QueryResult[];
notices: StatementNotice[];
execution_time_ms: number;
}
export type ChangeStatus = "pending" | "applied" | "error"; export type ChangeStatus = "pending" | "applied" | "error";
export type ChangeItemType = export type ChangeItemType =
@@ -367,6 +380,7 @@ export type ObjectType =
| "sequences" | "sequences"
| "enums" | "enums"
| "extensions" | "extensions"
| "hypertables"
| "indexes" | "indexes"
| "constraints" | "constraints"
| "procedures" | "procedures"
@@ -378,6 +392,21 @@ export interface ObjectSearchHit {
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
} }
export interface HypertableInfo {
name: string;
schema: string;
num_dimensions: number;
compression_enabled: boolean;
num_chunks: number;
total_size_bytes: number | null;
}
export interface HypertableListResponse {
available: boolean;
reason: string | null;
items: HypertableInfo[];
}
export interface DependencyInfo { export interface DependencyInfo {
deptype: string; deptype: string;
class: string; class: string;
@@ -477,4 +506,25 @@ export interface Relationship {
export interface SchemaGraph { export interface SchemaGraph {
tables: TableNode[]; tables: TableNode[];
relationships: Relationship[]; relationships: Relationship[];
} }
export interface DiffDetailLine {
label: string;
old: string;
new: string;
}
export interface DiffItem {
object_type: string;
name: string;
kind: "added" | "removed" | "changed";
detail: DiffDetailLine[];
sync_sql: string[];
destructive: boolean;
}
export interface DiffReport {
items: DiffItem[];
source_label: string;
target_label: string;
truncated: boolean;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export function abbreviateType(dataType: string): string {
return TYPE_ABBREV[lower] ?? dataType; return TYPE_ABBREV[lower] ?? dataType;
} }
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "sqlite", "redis"]; const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
export function validateConnectionInput(input: ConnectionInput): ValidationResult { export function validateConnectionInput(input: ConnectionInput): ValidationResult {
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" }; if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.14 across the app shell", () => { it("declares v0.8.1 across the app shell", () => {
expect(pkg.version).toBe("0.7.14"); expect(pkg.version).toBe("0.8.1");
}); });
}); });
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useDbViewerStore } from "./dbViewerStore";
import type { DiffItem } from "../lib/types";
const item = (name: string, sql: string, destructive: boolean): DiffItem => ({
object_type: "table",
name,
kind: "added",
detail: [],
sync_sql: [sql],
destructive,
});
describe("addDiffItems", () => {
beforeEach(() => useDbViewerStore.getState().clearChanges());
it("stages one ddl change per non-destructive item, skipping destructive and empty", () => {
const items = [
item("users", "CREATE TABLE users (id int);", false),
item("old", "DROP TABLE old;", true),
{ ...item("weird", "", false), sync_sql: [] },
];
useDbViewerStore.getState().addDiffItems(items);
const queue = useDbViewerStore.getState().changesQueue;
expect(queue).toHaveLength(1);
expect(queue[0].type).toBe("ddl");
expect(queue[0].sql).toBe("CREATE TABLE users (id int);");
expect(queue[0].description).toContain("users");
});
});
+10
View File
@@ -247,6 +247,16 @@ describe("dbViewerStore", () => {
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0); expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
}); });
it("updateChange patches a single change in place", () => {
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
const id = useDbViewerStore.getState().changesQueue[0].id;
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
const item = useDbViewerStore.getState().changesQueue[0];
expect(item.newData).toEqual({ a: "hello" });
expect(item.type).toBe("insert");
expect(item.description).toBe("x");
});
it("clearChanges empties the queue", () => { it("clearChanges empties the queue", () => {
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any); useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any); useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
+39 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult, DiffItem } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands"; import { getDatabases, getSchemas, getTables } from "../lib/commands";
import type { ObjectKind, DdlParams } from "../lib/objectCrud"; import type { ObjectKind, DdlParams } from "../lib/objectCrud";
@@ -58,6 +58,7 @@ export interface ViewerTab {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
data: QueryResult | null; data: QueryResult | null;
multi?: MultiQueryResult | null;
columnFilter?: { column: string; value: string }; columnFilter?: { column: string; value: string };
filterRules: FilterRule[]; filterRules: FilterRule[];
sortRules: SortRule[]; sortRules: SortRule[];
@@ -117,6 +118,8 @@ interface DbViewerState {
sequences: SequenceInfo[] | null; sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null; enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null; extensions: ExtensionInfo[] | null;
hypertables: HypertableInfo[] | null;
hypertablesAvailable: boolean | null;
indexes: IndexInfo[] | null; indexes: IndexInfo[] | null;
setSchemaTreeLoading: (loading: boolean) => void; setSchemaTreeLoading: (loading: boolean) => void;
constraints: ConstraintInfo[] | null; constraints: ConstraintInfo[] | null;
@@ -144,6 +147,7 @@ interface DbViewerState {
setPage: (tabId: string, page: number) => void; setPage: (tabId: string, page: number) => void;
setPageSize: (tabId: string, pageSize: number) => void; setPageSize: (tabId: string, pageSize: number) => void;
setTabData: (tabId: string, data: QueryResult) => void; setTabData: (tabId: string, data: QueryResult) => void;
setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void;
setTabLoading: (tabId: string, loading: boolean) => void; setTabLoading: (tabId: string, loading: boolean) => void;
setTabError: (tabId: string, error: string) => void; setTabError: (tabId: string, error: string) => void;
setColumnFilter: (tabId: string, column: string, value: string) => void; setColumnFilter: (tabId: string, column: string, value: string) => void;
@@ -165,8 +169,10 @@ interface DbViewerState {
rows?: unknown[][]; rows?: unknown[][];
description?: string | null; description?: string | null;
}) => void; }) => void;
addDiffItems: (items: DiffItem[]) => void;
cancelChange: (changeId: string) => void; cancelChange: (changeId: string) => void;
removeChange: (changeId: string) => void; removeChange: (changeId: string) => void;
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
clearChanges: () => void; clearChanges: () => void;
markChangeCommitted: (changeId: string) => void; markChangeCommitted: (changeId: string) => void;
markChangeFailed: (changeId: string, error: string) => void; markChangeFailed: (changeId: string, error: string) => void;
@@ -181,6 +187,8 @@ interface DbViewerState {
setSequences: (sequences: SequenceInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => void; setEnums: (enums: EnumInfo[]) => void;
setExtensions: (extensions: ExtensionInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void;
setHypertables: (hypertables: HypertableInfo[]) => void;
setHypertablesAvailable: (v: boolean | null) => void;
setIndexes: (indexes: IndexInfo[]) => void; setIndexes: (indexes: IndexInfo[]) => void;
setConstraints: (constraints: ConstraintInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void;
setRoles: (roles: RoleInfo[]) => void; setRoles: (roles: RoleInfo[]) => void;
@@ -223,6 +231,8 @@ const initialState = {
sequences: null as SequenceInfo[] | null, sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null, enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null, extensions: null as ExtensionInfo[] | null,
hypertables: null as HypertableInfo[] | null,
hypertablesAvailable: null as boolean | null,
indexes: null as IndexInfo[] | null, indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null, constraints: null as ConstraintInfo[] | null,
roles: [] as RoleInfo[], roles: [] as RoleInfo[],
@@ -442,10 +452,15 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setTabData: (tabId, data) => setTabData: (tabId, data) =>
set((state) => ({ set((state) => ({
tabs: state.tabs.map((t) => tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, data, loading: false, error: null } : t, t.id === tabId ? { ...t, data, multi: null, loading: false, error: null } : t,
), ),
})), })),
setTabMulti: (tabId, multi) =>
set((s) => ({
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, multi } : t)),
})),
setTabLoading: (tabId, loading) => setTabLoading: (tabId, loading) =>
set((state) => ({ set((state) => ({
tabs: state.tabs.map((t) => tabs: state.tabs.map((t) =>
@@ -535,6 +550,19 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
set((state) => ({ changesQueue: [...state.changesQueue, item] })); set((state) => ({ changesQueue: [...state.changesQueue, item] }));
}, },
addDiffItems: (items) => {
for (const it of items) {
if (it.destructive || it.sync_sql.length === 0) continue;
for (const sql of it.sync_sql) {
get().addChange({
type: "ddl",
sql,
description: `Schema diff: ${it.object_type} ${it.name} (${it.kind})`,
});
}
}
},
cancelChange: (changeId) => cancelChange: (changeId) =>
set((state) => ({ set((state) => ({
changesQueue: state.changesQueue.map((c) => changesQueue: state.changesQueue.map((c) =>
@@ -547,6 +575,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
changesQueue: state.changesQueue.filter((c) => c.id !== changeId), changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
})), })),
updateChange: (changeId, patch) =>
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === changeId ? { ...c, ...patch } : c,
),
})),
clearChanges: () => set({ changesQueue: [] }), clearChanges: () => set({ changesQueue: [] }),
markChangeCommitted: (changeId) => markChangeCommitted: (changeId) =>
@@ -578,6 +613,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setSequences: (sequences) => set({ sequences }), setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }), setEnums: (enums) => set({ enums }),
setExtensions: (extensions) => set({ extensions }), setExtensions: (extensions) => set({ extensions }),
setHypertables: (hypertables) => set({ hypertables }),
setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }),
setIndexes: (indexes) => set({ indexes }), setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }), setConstraints: (constraints) => set({ constraints }),
setRoles: (roles) => set({ roles }), setRoles: (roles) => set({ roles }),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.7.10", "version": "0.8.0",
"workspaces": ["desktop", "www"], "workspaces": ["desktop", "www"],
"scripts": { "scripts": {
"dev:desktop": "bun run --filter gridline-desktop dev", "dev:desktop": "bun run --filter gridline-desktop dev",
+6 -6
View File
@@ -1,9 +1,9 @@
# Homebrew Cask formula for Gridline. # Homebrew Cask formula for Gridline.
# #
# This file is the template; `scripts/render-cask.sh` substitutes # This file is the template; `scripts/render-cask.sh` substitutes the version
# @VERSION@ / @ARM_SHA256@ / @INTEL_SHA256@ on each release and pushes the # and the per-arch SHA-256 checksums on each release and pushes the rendered
# rendered file to the `AdrianBonpin/homebrew-gridline` tap as # file to the `AdrianBonpin/homebrew-gridline` tap as `Casks/gridline.rb`.
# `Casks/gridline.rb`. End users then install with # End users then install with
# #
# brew install --cask AdrianBonpin/gridline/gridline # brew install --cask AdrianBonpin/gridline/gridline
# #
@@ -21,10 +21,10 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@", sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@" intel: "@INTEL_SHA256@"
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg" url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline" name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL" desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://git.ranio.xyz/adrianbonpin/gridline" homepage "https://github.com/AdrianBonpin/gridline"
depends_on macos: :monterey depends_on macos: :monterey
+54 -60
View File
@@ -7,13 +7,12 @@
# #
# Usage: # Usage:
# TAG=v0.7.11 ./scripts/release-mac.sh # TAG=v0.7.11 ./scripts/release-mac.sh
# (optionally set GITEA_TOKEN env var to override token lookup)
# #
# The release is keyed by tag. The Linux/Windows job creates it on the first # The release is keyed by tag. The Linux/Windows job creates it on the first
# tag push; this script finds it (creating it only if it somehow doesn't exist) # tag push; this script finds it (creating it only if it somehow doesn't exist)
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg # and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg
# #
# Token: reads $GITEA_TOKEN, else tries the tea CLI config, else prompts. # Auth: uses the `tea` CLI (OAuth token, auto-refreshed).
set -euo pipefail set -euo pipefail
@@ -28,40 +27,15 @@ if [ -z "$TAG" ]; then
exit 1 exit 1
fi fi
# ---- resolve auth token ---------------------------------------------------- # ---- resolve auth (tea CLI) ------------------------------------------------
get_token() { # The `tea` CLI carries the OAuth token and refreshes it automatically, so the
if [ -n "${GITEA_TOKEN:-}" ]; then # release upload below uses `tea` rather than a raw curl + token. (The git
echo "$GITEA_TOKEN"; return # credential store can hold a stale access token and 401.)
fi if ! command -v tea >/dev/null 2>&1; then
# Try tea's stored credentials if available. echo "error: tea CLI not found (needed to upload release assets)" >&2
if command -v tea >/dev/null 2>&1; then echo " install it: https://gitea.com/gitea/tea" >&2
# tea stores its token in a config under the platform app-support dir.
local cfg
cfg="$(find "$HOME/Library/Application Support" "$HOME/.config" \
-maxdepth 2 -name 'config.yml' -path '*tea*' 2>/dev/null | head -1 || true)"
if [ -n "$cfg" ]; then
local tok
tok="$(sed -n 's/.*token:[" ]*\([^" ]*\).*/\1/p' "$cfg" 2>/dev/null | head -1 || true)"
if [ -n "$tok" ]; then echo "$tok"; return; fi
fi
fi
# Fall back to the git credential store: tea's OAuth login stores an access
# token as the password for every push to this remote, and Gitea accepts it
# for API auth (Authorization: token <access_token>).
local host cred
host="${GITEA_SERVER#*://}"
cred="$(printf 'protocol=https\nhost=%s\n\n' "$host" \
| git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1 || true)"
if [ -n "$cred" ]; then echo "$cred"; return; fi
# Fail fast — continuing without a token fails only at the upload step,
# after the whole build has run.
echo "error: no Gitea token found" >&2
echo " set GITEA_TOKEN, or run 'tea login' / 'git push' once so the" >&2
echo " credential store has an entry for $host" >&2
exit 1 exit 1
} fi
GITEA_TOKEN="$(get_token)"
# ---- macOS signing + notarization ----------------------------------------- # ---- macOS signing + notarization -----------------------------------------
# Requires a "Developer ID Application" certificate in the login keychain and # Requires a "Developer ID Application" certificate in the login keychain and
@@ -223,33 +197,53 @@ for APP in \
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)" || echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
done done
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- notarize + staple the DMGs --------------------------------------------
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}" # Notarizing the .app is not enough. When a user opens the downloaded DMG,
AUTH="Authorization: token ${GITEA_TOKEN}" # Gatekeeper assesses the DMG itself — and an unnotarized DMG is rejected as
# "Unnotarized Developer ID" even though the app inside it is fine. Submit each
echo ">> Ensuring release ${TAG} exists..." # DMG to the same notary service and staple the ticket into the DMG.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) echo ">> Skipping DMG notarization (GRIDLINE_SKIP_NOTARIZE=1)."
if [ -z "$REL_ID" ]; then else
BODY="Gridline ${VERSION} — macOS (built manually)." API_KEY_PATH="${APPLE_API_KEY_PATH:-${HOME}/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8}"
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ [ -f "$API_KEY_PATH" ] || {
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${VERSION}\",\"body\":\"${BODY}\",\"draft\":true}" \ echo "ERROR: notarization API key not found at $API_KEY_PATH" >&2
"${API}/releases" \ echo " Set APPLE_API_KEY_PATH in ${NOTARIZE_ENV} or place the .p8 at the default location." >&2
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") exit 1
}
for DMG in \
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"; do
[ -f "$DMG" ] || { echo "missing $DMG" >&2; exit 1; }
echo ">> Notarizing $(basename "$DMG") (this can take a few minutes)"
xcrun notarytool submit "$DMG" \
--key "$API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$DMG" \
|| { echo "failed to staple $(basename "$DMG")" >&2; exit 1; }
xcrun stapler validate "$DMG" \
|| { echo "notarization ticket not stapled to $(basename "$DMG")" >&2; exit 1; }
done
fi fi
upload() { # ---- create/ensure release + upload DMGs -----------------------------------
local file="$1" # Use `tea` for release management — it handles OAuth token refresh, unlike a
[ -f "$file" ] || { echo "missing $file" >&2; exit 1; } # raw curl with a token from the git credential store (which can 401).
local name cd "${WORKSPACE}"
name=$(basename "$file")
echo ">> uploading $name"
curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary "@$file" \
"${API}/releases/${REL_ID}/assets?name=${name}"
}
upload "${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" echo ">> Ensuring release ${TAG} exists..."
upload "${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg" if ! tea release list -o json 2>/dev/null | grep -q "\"${TAG}\""; then
tea release create --tag "${TAG}" --title "Gridline ${VERSION}" \
--note "Gridline ${VERSION} — macOS (built manually)." --draft
else
echo ">> Release ${TAG} already exists."
fi
echo ">> Uploading macOS DMGs..."
tea releases assets create "${TAG}" \
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}" echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
+3 -3
View File
@@ -59,12 +59,12 @@
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent); const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
const keepLabel = downloadBtn.hasAttribute("data-download-label"); const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases"; const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
const version = "0.7.14"; const version = "0.8.1";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
if (!keepLabel) downloadBtn.textContent = "Get it on Gitea"; if (!keepLabel) downloadBtn.textContent = "Get it on GitHub";
} else if (isMac) { } else if (isMac) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`; downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
if (!keepLabel) downloadBtn.textContent = "Download for macOS"; if (!keepLabel) downloadBtn.textContent = "Download for macOS";
+32 -1
View File
@@ -5,12 +5,43 @@ export interface ChangelogEntry {
} }
export const changelogEntries: ChangelogEntry[] = [ export const changelogEntries: ChangelogEntry[] = [
{
version: "0.8.1",
date: "2026-09-24",
highlights: [
"Managed-provider connections fixed — pasting a Neon / Supabase / PlanetScale connection string now carries its `?sslmode=` into the saved connection (it was silently dropped, so TLS stayed off and Neon answered `connection is insecure (try using sslmode=require)`). The SSH / SSL tab's mode and certificate paths are now persisted on create *and* edit, and the managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). As a safety net, hosts under `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` default to `require` when a connection has no stored mode, so connections saved before this fix keep working.",
"Pasting a connection URI fills every setting it can — type, host, port, user, password, database and TLS mode — and suggests a name from the URL (database name, else host), which Test and Save use when the label is left blank. A pasted URL can now be tested and saved with no retyping.",
"macOS keychain prompts now self-heal — an item whose ACL still points at an older binary path (the pre-monorepo `src-tauri/` layout, a reinstalled app, an ad-hoc dev build) made macOS ask for your keychain password on *every* access, once per secret, with no way to make it stick. The app now checks the item's ACL (silently) before reading and repairs it when needed, so you authorize a secret once and it stays silent afterwards.",
],
},
{
version: "0.8.0",
date: "2026-09-09",
highlights: [
"Schema diff / compare — pick a source connection of the same engine family and diff it against the current one: added/removed/changed tables, columns, constraints, indexes, views, sequences, and enums, each with generated same-dialect sync SQL. Non-destructive items stage into the changes queue; destructive changes (DROPs, ALTER TYPE) are copy-only with a warning.",
"Multiple result sets — run a script and see every statement's results stacked in order, with affected-count notices for DML/DDL and a stop-at-first-error card that keeps prior results visible.",
"Export all rows to file — stream the full result of any query (no page limit, constant memory) to CSV, JSONL, or JSON, with progress and cancel.",
"MariaDB — first-class database type: provider card, type filter, connection URL copy/paste, and full viewer/editing/import/backup parity with MySQL.",
"TimescaleDB — hypertables surface in the Objects view on PostgreSQL connections with the extension installed: dimensions, compression status, chunk count, and size.",
"PlanetScale preset — managed MySQL (Vitess) provider card with a researched setup guide and .psdb.cloud host detection.",
],
},
{
version: "0.7.15",
date: "2026-09-08",
highlights: [
"Insert Row now works — it adds an editable pending row at the top of the data grid (with a visible accent outline), and committing inserts only the columns you filled in 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 foreign keys already had dropdowns), applied to both new rows and inline editing.",
"Landing page adds a support channel: coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature.",
"Landing page copy refreshed: em dashes dropped, OSS section simplified, FAQ added for how the project is funded.",
],
},
{ {
version: "0.7.14", version: "0.7.14",
date: "2026-09-04", date: "2026-09-04",
highlights: [ highlights: [
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now 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).", "No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now 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 Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.", "Install via Homebrew — add the Homebrew tap (brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git) and run brew install --cask gridline.",
], ],
}, },
{ {
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { faqItems } from "./faq"; import { faqItems } from "./faq";
describe("faqItems", () => { describe("faqItems", () => {
it("has exactly 5 items", () => { it("has exactly 6 items", () => {
expect(faqItems).toHaveLength(5); expect(faqItems).toHaveLength(6);
}); });
it("every item has a non-empty question and answer", () => { it("every item has a non-empty question and answer", () => {
+9 -4
View File
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
{ {
question: "Is Gridline really free for commercial use?", question: "Is Gridline really free for commercial use?",
answer: answer:
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.", "Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
}, },
{ {
question: "Which databases are supported?", question: "Which databases are supported?",
answer: answer:
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.", "PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.",
}, },
{ {
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?", question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
{ {
question: "Do you store my database credentials?", question: "Do you store my database credentials?",
answer: answer:
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", "No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.",
}, },
{ {
question: "Can I self-host or contribute?", question: "How do you make money?",
answer:
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin. It's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
},
{
question: "Can I contribute or self-host?",
answer: answer:
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.", "Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
}, },

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