55 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
adrianbonpin 67f3532938 fix: release-mac.sh resolves tokens from the git credential store; fail fast (#24) 2026-09-04 04:21:16 +00:00
adrianbonpin 1d8dfce99a fix: make release-mac.sh idempotent for re-runs (#23) 2026-09-04 04:10:29 +00:00
adrianbonpin 8bcdc0888f chore: release 0.7.14 (#22)
Release / linux (push) Successful in 16m24s
Release / windows (push) Successful in 18m27s
2026-09-04 03:44:32 +00:00
adrianbonpin fd451d219d feat: pin keychain item ACLs to the app's code signature (macOS) (#21) 2026-09-04 03:41:54 +00:00
adrianbonpin a86f5f7199 docs: Homebrew install via Gitea tap (#20) 2026-09-04 02:54:08 +00:00
adrianbonpin 2fccb7b405 fix: use non-deprecated depends_on macos syntax in cask (#19) 2026-09-04 02:53:33 +00:00
adrianbonpin 0efabbdc2e fix: point Homebrew cask at Gitea releases (#18) 2026-09-04 02:46:06 +00:00
adrianbonpin 294e72e6ff chore: drop xattr postflight from Homebrew cask template (#17) 2026-09-04 02:32:43 +00:00
adrianbonpin 15f4dc9bac chore: sync Cargo.lock version to 0.7.13 (#16) 2026-09-04 02:03:13 +00:00
adrianbonpin 867df98958 fix: sign bundled DB tools with Developer ID for notarization (#15) 2026-09-04 02:02:54 +00:00
adrianbonpin e66d7ac102 release: v0.7.13 — Developer-ID signed + notarized macOS (#14)
Release / windows (push) Successful in 19m32s
Release / linux (push) Successful in 22m48s
2026-09-04 01:43:08 +00:00
adrianbonpin 84a6f8d41c feat: Developer-ID sign + notarize macOS builds (#13) 2026-09-04 01:40:48 +00:00
adrianbonpin 08b0f79f07 feat(www): SEO technical + on-page improvements (#12) 2026-09-02 09:10:50 +00:00
adrianbonpin 9611567daa chore: sync Cargo.lock to v0.7.12 + gitignore bundled DB tools (#11) 2026-09-02 08:45:26 +00:00
adrianbonpin 0bc5110e05 fix(www): point download buttons at Gitea + v0.7.12 (#10) 2026-09-02 08:43:31 +00:00
adrianbonpin 2433697951 fix(www): point download buttons at Gitea + v0.7.12 (#9) 2026-09-01 06:15:43 +00:00
adrianbonpin 341354ecb9 ci: use ASCII release body so Windows create succeeds (#8)
Release / linux (push) Successful in 18m41s
Release / windows (push) Successful in 19m4s
2026-09-01 05:06:03 +00:00
adrianbonpin 37e6638854 ci: make release-creation race-safe across parallel jobs (#7)
Release / windows (push) Failing after 17m35s
Release / linux (push) Successful in 20m23s
2026-09-01 04:44:42 +00:00
adrianbonpin 7bd4e8d05f fix: PG cell-edit serialization + recent-connections startup race (#6)
Release / windows (push) Failing after 18m10s
Release / linux (push) Successful in 19m10s
2026-09-01 04:23:07 +00:00
adrianbonpin 7a66b70c26 fix(www): Gridline favicon + Gitea links (#4) 2026-08-21 01:10:07 +00:00
adrianbonpin c4eaaa70ce fix: install xdg-utils in Linux release deps (#3)
Release / windows (push) Failing after 16s
Release / linux (push) Successful in 24m36s
2026-08-20 20:50:39 +00:00
adrianbonpin 5aadece5fa release: v0.7.11 (#2)
Release / windows (push) Failing after 32s
Release / linux (push) Failing after 23m10s
2026-08-20 20:23:47 +00:00
adrianbonpin 4cff3e2508 chore: Gitea Actions release workflow (Linux + Windows auto, macOS manual) (#1) 2026-08-20 20:12:15 +00:00
adrianbonpin 96d34a68a1 feat(www): interactive DB viewer hero + max-w-7xl centering (#28)
Replace the static hero screenshot with a self-contained React mockup of the
full Gridline DB viewer (sidebar, table tree, tab bar, fake SQL editor,
data grid, queries/objects/schema-visualizer views, disabled Tools state).

- Add @astrojs/react and mount the island via client:visible
- 16:9 aspect-ratio mockup, hidden on mobile (static image fallback)
- JSON cell popover: solid surface, anchored within the mockup window,
  Escape/outside-click close
- Dependency-free HTML5 drag-to-reorder tabs
- Theme-aware app tokens (canvas, surface-raised, popover bg)
- Site-wide max-w-7xl (was max-w-[1200px]) so sections center on big screens
- Regenerate standalone www/bun.lock for deterministic Dokploy builds
2026-08-17 23:31:31 +08:00
adrianbonpin c22e675f2b fix(www): prevent flicker and reveal macos-hint on landing page (#27)
Hide entry-animated elements at first paint (html.anime-ready guard in
global.css) so the landing page no longer flashes visible content before
anime.js loads. Adds a <noscript> fallback and a JS load-error fallback to
guarantee content is never permanently hidden, and reveals the hero
immediately for prefers-reduced-motion users. Includes #macos-hint in the
hero entrance timeline so the macOS command box animates in correctly.
2026-08-17 10:30:04 +08:00
adrianbonpin 73c8cdd79b Merge pull request #26 from AdrianBonpin/fix/www-bun-lock-sync
fix(www): regenerate standalone bun.lock (vitest missing → frozen lockfile deploy failure)
2026-08-17 09:21:12 +08:00
adrianbonpin d8bab12b51 fix(www): regenerate standalone bun.lock to include vitest
www/package.json declares vitest ^4.1.10 but the standalone www/bun.lock
(Railpack/Dokploy builds from /www with --frozen-lockfile) was stale and
missing it, failing every deployment with 'lockfile had changes, but
lockfile is frozen'. Regenerated www/bun.lock from www/package.json.
2026-08-17 09:20:51 +08:00
adrianbonpin 501fed962c feat(www): landing page (#25)
* design: landing page spec + app icon asset

* chore(www): add anime.js, fonts, and landing page screenshot assets

* feat(www): add design tokens, fonts, shared layout

* feat(www): add landing page sections and styling

* feat(www): add theme toggle and anime.js viewport animations

* feat(www): feedback updates — no hero card, os-aware download, footer email, lowercase colored heading

* feat(www): remove screenshot borders, move macos hint below ctas, mobile github fallback

* feat(www): keep hero CTAs side by side

* feat(www): set initial nav border to transparent

* feat(www): add Rybbit prod-only, sitemap, SEO head, OG image, robots.txt, 404 page

* docs(AGENTS): include www landing page version in release sync checklist

* chore(www): add railpack.json for Dokploy deploy (force node provider, TZ)

* chore(www): use bun for Railpack deploy (packageManager + standalone www/bun.lock)

* docs(AGENTS): note www/bun.lock sync for Railpack deploys

* chore(www): add vitest test tooling

* feat(www): add FAQ data source

* feat(www): add SEO JSON-LD builders

* feat(www): add Seo JSON-LD component

* feat(www): wire Seo component and social/meta tags into Layout

* feat(www): add homepage JSON-LD, keyword H1, data-driven FAQ

* fix(www): point Roadmap footer link to GitHub

* feat(www): add lastmod to sitemap

* perf(www): self-host anime.js

* refactor(www): move nav and footer into shared Layout

* feat(www): add changelog page

* fix(www): make shared component styles global in Layout

* perf(www): optimize screenshots with Astro Image (webp/avif + srcset)

* feat(www): add web app manifest

* feat(www): add helpful links to 404 page

* fix(www): shorten homepage H1 copy

* fix(www): OS-aware navbar download + footer layout

* feat(www): build full changelog from v0.7.0 to v0.7.10
2026-08-17 09:16:58 +08:00
adrianbonpin 62eed15844 Merge pull request #24 from AdrianBonpin/feat/monorepo
refactor: convert to bun-workspaces monorepo
2026-08-16 19:52:14 +08:00
adrianbonpin 6854d889c5 refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
2026-08-16 19:31:53 +08:00
adrianbonpin d752642b4d Merge pull request #23 from AdrianBonpin/feat/homebrew-cask
feat: bump Homebrew cask on release
2026-08-16 18:42:57 +08:00
adrianbonpin 2d6b576d3a feat: bump Homebrew cask on release 2026-08-16 18:42:36 +08:00
adrianbonpin 6d1bbb0fa3 feat: copy connection URL from connection card menu (#22)
Release / publish (, ubuntu-22.04) (push) Failing after 20m6s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Add 'Copy connection URL' (with password, fetched from the OS keychain on
demand) and 'Copy connection URL (no password)' to the connection card kebab
menu. Builds postgresql://, mysql://, sqlite://, redis:// strings with
percent-encoded credentials and the PG sslmode appended. Bump to v0.7.10.
2026-08-13 14:33:54 +08:00
adrianbonpin df3e3957a2 docs: add feature request + improvement issue templates, auto-assign new issues (#21)
- Add 2-feature_request.md and 3-improvement.md templates (chooser now
  lists Bug report, Feature request, Improvement in order)
- Improve bug report template: cross-template pointer, Linux log hint,
  Related issues/PRs section, demo-DB reproduction hint
- Auto-assign every newly opened issue to the repo owner:
  assignees in template front matter + a workflows/auto-assign.yml
  (covers issues opened without a template)
- Add ISSUE_TEMPLATE/config.yml with Roadmap/README contact links
- Update README Contributing section
2026-08-08 17:16:16 +08:00
adrianbonpin 5bc31a14a6 docs: use sudo for the quarantine-removal command everywhere (#20)
The bundle contains read-only files (e.g. the bundled OpenSSL dylibs), so a
sudo-less 'xattr -dr com.apple.quarantine' fails with Permission denied for
some users. Standardize on 'sudo xattr -dr com.apple.quarantine' in the
README, ROADMAP, the release.yml SIGNING STATUS comment, and the release
notes; AGENTS.md now requires release notes to carry the macOS first-launch
instructions until a Developer ID cert is secured.
2026-08-08 14:04:25 +08:00
adrianbonpin 38de9b4da1 docs: call out macOS first-launch caveat at the top Download section (#19)
Users at the top table previously saw no mention of the unsigned-app
warnings; the details lived ~200 lines down under Getting Started. Add a
callout right under the Download table linking to the macOS first-launch
instructions (right-click Open / xattr workaround).
2026-08-08 13:32:05 +08:00
adrianbonpin 091d9df6f0 fix/macos launch (#18)
Release / publish (, ubuntu-22.04) (push) Failing after 18m54s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
* fix: macOS Finder launch (store path, ad-hoc signing, vendored openssl)

- Store::open was resolving 'gridline.db' relative to the working directory;
  Finder/LaunchServices launches run with cwd=/ so the .expect() panicked
  (exit 101, silent 'app does nothing') before Tauri ever started. Open the
  store under app.path().app_data_dir() in setup instead (same dir as the
  demo DB), creating the dir when needed.
- Add bundle.macOS.signingIdentity "-" + hardenedRuntime false so the
  bundler ad-hoc signs the whole bundle. Previously only the inner binary
  got Xcode 16's linker-signed signature, which macOS treats as unsigned:
  quarantined downloads showed 'damaged and can't be opened', and after
  quarantine removal LaunchServices silently refused to spawn it.
- ssh2 now builds OpenSSL vendored (feature vendored-openssl): the release
  binary previously carried an absolute LC_LOAD_DYLIB to the build machine's
  /opt/homebrew/opt/openssl@3/lib, which dyld aborted on (and hardened
  runtime library-validation rejected even when present).
- README: document the macOS first-launch paths (right-click Open / xattr
  for the damaged-error case, re-run after every upgrade).
- release.yml: update SIGNING STATUS comment to reflect ad-hoc signing.

* chore: bump 0.7.8 -> 0.7.9 (release prep)

- Version sync across package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json
- version.test.ts / bundle-config.test.ts / docs-coverage.test.ts expect 0.7.9
- README: both download tables -> v0.7.9 asset names; new v0.7.9 changelog entry;
  tag instructions -> v0.7.9; MAINTENANCE comment updated
- AGENTS.md maintenance note example -> v0.7.9
- ROADMAP: Shipped (0.7.9) section; Next up retitled (0.8.0) TBD
2026-08-08 13:29:31 +08:00
adrianbonpin 751746f784 fix(release): bundle MariaDB client tools (GnuTLS + dep-chain rpath/loader-path) (#17)
Release / publish (, ubuntu-22.04) (push) Failing after 18m4s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
* fix(release): MariaDB client tools — satisfy Connector C GnuTLS probe + bundle dep chain (ldd/patchelf + @loader_path)

* fix(release): macOS mariadb clients — keep known-good brew openssl config, rewrite deps to @loader_path + ad-hoc re-sign

* fix(release): macOS dylib rewrite — || true on the otool/grep pipeline (pipefail aborts on zero matches)
2026-08-08 00:57:29 +08:00
adrianbonpin 32a7b852ec v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)
* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
2026-08-08 00:09:36 +08:00
adrianbonpin 9fb3222956 docs(agents): reword PR path note (no review gate on prod) (#15) 2026-08-07 14:04:11 +08:00
adrianbonpin 76cd1ded16 docs(agents): enforce branch + PR workflow for prod (never push directly) (#14) 2026-08-07 14:02:40 +08:00
adrianbonpin a424e31de8 docs(roadmap): reorder shipped sections to bottom, add Windows title bar fix + SQLite table editor to next up 2026-08-07 13:59:43 +08:00
adrianbonpin 7401e91a12 v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)
Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, windows-latest) (push) Canceled after 0s
* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1)

* feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2)

* feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3)

* feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1)

* feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2)

* feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3)

* feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4)

* feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5)

* feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1)

* feat(store): roles slice + openFormTab table/role dedup (Task 3.2)

* feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1)

* feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2)

* feat(ui): wire roles into object registry + explorer (Task 4.3)

* feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4)

* chore: bump version to 0.7.7 (Task 5.1)

* docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2)

* test: v0.7.7 manual smoke checklist (Task 5.3)

* chore: sync Cargo.lock to v0.7.7

* style(ui): match TableForm visual layout to shared object-form rows (remove stray padding)

* feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls

* style(ui): table-creator columns grid — bordered cells like the data table view

* feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row

* feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints

* style(ui): table creator — constraints column sized to content (no clip), overscroll containment

* style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2

* feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema

* style(ui): table creator — columns grid fills container width, scrolls only when too narrow

* feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family

* feat(ui): FK panel — animate in/out from the right, flush create-table-style rows

* fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid

* feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs

* fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs

* style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker

* style(ui): FK panel — vertical divider between local/ref columns

* style(ui): FK panel — section title padding py-2

* style(ui): FK panel — remove row margins + select/text padding in column picker

* style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells

* style(ui): FK panel — cell padding + row borders on column picker (user polish)

* feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class

* style(ui): hide FK icon on PK columns; fix grid header/row column alignment

* feat(ui): SQL preview in table creator uses read-only Monaco editor

* feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions

* style(ui): FK relation row — label, icon, table on one line

* style(ui): FormRow selects transparent like FK panel; test matches FK relation label

* style(ui): form-row focus outline amber-400/20 (subtle)

* style(ui): SQL view — remove px-4 py-3 wrapper padding

* feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card

* fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64

* fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed)

* feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more

* feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all

* chore(test): drop unused fireEvent import in RoleDetail test

* style(ui): hide chevron when section has 5 or fewer entries

* docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins)

* docs(readme): collapse Recent changes and Key features into details blocks

* docs(readme): move hero caption under image; align download + comparison table columns

* docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible

* docs(readme): per-subsection collapsibles with meaningful summary labels
2026-08-07 05:51:23 +08:00
479 changed files with 26311 additions and 3923 deletions
+285
View File
@@ -0,0 +1,285 @@
name: Release
# Builds Gridline installers for Linux and Windows on the self-hosted
# Gitea Actions runners, then uploads them to a Gitea release.
#
# * Linux -> sec-s1-runner (label ubuntu-22.04) -> .deb / .rpm / .AppImage
# * Windows-> win-ig3i (label windows) -> .exe / .msi
#
# macOS is NOT built here. The Mac is built manually on Adrian's machine and
# its DMGs uploaded to the same release by tag (scripts/release-mac.sh).
#
# Trigger: push a version tag from the `prod` branch, e.g.
# git checkout prod && git pull
# git tag v0.7.11 && git push origin v0.7.11
#
# Each job independently ensures the Gitea release exists (GET by tag, create
# on 404) then uploads only its own platform assets — so Linux and Windows run
# in parallel and both land on the same release page. If one platform's runner
# is offline, the other job still succeeds (fail-fast: false).
#
# SIGNING STATUS: macOS is ad-hoc signed (manual, via tauri.conf.json). Linux
# and Windows installers are unsigned. No certs are bundled.
on:
push:
tags:
- 'v*'
permissions:
contents: write
env:
GITEA_SERVER: https://git.ranio.xyz
REPO_OWNER: adrianbonpin
REPO_NAME: gridline
jobs:
linux:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: swatinem/rust-cache@v2
with:
workspaces: './desktop/src-tauri -> target'
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Build PostgreSQL client tools (bundled)
shell: bash
run: |
set -euo pipefail
PG_VER="16.4"
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
mkdir -p "$OUT"
sudo apt-get update -y
sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison patchelf
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER}
./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
make -C src/backend generated-headers
make -C src/interfaces/libpq all
make -j"$(nproc)" -C src/bin/pg_dump all
make -j"$(nproc)" -C src/bin/psql all
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$OUT"/
cp src/interfaces/libpq/libpq.so.5 "$OUT"/libpq.so.5
for b in pg_dump pg_restore psql; do
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
done
- name: Build MariaDB client tools (bundled)
shell: bash
run: |
set -euo pipefail
MARIADB_VER="11.4.5"
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
mkdir -p "$OUT"
sudo apt-get update -y
sudo apt-get install -y cmake build-essential libssl-dev libzstd-dev pkg-config libgnutls28-dev patchelf
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server
cmake -DCMAKE_BUILD_TYPE=Release \
-DWITHOUT_SERVER=ON \
-DWITHOUT_TOKUDB=1 \
-DWITHOUT_ROCKSDB=1 \
-DWITHOUT_MROONGA=1 \
-DWITHOUT_SPIDER=1 \
-DWITHOUT_SEQUENCE=1 \
-DWITH_UNIT_TESTS=OFF \
-DWITH_SSL=bundled \
-DWITH_ZLIB=bundled .
make -j"$(nproc)" mariadb-dump mariadb
for b in mariadb-dump mariadb; do
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
cp "$src" "$OUT"/
done
for b in mariadb-dump mariadb; do
ldd "$OUT/$b" | awk '/=> \// {print $3}' | sort -u | while read -r lib; do
case "$(basename "$lib")" in
ld-linux*|libc.so*|libm.so*|libpthread*|libdl.so*|librt.so*|libgcc_s*|libstdc++*|libcrypt.so*|libresolv*|libutil*)
;;
*)
cp -n "$lib" "$OUT/$(basename "$lib")" 2>/dev/null || true
;;
esac
done
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
done
- name: Build installers
run: bun run --filter gridline-desktop tauri build
- name: Create release and upload Linux assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
AUTH="Authorization: token ${GITHUB_TOKEN}"
# Ensure the release exists (idempotent — both jobs may call this).
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
cd "$BUNDLE"
for f in deb/*.deb rpm/*.rpm appimage/*.AppImage; do
[ -f "$f" ] || { echo "missing $f"; exit 1; }
name=$(basename "$f")
echo "uploading $name"
curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary "@$f" \
"${API}/releases/${REL_ID}/assets?name=${name}"
done
echo "Linux assets uploaded to release ${REL_ID}"
windows:
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: swatinem/rust-cache@v2
with:
workspaces: './desktop/src-tauri -> target'
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Build PostgreSQL client tools (bundled)
shell: bash
run: |
set -euo pipefail
PG_VER="16.4"
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
mkdir -p "$OUT"
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
CACHE="/c/ci/cache/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
if [ -f "$CACHE" ]; then
echo "Using cached PG zip: $CACHE"
cp "$CACHE" /tmp/pg.zip
else
curl -fsSL "$URL" -o /tmp/pg.zip
fi
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
sha256sum /tmp/pg.zip
unzip -o /tmp/pg.zip -d /tmp/pg
cp /tmp/pg/pgsql/bin/pg_dump.exe /tmp/pg/pgsql/bin/pg_restore.exe /tmp/pg/pgsql/bin/psql.exe "$OUT"/
cp /tmp/pg/pgsql/bin/*.dll "$OUT"/
- name: Build MariaDB client tools (bundled)
shell: bash
run: |
set -euo pipefail
MARIADB_VER="11.4.5"
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
mkdir -p "$OUT"
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
CACHE="/c/ci/cache/mariadb-${MARIADB_VER}-winx64.zip"
if [ -f "$CACHE" ]; then
echo "Using cached MariaDB zip: $CACHE"
cp "$CACHE" /tmp/mariadb.zip
else
curl -fsSL "$URL" -o /tmp/mariadb.zip
fi
sha256sum /tmp/mariadb.zip
unzip -o /tmp/mariadb.zip -d /tmp/mariadb
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
# rustc resolves `link.exe` via PATH for the windows-msvc target, but Git
# for Windows ships GNU coreutils with its own `link.exe` that shadows
# MSVC's linker (`link: extra operand ...`). Load the MSVC dev
# environment so PATH/LIB/INCLUDE point at the real MSVC toolchain.
# Fails fast here (instead of at link time) if the toolchain is missing.
- name: Set up MSVC build environment
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build installers
shell: cmd
run: |
call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
bun run --filter gridline-desktop tauri build
- name: Create release and upload Windows assets
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
AUTH="Authorization: token ${GITHUB_TOKEN}"
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
cd "$BUNDLE"
for f in nsis/*-setup.exe msi/*.msi; do
[ -f "$f" ] || { echo "missing $f"; exit 1; }
name=$(basename "$f")
echo "uploading $name"
curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary "@$f" \
"${API}/releases/${REL_ID}/assets?name=${name}"
done
echo "Windows assets uploaded to release ${REL_ID}"
@@ -3,12 +3,15 @@ name: Bug report
about: Report a bug or unexpected behavior in Gridline so we can fix it about: Report a bug or unexpected behavior in Gridline so we can fix it
title: "[Bug]: " title: "[Bug]: "
labels: ["bug"] labels: ["bug"]
assignees: '' assignees: ['AdrianBonpin']
--- ---
<!-- <!--
Thank you for reporting a bug! A complete report is the fastest way to get it fixed. Thank you for reporting a bug! A complete report is the fastest way to get it fixed.
Please fill in every section you can — the environment table alone often closes issues. Please fill in every section you can — the environment table alone often closes issues.
Is this a request for something new instead? Use the
Feature request or Improvement templates — those get triaged faster.
--> -->
## Before you submit ## Before you submit
@@ -58,7 +61,9 @@ assignees: ''
<!-- <!--
Paste the error text, console output, or a crash report here. Paste the error text, console output, or a crash report here.
On macOS: Console.app → Gridline. On Windows: Event Viewer → Application. - macOS: Console.app → Gridline
- Windows: Event Viewer → Application
- Linux: run Gridline from a terminal and capture stdout/stderr (or check the app-data dir, e.g. ~/.local/share/…)
Remove anything that looks like a host/user/password/token before pasting. Remove anything that looks like a host/user/password/token before pasting.
--> -->
@@ -76,6 +81,10 @@ assignees: ''
<!-- Anything that gets you unstuck today (e.g. "works when I connect without SSH", "restarting the app fixes it"). Even a partial workaround helps others. --> <!-- Anything that gets you unstuck today (e.g. "works when I connect without SSH", "restarting the app fixes it"). Even a partial workaround helps others. -->
## Related issues / PRs
<!-- Links to related issues, PRs, or roadmap items. -->
## Additional context ## Additional context
<!-- Anything else: does it reproduce with a fresh connection or only one specific database? Did it work in an earlier version? Frequency (always / sometimes)? --> <!-- Anything else: does it reproduce with a fresh connection or only one specific database? Did it work in an earlier version? Frequency (always / sometimes)? Does it happen on the demo SQLite database? -->
@@ -0,0 +1,61 @@
---
name: Feature request
about: Suggest a new feature or capability for Gridline
title: "[Feature]: "
labels: ["enhancement"]
assignees: ['AdrianBonpin']
---
<!--
Thank you for suggesting a feature! 🎉
Before you submit, check that it isn't already shipped or planned:
- README feature list: https://github.com/AdrianBonpin/gridline#features
- Roadmap: https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md
-->
## Before you submit
- [ ] I searched [existing issues](https://github.com/AdrianBonpin/gridline/issues?q=is%3Aissue) and this isn't a duplicate
- [ ] I checked the [Roadmap](https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md) — it isn't already planned
- [ ] I checked the [README feature list](https://github.com/AdrianBonpin/gridline#features) — it isn't already shipped
- [ ] I will **not** paste secrets — redact any credentials as `<REDACTED>`
## Problem
<!-- What can't you do today? Describe the workflow that's blocked or painful. The problem matters more than the solution you have in mind. -->
## Proposed solution
<!-- How should it work? High-level description. Consider where it lives in the UI (home screen, DB viewer, object explorer, query editor, tools). Mockups welcome — see below. -->
## Example scenario
<!-- A concrete walkthrough: "I want to compare row counts across three production schemas, so I open … and …" -->
## Alternatives considered
<!-- What you do today instead: other tools, manual SQL, workarounds. This helps us judge how much it's really needed. -->
## Scope
<!-- Rough size so we can prioritize. -->
- [ ] Small — UI polish / single command
- [ ] Medium — a new view or feature area
- [ ] Large — new DB type, new driver, multi-week effort
## Mockups / screenshots
<!-- Drag & drop sketches or screenshots; ASCII art is fine. If referencing how another tool does it, name the tool (TablePlus, Beekeeper Studio, DB Pro, …). -->
## Related
<!-- Roadmap items, related issues/PRs, competitor references, docs links. -->
## Priority
<!-- Optional: how important is this to you? -->
- [ ] Nice to have
- [ ] Important — would save me time every day
- [ ] Critical — blocks my workflow without it
+40
View File
@@ -0,0 +1,40 @@
---
name: Improvement
about: Suggest a refinement or UX polish for an existing Gridline feature
title: "[Improvement]: "
labels: ["enhancement"]
assignees: ['AdrianBonpin']
---
<!--
For refinements to something that already exists — not brand-new features
(use the Feature request template for those) and not bugs (use Bug report).
Examples: a confusing label, a clunky interaction, a missing keyboard shortcut,
faster scroll, better default, accessibility fix.
-->
## Before you submit
- [ ] I searched [existing issues](https://github.com/AdrianBonpin/gridline/issues?q=is%3Aissue) and this isn't a duplicate
- [ ] This targets an **existing** feature (not a new one)
- [ ] I will **not** paste secrets — redact any credentials as `<REDACTED>`
## What's lacking today?
<!-- The pain point in the current behavior. Point at the exact screen/flow. -->
## Suggested change
<!-- The refinement. What should it look like / behave like? Specific beats vague. -->
## Why it matters
<!-- Who benefits and how (time saved, fewer misclicks, consistency, accessibility, …). -->
## Screenshots / recordings
<!-- Current behavior, plus any mock of the desired behavior. -->
## Related
<!-- Links to related issues/PRs. -->
+10
View File
@@ -0,0 +1,10 @@
# Template chooser settings — see
# https://docs.github.com/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository
blank_issues_enabled: true
contact_links:
- name: Gridline Roadmap
url: https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md
about: Check what's already planned before requesting a feature
- name: README — feature list
url: https://github.com/AdrianBonpin/gridline#features
about: Confirm a feature isn't already shipped
+25
View File
@@ -0,0 +1,25 @@
name: Auto-assign new issues
# Assigns every newly opened issue to the repository owner so nothing
# lands in the inbox untriaged. The `issues` event only fires in the base
# repository (not forks) and this job never checks out code — it only
# talks to the GitHub API, so there is no fork/pwn-request exposure.
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
auto-assign:
name: Assign issue to repo owner
runs-on: ubuntu-latest
steps:
- name: Assign to ${{ github.repository_owner }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_URL: ${{ github.event.issue.html_url }}
OWNER: ${{ github.repository_owner }}
run: gh issue edit "$ISSUE_URL" --add-assignee "$OWNER"
+400 -11
View File
@@ -7,23 +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: builds are UNSIGNED for now (no code-signing certs yet — # SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
# see README "Download a release"). tauri-action automatically signs + # signed + notarized in CI when the Apple secrets below are configured (see
# notarizes when the signing secrets are present, so the moment we add # README "macOS signing & notarization"). When the secrets are absent the mac
# APPLE_CERTIFICATE / APPLE_API_KEY / WINDOWS_CERTIFICATE (or Azure Trusted # jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
# Signing) to repo secrets, future builds are signed — no changes to this # remain unsigned.
# file required. #
# 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:
@@ -40,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'
@@ -58,7 +163,7 @@ jobs:
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
with: with:
workspaces: './src-tauri -> target' workspaces: './desktop/src-tauri -> target'
- name: Install frontend dependencies - name: Install frontend dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -68,7 +173,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
PG_VER="16.4" PG_VER="16.4"
OUT="${GITHUB_WORKSPACE}/src-tauri/resources/pg_tools" OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
mkdir -p "$OUT" mkdir -p "$OUT"
case "${{ matrix.platform }}" in case "${{ matrix.platform }}" in
ubuntu-22.04) ubuntu-22.04)
@@ -132,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"
@@ -167,16 +282,290 @@ jobs:
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; } "$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
done done
- name: Build MariaDB client tools (bundled)
shell: bash
run: |
set -euo pipefail
MARIADB_VER="11.4.5"
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
mkdir -p "$OUT"
case "${{ matrix.platform }}" in
ubuntu-22.04)
sudo apt-get update -y
sudo apt-get install -y cmake build-essential libssl-dev libzstd-dev pkg-config libgnutls28-dev patchelf
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server
# Client-only build: wolfSSL + zlib are vendored. The in-tree
# Connector C still probes GnuTLS on Unix regardless of WITH_SSL,
# so libgnutls28-dev is required at build time.
cmake -DCMAKE_BUILD_TYPE=Release \
-DWITHOUT_SERVER=ON \
-DWITHOUT_TOKUDB=1 \
-DWITHOUT_ROCKSDB=1 \
-DWITHOUT_MROONGA=1 \
-DWITHOUT_SPIDER=1 \
-DWITHOUT_SEQUENCE=1 \
-DWITH_UNIT_TESTS=OFF \
-DWITH_SSL=bundled \
-DWITH_ZLIB=bundled .
make -j"$(nproc)" mariadb-dump mariadb
# Clients build into client/ (stable across MariaDB 10.x/11.x);
# fall back to a broad search if a future version moves them.
for b in mariadb-dump mariadb; do
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
cp "$src" "$OUT"/
done
# Bundle every non-core shared dependency (GnuTLS + its chain,
# libzstd) next to the clients and point the loader at $ORIGIN —
# the same pattern the pg_tools step uses for libpq.
for b in mariadb-dump mariadb; do
ldd "$OUT/$b" | awk '/=> \// {print $3}' | sort -u | while read -r lib; do
case "$(basename "$lib")" in
ld-linux*|libc.so*|libm.so*|libpthread*|libdl.so*|librt.so*|libgcc_s*|libstdc++*|libcrypt.so*|libresolv*|libutil*)
;; # core runtime — present on every glibc system
*)
cp -n "$lib" "$OUT/$(basename "$lib")" 2>/dev/null || true
;;
esac
done
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
done
;;
macos-latest|macos-15-intel)
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server
# macOS connects the in-tree Connector C against brew OpenSSL
# (proven to build+run on the runners); the Linux-only GnuTLS
# probe does not fire here. Absolute brew paths are rewritten to
# @loader_path below so user machines resolve them from the
# resource dir.
if [ "$(uname -m)" = arm64 ]; then
SSL_DIR="/opt/homebrew/opt/openssl"
else
SSL_DIR="/usr/local/opt/openssl"
fi
[ -d "$SSL_DIR" ] || brew install openssl
cmake -DCMAKE_BUILD_TYPE=Release \
-DWITHOUT_SERVER=ON \
-DWITHOUT_TOKUDB=1 \
-DWITHOUT_ROCKSDB=1 \
-DWITHOUT_MROONGA=1 \
-DWITHOUT_SPIDER=1 \
-DWITHOUT_SEQUENCE=1 \
-DWITH_UNIT_TESTS=OFF \
-DWITH_SSL="$SSL_DIR" \
-DWITH_ZLIB=bundled .
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
for b in mariadb-dump mariadb; do
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
cp "$src" "$OUT"/
done
# Rewrite absolute Homebrew dylib deps to @loader_path so the
# bundled clients (and OpenSSL + their own deps) resolve from the
# resource dir on user machines. Fixed-point loop: each pass
# copies newly-seen dylibs and rewrites their parents' refs.
# Ad-hoc re-sign after each edit — arm64 dyld refuses to load a
# dylib whose (Homebrew) code signature was invalidated.
for pass in 1 2 3 4 5 6; do
for dylib in "$OUT"/*.dylib "$OUT"/mariadb-dump "$OUT"/mariadb; do
[ -f "$dylib" ] || continue
# `|| true` — with pipefail, grep exits 1 on zero matches.
otool -L "$dylib" | awk 'NR>1 {print $1}' | grep -E '^/(usr/local|opt/homebrew)' | while read -r lib; do
base=$(basename "$lib")
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
done || true
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
else
codesign --force --sign - "$dylib" 2>/dev/null || true
fi
done
done
;;
windows-latest)
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
curl -fsSL "$URL" -o /tmp/mariadb.zip
# TODO(RELEASE): pin the sha256 printed on the first CI run, then
# uncomment the check so later releases verify the download.
# EXPECTED="<sha256 of mariadb-${MARIADB_VER}-winx64.zip>"
# echo "$EXPECTED /tmp/mariadb.zip" | sha256sum -c -
sha256sum /tmp/mariadb.zip
unzip -o /tmp/mariadb.zip -d /tmp/mariadb
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
# The clients' only runtime dependency is the client library
# (lib/libmariadb.dll); the winx64 package statically links
# wolfSSL, so there are no OpenSSL DLLs to bundle. bin/*.dll is
# just the embedded server.dll, which the client tools don't need.
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
;;
esac
(cd "$OUT" && sha256sum * | tee checksums.txt)
# Resolve the per-platform binary suffix WITHOUT a command
# substitution (see the pg_tools step for why).
if [ "${{ matrix.platform }}" = windows-latest ]; then
BIN_EXT=".exe"
else
BIN_EXT=""
fi
for b in mariadb-dump mariadb; do
f="$OUT/${b}${BIN_EXT}"
test -f "$f" || { echo "missing $f"; exit 1; }
done
# Sanity: every tool must run (loader path is correct) — this catches
# a missing shared library before we ship a broken bundle.
for b in mariadb-dump mariadb; do
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
done
- name: Build and upload to GitHub Release - name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tagName: ${{ github.ref_name }} projectPath: desktop
releaseName: 'Gridline ${{ github.ref_name }}' tagName: ${{ env.RELEASE_TAG }}
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.
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the # Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
# 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
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
# render Casks/gridline.rb from the template, and push it. Users then get
# the new version via `brew upgrade --cask gridline`.
#
# Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
# key on the tap repo (the default GITHUB_TOKEN cannot push to a different
# 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.
bump-cask:
# 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
permissions:
contents: read
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
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4
- name: Resolve version and DMG checksums
id: meta
if: steps.tap.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${RELEASE_TAG#v}"
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
echo "version=$VERSION" >> "$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"
- name: Checkout tap repo
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4
with:
repository: AdrianBonpin/homebrew-gridline
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
path: tap
- name: Render and push cask
if: steps.tap.outputs.enabled == 'true'
env:
VERSION: ${{ steps.meta.outputs.version }}
SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
SHA_INTEL: ${{ steps.meta.outputs.sha_intel }}
run: |
mkdir -p tap/Casks
./scripts/render-cask.sh "$VERSION" "$SHA_ARM" "$SHA_INTEL" > tap/Casks/gridline.rb
cat tap/Casks/gridline.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/gridline.rb
if git diff --cached --quiet; then
echo "cask already up to date"
else
git commit -m "gridline $VERSION"
git push
fi
+10
View File
@@ -38,3 +38,13 @@ docs/superpowers/
.env .env
!.env.example !.env.example
.worktrees/ .worktrees/
# Bundled DB client tools (built/downloaded during release, never committed)
desktop/src-tauri/resources/pg_tools/pg_dump
desktop/src-tauri/resources/pg_tools/pg_restore
desktop/src-tauri/resources/pg_tools/psql
desktop/src-tauri/resources/pg_tools/libpq.5.dylib
desktop/src-tauri/resources/mysql_tools/mariadb
desktop/src-tauri/resources/mysql_tools/mariadb-dump
desktop/src-tauri/resources/mysql_tools/libcrypto.3.dylib
desktop/src-tauri/resources/mysql_tools/libssl.3.dylib
+137 -77
View File
@@ -43,55 +43,68 @@ Gridline is an **open-source, cross-platform database GUI client** for PostgreSQ
``` ```
gridline/ gridline/
├── src/ # React frontend (TypeScript) ├── package.json # Workspace root: bun workspaces ["desktop", "www"], orchestration scripts
│ ├── components/ # Reusable UI components ├── desktop/ # Tauri desktop app (React frontend + Rust backend)
│ │ ├── layout/ # App shell, sidebar, tabs │ ├── package.json # Frontend deps + scripts (name: gridline-desktop)
│ │ ├── editor/ # Monaco wrapper, autocomplete │ ├── index.html
│ │ ├── grid/ # Data grid, filters, export │ ├── vite.config.ts # Vite + Tailwind v4 + Tauri dev server (port 1420)
│ │ ├── tree/ # Workspace/object explorer tree │ ├── vitest.config.ts
│ │ └── ui/ # Primitives (buttons, modals, inputs) │ ├── tsconfig.json
│ ├── stores/ # Zustand/Jotai stores │ ├── tsconfig.node.json
│ ├── hooks/ # Custom hooks (useConnection, useQuery, etc.) │ ├── bunfig.toml # bun test preload
│ ├── lib/ # Utilities, types, Tauri bindings │ ├── src/ # React frontend (TypeScript)
│ │ ├── commands.ts # Typed wrappers around Tauri invoke() │ │ ├── components/ # Reusable UI components
│ │ ├── types.ts # Shared TypeScript interfaces │ │ │ ├── layout/ # App shell, sidebar, tabs
│ │ └── utils.ts # Formatting, validation helpers │ │ │ ├── editor/ # Monaco wrapper, autocomplete
│ ├── App.tsx │ │ │ ├── grid/ # Data grid, filters, export
│ ├── main.tsx │ │ │ ├── tree/ # Workspace/object explorer tree
│ └── index.css # Tailwind directives + custom theme tokens │ │ │ └── ui/ # Primitives (buttons, modals, inputs)
├── src-tauri/ # Rust backend │ │ ├── stores/ # Zustand/Jotai stores
│ ├── src/ │ │ ├── hooks/ # Custom hooks (useConnection, useQuery, etc.)
│ │ ├── main.rs # Binary entry point │ │ ├── lib/ # Utilities, types, Tauri bindings
│ │ ├── lib.rs # Tauri builder, command registration │ │ │ ├── commands.ts # Typed wrappers around Tauri invoke()
│ │ ├── db/ # Connection pooling, query execution │ │ │ ├── types.ts # Shared TypeScript interfaces
│ │ │ ├── mod.rs │ │ │ └── utils.ts # Formatting, validation helpers
│ │ │ ├── pool.rs # Connection pool manager │ │ ├── App.tsx
│ │ │ └── introspection.rs # Schema/system catalog queries │ │ ├── main.tsx
│ │ ├── commands/ # Tauri #[tauri::command] handlers │ │ └── index.css # Tailwind directives + custom theme tokens
│ │ │ ├── mod.rs │ └── src-tauri/ # Rust backend
│ │ │ ├── connections.rs # CRUD for saved connections │ ├── src/
│ │ │ ├── query.rs # SQL execution │ │ ├── main.rs # Binary entry point
│ │ │ ├── schema.rs # Object tree introspection │ │ ├── lib.rs # Tauri builder, command registration
│ │ │ ├── schema_graph.rs # ER diagram / relationship graph │ │ ├── db/ # Connection pooling, query execution
│ │ │ ├── backup.rs # pg_dump / pg_restore wrappers │ │ │ ├── mod.rs
│ │ │ └── workspace.rs # Workspace/folder persistence │ │ │ ├── pool.rs # Connection pool manager
│ │ ├── models/ # Serde structs shared across commands │ │ │ └── introspection.rs # Schema/system catalog queries
│ │ │ ├── mod.rs │ │ ├── commands/ # Tauri #[tauri::command] handlers
│ │ │ ├── connection.rs │ │ │ ├── mod.rs
│ │ │ ├── query.rs │ │ │ ├── connections.rs # CRUD for saved connections
│ │ │ ├── db_viewer.rs # DB viewer types (SchemaGraph, TableNode, etc.) │ │ │ ├── query.rs # SQL execution
│ │ │ └── workspace.rs │ │ │ ├── schema.rs # Object tree introspection
│ │ └── store/ # SQLite local persistence layer │ │ │ ├── schema_graph.rs # ER diagram / relationship graph
│ │ ├── mod.rs │ │ │ ├── backup.rs # pg_dump / pg_restore wrappers
│ │ └── migrations.rs │ │ │ └── workspace.rs # Workspace/folder persistence
│ ├── Cargo.toml │ │ ├── models/ # Serde structs shared across commands
│ ├── tauri.conf.json │ │ │ ├── mod.rs
│ └── capabilities/ # Tauri capability permissions │ │ │ ├── connection.rs
├── public/ # Static frontend assets │ │ │ ├── query.rs
├── package.json │ │ │ ├── db_viewer.rs # DB viewer types (SchemaGraph, TableNode, etc.)
├── tsconfig.json │ │ │ └── workspace.rs
├── vite.config.ts │ │ └── store/ # SQLite local persistence layer
├── tailwind.config.ts │ │ ├── mod.rs
│ │ └── migrations.rs
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── capabilities/ # Tauri capability permissions
├── www/ # Website: marketing site + docs (Astro + Tailwind)
│ ├── package.json # name: gridline-www
│ ├── astro.config.mjs
│ ├── tsconfig.json
│ └── src/ # Astro pages/components/styles
├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml
├── docs/ # Project docs
├── screenshots/ # README marketing images
├── scripts/ # Release tooling (render-cask.sh)
└── AGENTS.md # This file └── AGENTS.md # This file
``` ```
@@ -141,35 +154,59 @@ gridline/
### Commands ### Commands
```bash ```bash
bun install # Install frontend dependencies bun install # Install all workspace deps (repo root)
bun run dev # Vite dev server only (no Tauri) bun run dev:desktop # Vite dev server only (no Tauri)
bun run tauri dev # Full Tauri app with hot-reload bun run tauri:dev # Full Tauri app with hot-reload
bun run tauri build # Production build bun run --filter gridline-desktop build # Desktop production build
cargo build # Rust backend only (from src-tauri/) bun run dev:www # Website dev server (Astro)
cargo test # Rust tests bun run build:www # Website static build (www/dist/)
bun run test # Desktop frontend tests (vitest)
cargo build # Rust backend only (from desktop/src-tauri/)
cargo test # Rust tests (from desktop/src-tauri/)
``` ```
### 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:
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
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.
### Releases ### Releases
Cut a release from the **`prod`** branch (never feature branches) by tagging it — `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 `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` - 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)
- `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version - `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.6/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md). - **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. - **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
- **macOS is Developer-ID signed + notarized** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section).
### Adding a Tauri Command ### Adding a Tauri Command
1. Define the command function in the appropriate `src-tauri/src/commands/` module 1. Define the command function in the appropriate `desktop/src-tauri/src/commands/` module
2. Register it in `src-tauri/src/lib.rs` via `.invoke_handler(tauri::generate_handler![...])` 2. Register it in `desktop/src-tauri/src/lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
3. Create a typed wrapper function in `src/lib/commands.ts` 3. Create a typed wrapper function in `desktop/src/lib/commands.ts`
4. Call the wrapper from your React component/store 4. Call the wrapper from your React component/store
### Adding a New Dependency ### Adding a New Dependency
- **Frontend:** `bun add <package>` (runtime) or `bun add -d <package>` (dev) - **Frontend:** `bun add <package>` (runtime) or `bun add -d <package>` (dev)
- **Rust:** Add to `src-tauri/Cargo.toml` under `[dependencies]` - **Rust:** Add to `desktop/src-tauri/Cargo.toml` under `[dependencies]`
**Website (`www/`) deps — keep the standalone lockfile in sync:** `www/` is a bun workspace member, so `bun add` updates the **root** `bun.lock`, but Railpack (Dokploy, build path `/www`) uses the **standalone `www/bun.lock`**. After adding/removing a dep in `www/`, regenerate `www/bun.lock` so the deployed build stays deterministic:
```bash
TMP=$(mktemp -d) && cp www/package.json "$TMP/package.json" && cd "$TMP" && bun install --lockfile-only && cp bun.lock ../../www/bun.lock && cd - && rm -rf "$TMP"
```
(Adjust the `../../` relative path to point back at `www/` from the temp dir.) Commit both `bun.lock` and `www/bun.lock` together.
### Testing Strategy ### Testing Strategy
@@ -202,19 +239,21 @@ 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 | | 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) |
### Home Screen & Organization ### Home Screen & Organization
| Feature | Status | Details | | Feature | Status | Details |
@@ -236,7 +275,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) | | Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) |
| Favorites / Recent connections | ✅ | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) | | Favorites / Recent connections | ✅ | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) |
| Connection status indicator on cards | ✅ | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s | | Connection status indicator on cards | ✅ | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s |
| Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), Manage submenu (Edit… / Duplicate / Delete…) | | Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), **Copy connection URL** (with password) / **Copy connection URL (no password)** (builds a `postgresql://`/`mysql://`/`sqlite://`/`redis://` string from the saved connection, percent-encoded, sslmode appended for PG; password fetched from keychain on demand), Manage submenu (Edit… / Duplicate / Delete…) |
### Database Viewer ### Database Viewer
| Feature | Status | Details | | Feature | Status | Details |
@@ -254,25 +293,39 @@ 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) | ✅ | Client-side Blob download of visible rows | | 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) |
| 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 `drop_table` via the queue — 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 |
| Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc | | Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc |
| FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it | | FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it |
| Maintenance actions (VACUUM/ANALYZE/REINDEX) | ✅ | Right-click table → VACUUM / ANALYZE / REINDEX (v0.7.7) |
### Table Editor & Roles (v0.7.7)
| Feature | Status | Details |
| :--- | :---: | :--- |
| Create Table (visual editor) | ✅ | "Create Table…" workspace tab: columns grid (name / type dropdown / default / PK per row, drag-to-reorder, add/remove), single + composite PK (first column auto-PK), shorthand PG types (`int`/`int8`/`int2`/`float8`/`bool`), auto-increment gating, live Monaco SQL preview, transparent selects + form-row focus `amber-400/20` |
| Edit Table (column diff) | ✅ | Same grid on existing tables; Stage diffs old vs new columns → one queue item per statement: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `SET|DROP DEFAULT`, `SET|DROP NOT NULL`; stale-write guard (re-fetches live columns, refuses if changed) |
| Column reorder (atomic rebuild) | ✅ | Drag-to-reorder triggers a table rebuild: single transaction, preserves constraints/indexes/FKs/grants/sequences (copied DDL), fail-closed when triggers/RLS policies/inheritance/partitioning/generated columns are present (`get_table_rebuild_readiness`) |
| FK management | ✅ | Multi-column FK composer (local col → ref col pairs, referenced PKs first, type-match preview `localType → refType`, auto-named constraints, ref-table picker with `name (type)` options); cross-schema references; ON DELETE / ON UPDATE actions; create mode inlines FKs into the single CREATE TABLE change, edit mode stages ALTERs; FK rows in the form with Edit/Remove (queued changes revert, DB FKs stage DROP CONSTRAINT) |
| Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section |
| Roles & grants management | ✅ | Objects → Roles: list non-system roles (attributes incl. connection limit — `rolconnlimit::int8` cast fix), role detail with attribute grid + memberships + privilege explorer; create/edit/drop roles staged through the queue |
| Role privilege explorer | ✅ | Per-role privileges grouped by object class (tables/sequences/routines/schemas/databases) with collapsible sections — collapsed shows first 5 + fade, expand shows all, chevron hidden when ≤5; GRANT/REVOKE composer (object class + schema + name, WITH GRANT OPTION); aclexplode-based queries (`role_sequence_grants` removed in PG 15, `schema_privileges` never existed) |
| SQLite table editor (Create/Edit Table) | ✅ | Visual Create Table / Edit Table extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (v0.7.8) |
### Object Explorer (non-table objects) ### Object Explorer (non-table objects)
| Feature | Status | Details | | Feature | Status | Details |
@@ -294,6 +347,9 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) | | Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) |
| Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) | | Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) |
| 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) |
| 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 |
@@ -303,7 +359,8 @@ 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) |
| 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 |
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor | | Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
@@ -319,7 +376,9 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) | | Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
| SQLite .dump | ❌ | | | 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) |
| 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
@@ -327,6 +386,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| :--- | :---: | :--- | | :--- | :---: | :--- |
| Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) | | Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) |
| Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) | | Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) |
| Windows/Linux title bar fix | ✅ | macOS-only overlay drag strip (`h-7` in `App.tsx`) gated to macOS; Windows/Linux use the native title bar for dragging (v0.7.8) |
| Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) | | Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) |
| Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` | | Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` |
| Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated | | Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated |
@@ -339,7 +399,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| More keyboard shortcuts | ❌ | Only 2 configurable actions | | More keyboard shortcuts | ❌ | Only 2 configurable actions |
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` | | Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` |
| SSH key management | ❌ | Only path inputs, no key file reading | | SSH key management | ❌ | Only path inputs, no key file reading |
| Settings export/import | ❌ | | | Settings export/import | ✅ | Export/import settings (theme, accent, editor options, page sizes, defaults) as a JSON file (v0.7.8) |
### Demo & Onboarding ### Demo & Onboarding
| Feature | Status | Details | | Feature | Status | Details |
+197 -100
View File
@@ -1,16 +1,15 @@
<p align="center"> <p align="center">
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" /> <img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
</p> </p>
<p>
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
</p>
<h1>Gridline</h1> <h1>Gridline</h1>
<p> <p>
<i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br /> <i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br />
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync. Unlimited connections, tabs, and saved queries — with first-class backup, restore, and DB-to-DB sync for PostgreSQL, MySQL, and SQLite.
</p>
<p>
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
</p> </p>
<p> <p>
@@ -19,33 +18,46 @@
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a> <a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a> <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
<a href="https://github.com/adrianbonpin/gridline/stargazers"><img src="https://img.shields.io/github/stars/adrianbonpin/gridline?style=for-the-badge&logo=github&label=Stars" alt="GitHub stars" /></a> <a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
</p> </p>
<p> <p>
<a href="https://github.com/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge&logo=github&logoColor=white" alt="Download Latest" /></a> <a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://github.com/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge&logo=github&logoColor=white" alt="Open an Issue" /></a> <a href="https://github.com/AdrianBonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
</p> </p>
--- ---
## Download ## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.6**): 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.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-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.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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) |
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings. **macOS via Homebrew** (recommended for Mac users):
```bash
# one-time setup (the tap is hosted on GitHub):
brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
brew install --cask gridline
```
The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
> **macOS first launch:** 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.6 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:
@@ -62,6 +74,10 @@ Gridline is a modern, open-source database GUI client built with [Tauri 2.0](htt
- **No caps** on connections, tabs, or saved queries. - **No caps** on connections, tabs, or saved queries.
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync. - **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
- **Backup, restore & sync for every database** — PostgreSQL, MySQL (`mysqldump` / bundled MariaDB clients), and SQLite (`.dump`), with direct DB-to-DB sync between live connections.
- **Excel export** — CSV, JSON, SQL, Markdown, and `.xlsx` from any data grid.
- **Cancel long-running queries** — a per-connection cancel button instead of waiting it out or killing the app.
- **SQLite table editor** — the visual Create Table / Edit Table flow now works on SQLite too (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`).
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL. - **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures. - **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation. - **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
@@ -83,6 +99,27 @@ Gridline is built for developers and small teams who manage multiple database en
## Recent Changes ## Recent Changes
<details>
<summary>View the full changelog</summary>
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
- **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump.
- **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump.
- **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump. - **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
- **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops. - **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops.
@@ -97,12 +134,17 @@ Gridline is built for developers and small teams who manage multiple database en
> See the full history in the [roadmap](#roadmap) below or the [git log](./commits). > See the full history in the [roadmap](#roadmap) below or the [git log](./commits).
</details>
--- ---
## Key Features ## Key Features
### Connections & Workspace ### Connections & Workspace
<details>
<summary>Show features</summary>
- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically. - **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically.
- **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint. - **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint.
- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections. - **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections.
@@ -111,8 +153,13 @@ Gridline is built for developers and small teams who manage multiple database en
- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support. - **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support.
- **Connection status** — on-demand per-card test with real server version and latency. - **Connection status** — on-demand per-card test with real server version and latency.
</details>
### Schema Explorer ### Schema Explorer
<details>
<summary>Show features</summary>
- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags. - **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags.
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation. - **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher. - **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
@@ -122,9 +169,17 @@ Gridline is built for developers and small teams who manage multiple database en
- **Copy as DDL** — `CREATE` DDL for every browsable object type. - **Copy as DDL** — `CREATE` DDL for every browsable object type.
- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops. - **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops.
- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns. - **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
- **Table creator & editor** — build or alter tables from a visual columns grid: type dropdowns, drag-to-reorder, single/composite primary keys, defaults, nullability — with a live SQL preview and column-diff staging.
- **FK management** — create/edit/drop foreign keys with cross-schema references and ON DELETE/UPDATE actions, inlined into CREATE TABLE.
- **Table options** — tablespace and row-level security per table.
</details>
### Data Grid ### Data Grid
<details>
<summary>Show features</summary>
- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`. - **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`.
- **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`. - **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`.
- **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab. - **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab.
@@ -135,8 +190,13 @@ Gridline is built for developers and small teams who manage multiple database en
- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows. - **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows.
- **Auto-refresh** — configurable interval timer. - **Auto-refresh** — configurable interval timer.
</details>
### Query Workbench ### Query Workbench
<details>
<summary>Show features</summary>
- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete. - **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete.
- **Custom query execution** — arbitrary SQL with destructive-query confirmation. - **Custom query execution** — arbitrary SQL with destructive-query confirmation.
- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`. - **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`.
@@ -144,12 +204,24 @@ Gridline is built for developers and small teams who manage multiple database en
- **Saved queries** — name, folder, and manage them in the Queries view. - **Saved queries** — name, folder, and manage them in the Queries view.
- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all. - **Changes queue** — stage edits, review generated SQL, revert per change, then commit all.
</details>
### PostgreSQL Admin Tools ### PostgreSQL Admin Tools
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress. <details>
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation. <summary>Show features</summary>
- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections.
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback. - **Visual Backup** — `pg_dump` (format selector, schema filter, no-owner), `mysqldump`, and SQLite `.dump` wrappers with real-time progress.
- **Visual Restore** — `pg_restore` / `mysql` / SQLite restore with clean toggle and destructive confirmation.
- **DB-to-DB Sync** — pipe dump → restore between two live connections (PostgreSQL, MySQL, and SQLite).
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` plus `mariadb-dump`/`mariadb` ship with the app; system tools are preferred when present, bundled tools are the fallback.
- **Roles & grants** — create/edit/drop roles with attributes; a per-role privilege explorer grouped by object class (tables, sequences, routines, schemas, databases) with collapsible lists and GRANT/REVOKE staging.
- **Table maintenance** — VACUUM, ANALYZE, and REINDEX from the table menu.
- **Excel export** — hand-rolled `.xlsx` writer (inline strings, formula-injection safe) alongside CSV/JSON/SQL/Markdown in the grid toolbar and table menu.
- **Cancel long-running queries** — PG cancel request / MySQL `KILL QUERY` / SQLite interrupt, wired to the toolbar Cancel button.
- **Settings export / import** — share theme, accent, editor options, page sizes, and shortcuts across machines (JSON).
</details>
--- ---
@@ -200,41 +272,41 @@ Gridline is built for developers and small teams who manage multiple database en
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026). Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline | | Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
| :----------------------------- | :--------------------------: | :-----------------------------: | :-------------------------: | :--------------------------------------------------------: | | :----------------------------- | :---------------------------: | :-------------------------------: | :----------------------------------: | :-----------------------------------------------------------------: |
| Open tabs | 3 | Unlimited | 2 | **Unlimited** | | Open tabs | 3 | Unlimited | 2 | **Unlimited** |
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** | | Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** | | Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** | | Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** |
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** | | `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** | | DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
| Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** | | Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** |
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** | | ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** |
| SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** | | SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** |
| OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** | | OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** |
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** | | Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
| Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** | | Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** |
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** | | Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** | | Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
*Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters.* _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters._
--- ---
## Tech Stack ## Tech Stack
| Layer | Technology | Role | | Layer | Technology | Role |
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | | :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) | | **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution | | **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution |
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite | | **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` | | **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI | | **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design | | **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores | | **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing | | **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing |
| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization | | **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization |
| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state | | **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state |
--- ---
@@ -242,32 +314,48 @@ Capabilities below are fact-checked against each vendor's official docs and pric
### Download a release ### Download a release
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/adrianbonpin/gridline/releases) page. Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/adrianbonpin/gridline/issues/new) when you hit them. > ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
#### Installers are unsigned (for now) #### macOS signing & notarization
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet. Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee: Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
- **macOS:** right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version. **One-time setup** (only the repo owner needs to do this):
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
- **Linux:** no warning — install and run normally.
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed. 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**.
3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
| Secret | Value |
| :--------------------------- | :------------------------------------------------------------------------------------------------- |
| `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
| `APPLE_API_KEY` | App Store Connect **Key ID** |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
`scripts/release-mac.sh` is the **local fallback** path (used before GitHub Actions took over macOS builds, and still handy for a signed build without burning CI minutes). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
**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.6**): 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.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.6_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.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.6-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.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.
@@ -277,25 +365,28 @@ 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.6 git tag v0.7.15
git push origin v0.7.6 git push origin v0.7.15
``` ```
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it 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).
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names. 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.
### Build from source ### Build from source
```bash ```bash
# 1. Clone the repository # 1. Clone the repository
git clone https://github.com/adrianbonpin/gridline.git git clone https://github.com/AdrianBonpin/gridline.git
cd gridline cd gridline
# 2. Install frontend dependencies # 2. Install all workspace dependencies
bun install bun install
# 3. Run in development mode with hot-reload # 3. Run in development mode with hot-reload (from desktop/)
cd desktop
bun run tauri dev bun run tauri dev
# 4. Build for production # 4. Build for production
@@ -315,17 +406,21 @@ bun run tauri build
## Development ## Development
```bash ```bash
# Frontend only (Vite dev server) # Install all workspace dependencies (from the repo root)
bun run dev bun install
# Full Tauri app with hot-reload # Full Tauri app with hot-reload (from desktop/)
cd desktop
bun run tauri dev bun run tauri dev
# Production build # Production build
bun run tauri build bun run tauri build
# Frontend only (Vite dev server)
bun run dev
# Rust backend only # Rust backend only
cd src-tauri cd desktop/src-tauri
cargo build cargo build
# Run tests # Run tests
@@ -336,33 +431,35 @@ cargo test
``` ```
gridline/ gridline/
├── src/ # React frontend ├── package.json # Bun workspace root (desktop + www)
│ ├── components/ # Reusable UI components ├── desktop/ # Tauri desktop app (React + Rust)
│ ├── stores/ # Zustand/Jotai state stores │ ├── src/ # React frontend
│ ├── hooks/ # Custom React hooks │ │ ├── components/ # Reusable UI components
│ ├── lib/ # Utilities, types, Tauri bindings │ │ ├── stores/ # Zustand/Jotai state stores
│ ├── App.tsx # Root component │ │ ├── hooks/ # Custom React hooks
│ └── main.tsx # Entry point │ │ ├── lib/ # Utilities, types, Tauri bindings
├── src-tauri/ # Rust backend │ │ ├── App.tsx # Root component
│ ├── src/ │ │ └── main.tsx # Entry point
│ │ ├── main.rs # Entry point │ ├── src-tauri/ # Rust backend
│ │ ├── lib.rs # Tauri command registration │ │ ├── src/
│ │ ├── db/ # Database connection & pooling │ │ │ ├── main.rs # Entry point
│ │ ├── commands/ # Tauri IPC command handlers │ │ │ ├── lib.rs # Tauri command registration
│ │ └── models/ # Data structures & serde types │ │ │ ├── db/ # Database connection & pooling
│ ├── Cargo.toml │ │ │ ├── commands/ # Tauri IPC command handlers
│ └── tauri.conf.json │ │ │ └── models/ # Data structures & serde types
├── public/ # Static assets │ │ ├── Cargo.toml
├── package.json │ │ └── tauri.conf.json
├── tsconfig.json │ ├── package.json
└── vite.config.ts │ ├── tsconfig.json
│ └── vite.config.ts
└── www/ # Website (Astro + Tailwind)
``` ```
--- ---
## Roadmap ## Roadmap
The full plan — in-development (v0.7.6), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. The full plan — next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
Highlights of what's next: Highlights of what's next:
@@ -398,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.
- Report a bug via the **[issue template](.github/ISSUE_TEMPLATE/bug_report.md)** — it walks you through environment details (OS, Gridline version, DB type/version, connection method) so we can reproduce issues quickly. Opening a [new issue](https://github.com/AdrianBonpin/gridline/issues/new) pre-fills the template automatically. - 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.
--- ---
+155 -107
View File
@@ -6,6 +6,157 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## ✅ Shipped (0.8.0)
- **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.
- **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 (unreleased)
- **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`)"*.
- **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.
- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
## 📋 In the queue
### Full Redis Support
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)
- Value editors per type: string, list, hash, set, zset, stream, JSON
- Inline add / edit / delete keys, TTL management, flush
- Key expiry tracking and live refresh
### Additional Database Types
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).
### Managed Database Support (beyond Supabase / Neon)
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
### Query Workbench Upgrades
Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
### Schema & Data Tooling
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 *(PostgreSQL-only today)*
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
## 🔮 Planned
### AI Integration (BYOK)
Bring-your-own-key — no bundled model, no paywall, key stored in the OS keychain like DB passwords.
- Natural-language → SQL generation (schema-aware)
- Chat with your database (explain query results, error messages)
- Query explanations and schema summaries
- AI-generated charts from result sets
- Privacy-first: only the SQL/text you choose is sent to your provider; write-queries blocked by default, destructive actions confirmed before execution
### Website & Docs
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)
### UI/UX Improvements (rolling)
Continuous polish, tracked as issues rather than one-off milestones:
- Empty states, error surfacing, and microcopy
- Keyboard shortcut audit + more configurable actions
- Performance passes on the grid, object tree, and large schemas
- Accessibility (contrast, focus states, screen-reader labels)
- 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
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
- **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
---
## ✅ 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)
### Admin follow-up
- **PostgreSQL users/roles + grants management** — create/edit/drop roles with attributes; a per-role **privilege explorer** grouped by object class (tables, sequences, routines, schemas, databases) with collapsible sections (5-entry preview + fade, expand to show all) and GRANT/REVOKE staging through the changes queue (DB Pro has this at 0% on their roadmap — a differentiator to hold)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
### Table & relationship management
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, type dropdowns with shorthand PG types, drag-to-reorder, add/remove rows) with a live Monaco SQL preview; multi-column PRIMARY KEY on create
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
- **FK management** — multi-column FK composer (local column → referenced column pairs, referenced PKs first, type-match preview) with cross-schema references and ON DELETE / ON UPDATE actions; inlined into CREATE TABLE in create mode, separate ALTER staging in edit mode; FK rows in the form with Edit/Remove
- **Related niceties** — column reordering via atomic table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning), table options (tablespace, row-level security), changes queue auto-refreshes the object tree after schema-modifying commits
- **Version bump** 0.7.6 → **0.7.7**.
## ✅ Shipped (0.7.6) ## ✅ Shipped (0.7.6)
- **Full Object Management (PostgreSQL)** — create/edit/drop for every PostgreSQL object type (enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints), staged through the changes queue with generated-SQL previews. - **Full Object Management (PostgreSQL)** — create/edit/drop for every PostgreSQL object type (enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints), staged through the changes queue with generated-SQL previews.
@@ -34,116 +185,13 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
- **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 below): 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).
## 🎯 Next up ## ✅ Shipped — earlier releases (pre-0.7.0)
### Admin follow-up (0.7.7)
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
### Table & relationship management (next)
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, add/remove rows) with a live SQL preview
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
- **Relationships between tables & schemas** — FK create/edit/drop from a table editor (including cross-schema FKs) and schema-level relationship maintenance
- **Related niceties** — column reordering (PG requires a table rebuild — decide semantics), table options (tablespace, row-level security), multi-column PRIMARY KEY on create
## 📋 In the queue
### 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.
- Key browser (pattern search, filter by type, TTL display, key count)
- Value editors per type: string, list, hash, set, zset, stream, JSON
- Inline add / edit / delete keys, TTL management, flush
- Key expiry tracking and live refresh
### Additional Database Types
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work)
- **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)
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
- **PlanetScale** (Vitess/MySQL)
- **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
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **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)
### Schema & Data Tooling
- **SQLite `.dump` support** — match the pg_dump UX for SQLite
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
- **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)*
- **Backup/Restore/Sync for MySQL & SQLite** — pg_dump tooling is PostgreSQL-only today *(deferred from 0.7.0)*
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
## 🔮 Planned
### AI Integration (BYOK)
Bring-your-own-key — no bundled model, no paywall, key stored in the OS keychain like DB passwords.
- Natural-language → SQL generation (schema-aware)
- Chat with your database (explain query results, error messages)
- Query explanations and schema summaries
- AI-generated charts from result sets
- Privacy-first: only the SQL/text you choose is sent to your provider; write-queries blocked by default, destructive actions confirmed before execution
### Website & Docs
- Landing page with screenshots, feature tour, and download links
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
- Blog / changelog feed
### UI/UX Improvements (rolling)
Continuous polish, tracked as issues rather than one-off milestones:
- Empty states, error surfacing, and microcopy
- Keyboard shortcut audit + more configurable actions
- Performance passes on the grid, object tree, and large schemas
- Accessibility (contrast, focus states, screen-reader labels)
- 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
Deferred from 0.7.0, slated for this bucket:
- **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)
- **In-app changelog** — "What's new" panel fed from bundled release notes
---
## ✅ Shipped
- 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
@@ -152,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
+642 -1
View File
@@ -4,6 +4,10 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "gridline", "name": "gridline",
},
"desktop": {
"name": "gridline-desktop",
"version": "0.7.10",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -19,6 +23,7 @@
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"fflate": "^0.8.3",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
@@ -27,6 +32,7 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"simple-icons": "^16.27.1", "simple-icons": "^16.27.1",
"sql-formatter": "^15.8.2", "sql-formatter": "^15.8.2",
"tailwindcss": "^4.3.3",
"tauri-plugin-keyring-store-api": "^0.2.0", "tauri-plugin-keyring-store-api": "^0.2.0",
"zustand": "^5.0.14", "zustand": "^5.0.14",
}, },
@@ -45,6 +51,31 @@
"vitest": "^4.1.10", "vitest": "^4.1.10",
}, },
}, },
"www": {
"name": "gridline-www",
"version": "0.0.1",
"dependencies": {
"@astrojs/react": "^6.0.2",
"@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"animejs": "^4.5.0",
"astro": "^7.2.2",
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
},
"devDependencies": {
"@astrojs/sitemap": "^3.7.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"satori": "^0.29.0",
"sharp": "^0.35.3",
"vitest": "^4.1.10",
},
},
}, },
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -57,6 +88,40 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.3.2", "", { "optionalDependencies": { "@astrojs/compiler-binding-darwin-arm64": "0.3.2", "@astrojs/compiler-binding-darwin-x64": "0.3.2", "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.2", "@astrojs/compiler-binding-linux-arm64-musl": "0.3.2", "@astrojs/compiler-binding-linux-x64-gnu": "0.3.2", "@astrojs/compiler-binding-linux-x64-musl": "0.3.2", "@astrojs/compiler-binding-wasm32-wasi": "0.3.2", "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.2", "@astrojs/compiler-binding-win32-x64-msvc": "0.3.2" } }, "sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ=="],
"@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g=="],
"@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag=="],
"@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ=="],
"@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw=="],
"@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q=="],
"@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ=="],
"@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.3.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.2.0" }, "cpu": "none" }, "sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw=="],
"@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q=="],
"@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA=="],
"@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.3.2", "", { "dependencies": { "@astrojs/compiler-binding": "0.3.2" } }, "sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg=="],
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
"@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.3.5", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.2", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "satteri": "^0.9.1" } }, "sha512-CvWVEFAbay7YO+i9SaqDJubipA5ckiVB89QWoMJ5XC0m5CtFg8JwZ7Kau6X9sYY7FZURH0w2l03ISH2jOS/RDQ=="],
"@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="],
"@astrojs/react": ["@astrojs/react@6.0.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.2", "@vitejs/plugin-react": "^5.2.0", "devalue": "^5.8.1", "ultrahtml": "^1.6.0", "vite": "^8.0.13" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-Fvv2UqS7ajFL2Yjd5s4s5PcCBCSSfks4r04rbCHZO5z4pUPwCygV2ketI+iC+1P/sdHANLV3tgDL1wjzty4cJg=="],
"@astrojs/sitemap": ["@astrojs/sitemap@3.7.3", "", { "dependencies": { "sitemap": "^9.0.0", "stream-replace-string": "^2.0.0", "zod": "^4.3.6" } }, "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA=="],
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
@@ -99,6 +164,30 @@
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
"@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="],
"@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA=="],
"@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ=="],
"@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw=="],
"@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q=="],
"@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.9.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ=="],
"@bruits/satteri-win32-arm64-msvc": ["@bruits/satteri-win32-arm64-msvc@0.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg=="],
"@bruits/satteri-win32-x64-msvc": ["@bruits/satteri-win32-x64-msvc@0.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g=="],
"@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="],
"@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
@@ -119,6 +208,12 @@
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -173,10 +268,68 @@
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
"@fontsource-variable/literata": ["@fontsource-variable/literata@5.3.0", "", {}, "sha512-1Jw7xvOcoGyB83lxB2nG4bfNbqtX2/TTbdh9sHAOmU064SvmSdulDtA5RBu5JYWf64fdaKX7xU+z3GO1T2bCHA=="],
"@fontsource-variable/outfit": ["@fontsource-variable/outfit@5.3.0", "", {}, "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ=="],
"@fontsource/outfit": ["@fontsource/outfit@5.3.0", "", {}, "sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA=="], "@fontsource/outfit": ["@fontsource/outfit@5.3.0", "", {}, "sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA=="],
"@fontsource/space-mono": ["@fontsource/space-mono@5.3.0", "", {}, "sha512-FrpBMOVWn3PRdRHlrZWC55X3kZG/2BTH7SM5ZyS/bky3iKya2BturI5lk+t0RoDQL2JyHzURsVq/EisCsxpI5A=="], "@fontsource/space-mono": ["@fontsource/space-mono@5.3.0", "", {}, "sha512-FrpBMOVWn3PRdRHlrZWC55X3kZG/2BTH7SM5ZyS/bky3iKya2BturI5lk+t0RoDQL2JyHzURsVq/EisCsxpI5A=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -191,6 +344,40 @@
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
@@ -243,6 +430,24 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
"@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
"@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
"@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
"@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@@ -319,6 +524,8 @@
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -349,12 +556,28 @@
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
@@ -375,36 +598,100 @@
"@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="],
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
"animejs": ["animejs@4.5.0", "", { "peerDependencies": { "@types/three": ">=0.150.0", "three": ">=0.150.0" }, "optionalPeers": ["@types/three", "three"] }, "sha512-NQimYX+lz8WaXonGS9zVVoviCIAjONeJayxacUditaivYLqyXgGjFKjuyl0aUUhFKm5MriX9ty1TGovNzoJQWA=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"astro": ["astro@7.2.2", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.2", "@astrojs/internal-helpers": "0.10.2", "@astrojs/markdown-satteri": "0.3.5", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-process": "^2.1.1", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.2" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-OvLWCpXpb43lVYcWzSBJ0sk44qcEYYRZCjadtalLfAwb2RaC3P/zT+blZykBw/o6suw6sQQkn00ugN36akD8Mw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.3", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.3", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
"cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
"css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="],
"css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
@@ -433,18 +720,40 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.0", "", {}, "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
"emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="],
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
@@ -455,40 +764,104 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"find-process": ["find-process@2.1.1", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^14.0.3", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/cjs/bin/find-process.js" } }, "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
"fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="],
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="], "graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"gridline-desktop": ["gridline-desktop@workspace:desktop"],
"gridline-www": ["gridline-www@workspace:www"],
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -513,8 +886,12 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], "lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="],
@@ -523,10 +900,24 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="], "monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
@@ -539,30 +930,76 @@
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="],
"neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"p-limit": ["p-limit@7.3.1", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q=="],
"p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
"p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"process-ancestry": ["process-ancestry@0.1.0", "", {}, "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="], "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="],
"randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="],
@@ -575,26 +1012,58 @@
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="],
"retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="],
"rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"satori": ["satori@0.29.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-fPAfrwNaIQ7EsJLhY382STEqpczN6ZEH9NsKXDQgRcxUXNNPB73iAjSshzFwTaaScT68pxV0pmG6TBzDQ4sEGw=="],
"satteri": ["satteri@0.9.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.9.5", "@bruits/satteri-darwin-x64": "0.9.5", "@bruits/satteri-linux-arm64-gnu": "0.9.5", "@bruits/satteri-linux-arm64-musl": "0.9.5", "@bruits/satteri-linux-x64-gnu": "0.9.5", "@bruits/satteri-linux-x64-musl": "0.9.5", "@bruits/satteri-wasm32-wasi": "0.9.5", "@bruits/satteri-win32-arm64-msvc": "0.9.5", "@bruits/satteri-win32-x64-msvc": "0.9.5" } }, "sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w=="],
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"simple-icons": ["simple-icons@16.27.1", "", {}, "sha512-slZF8iKxkv7Lb9SF3L1AcIl5iYLNvDrKKm8yV69cG66XvxJ12SX+bYTgxRwCY4bXSmklGXoXaKEyVmEypOCeqw=="], "simple-icons": ["simple-icons@16.27.1", "", {}, "sha512-slZF8iKxkv7Lb9SF3L1AcIl5iYLNvDrKKm8yV69cG66XvxJ12SX+bYTgxRwCY4bXSmklGXoXaKEyVmEypOCeqw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="],
"smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"sql-formatter": ["sql-formatter@15.8.2", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg=="], "sql-formatter": ["sql-formatter@15.8.2", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -603,8 +1072,18 @@
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
@@ -613,8 +1092,12 @@
"tauri-plugin-keyring-store-api": ["tauri-plugin-keyring-store-api@0.2.0", "", { "dependencies": { "@tauri-apps/api": "2.11.0" } }, "sha512-ZQYb6Xj75AD+YFBNDr4Zjc751TlVCQqvcB3GXx9Q85dRkss2FKrfU1Bu3kATQAzMQcvPYT3Ww+Fi+3I+OlMVZA=="], "tauri-plugin-keyring-store-api": ["tauri-plugin-keyring-store-api@0.2.0", "", { "dependencies": { "@tauri-apps/api": "2.11.0" } }, "sha512-ZQYb6Xj75AD+YFBNDr4Zjc751TlVCQqvcB3GXx9Q85dRkss2FKrfU1Bu3kATQAzMQcvPYT3Ww+Fi+3I+OlMVZA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -629,22 +1112,62 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
@@ -657,12 +1180,38 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"@astrojs/react/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@bruits/satteri-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@shuding/opentype.js/fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="],
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -683,6 +1232,98 @@
"@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"astro/magic-string": ["magic-string@1.2.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw=="],
"astro/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"find-process/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"gridline-www/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"gridline-www/@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
"gridline-www/lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tauri-plugin-keyring-store-api/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], "tauri-plugin-keyring-store-api/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
"unifont/undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
"@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@astrojs/react/@vitejs/plugin-react/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"@astrojs/react/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"@astrojs/react/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"astro/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"astro/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"@astrojs/react/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"@astrojs/react/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"@astrojs/react/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"@astrojs/react/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"@astrojs/react/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"@astrojs/react/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"@astrojs/react/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"@astrojs/react/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"@astrojs/react/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"@astrojs/react/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"@astrojs/react/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"@astrojs/react/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"astro/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"astro/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"astro/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"astro/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"astro/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"astro/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"astro/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"astro/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"astro/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"astro/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"astro/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"astro/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
} }
} }
View File
View File
+58
View File
@@ -0,0 +1,58 @@
{
"name": "gridline-desktop",
"private": true,
"version": "0.8.1",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"dev": "vite",
"tauri:dev": "tauri dev",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.3",
"@tanstack/react-virtual": "^3.14.8",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"html-to-image": "^1.11.13",
"lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0",
"motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"simple-icons": "^16.27.1",
"sql-formatter": "^15.8.2",
"tauri-plugin-keyring-store-api": "^0.2.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dagre": "^0.7.54",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^29.1.1",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vitest": "^4.1.10"
}
}
+16 -1
View File
@@ -1783,10 +1783,13 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.6" version = "0.8.1"
dependencies = [ dependencies = [
"bytes",
"chrono", "chrono",
"core-foundation 0.10.1",
"deadpool-postgres", "deadpool-postgres",
"futures-util",
"indexmap 2.14.0", "indexmap 2.14.0",
"redis", "redis",
"rusqlite", "rusqlite",
@@ -1794,6 +1797,8 @@ dependencies = [
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile 2.2.0", "rustls-pemfile 2.2.0",
"rustls-pki-types", "rustls-pki-types",
"security-framework 3.7.0",
"security-framework-sys",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -3047,6 +3052,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.117" version = "0.9.117"
@@ -3055,6 +3069,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.6" 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"
@@ -31,10 +31,15 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
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
ssh2 = { version = "0.9" } # vendored-openssl: link OpenSSL statically so the release binary has NO
# runtime dependency on the build machine's Homebrew openssl (@/opt/homebrew
# absolute LC_LOAD_DYLIB — dyld aborts on user machines without it).
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
deadpool-postgres = { version = "0.14" } deadpool-postgres = { version = "0.14" }
indexmap = { version = "2", features = ["serde"] } indexmap = { version = "2", features = ["serde"] }
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false } tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
@@ -45,3 +50,12 @@ rustls-pemfile = "2"
rustls-pki-types = "1" rustls-pki-types = "1"
rustls-native-certs = "0.7" rustls-native-certs = "0.7"
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
# survive app re-signs without permission prompts) — macOS only.
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.7"
security-framework-sys = "2.17"
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
core-foundation = "0.10"
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
app crashes under the hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- The WebView allocates executable memory for JIT; allow it. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,13 @@
# Bundled MariaDB client tools
Static `mariadb-dump` and `mariadb` client binaries (wire-compatible with MySQL
servers) go here. They are NOT committed — `.github/workflows/release.yml`
builds and checksum-verifies them per matrix target, copying the result into
`resources/mysql_tools/{mariadb-dump,mariadb}` (plus `mariadb-dump.exe` /
`mariadb.exe` and the runtime `libmariadb.dll` on Windows). The app resolves
tools system-first and falls back to these bundled binaries (see
`backup::resolve_mysql_tool`).
In `tauri dev`, this dir is usually empty — the app falls back to any
`mariadb-dump`/`mariadb` on `PATH` (system-first resolution). MySQL
backup/restore degrades with a clear error if neither is present.
+164
View File
@@ -0,0 +1,164 @@
//! Per-connection cancellation handles, stored independently of the pool
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rusqlite::InterruptHandle;
use sqlx::mysql::MySqlConnectOptions;
use tokio_postgres::CancelToken;
use crate::db::tls::TlsDecision;
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
/// rustls config used to **build** the connection (or `None` for NoTls) so the
/// cancel connection reuses the exact same TLS decision.
#[derive(Clone)]
pub struct PgCancel {
pub cancel_token: CancelToken,
pub tls_decision: TlsDecision,
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
}
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
/// connection currently running a query (one active per connection because the
/// pool lock serializes queries). `connect_options` lets `cancel` open a
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
#[derive(Clone)]
pub struct MySqlCancel {
pub conn_id: Option<i64>,
pub connect_options: MySqlConnectOptions,
}
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
/// behind an `Arc`.)
#[derive(Clone)]
pub struct SqliteCancel {
handle: Arc<InterruptHandle>,
}
impl SqliteCancel {
pub fn new(handle: InterruptHandle) -> Self {
Self { handle: Arc::new(handle) }
}
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
#[derive(Clone)]
pub enum CancelHandle {
Pg(PgCancel),
MySql(MySqlCancel),
Sqlite(SqliteCancel),
}
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
/// this `Mutex` (NOT the pool lock).
#[derive(Default)]
pub struct CancelRegistry {
map: Mutex<HashMap<String, CancelHandle>>,
}
impl CancelRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn set_pg(&self, id: &str, c: PgCancel) {
self.set(id, CancelHandle::Pg(c));
}
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
self.set(id, CancelHandle::MySql(c));
}
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
self.set(id, CancelHandle::Sqlite(c));
}
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
let mut g = self.map.lock().unwrap();
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
m.conn_id = conn_id;
}
}
fn set(&self, id: &str, h: CancelHandle) {
self.map.lock().unwrap().insert(id.to_string(), h);
}
pub fn get(&self, id: &str) -> Option<CancelHandle> {
self.map.lock().unwrap().get(id).cloned()
}
pub fn remove(&self, id: &str) {
self.map.lock().unwrap().remove(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_insert_and_drain() {
let reg = CancelRegistry::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
reg.set_sqlite("c1", SqliteCancel::new(handle));
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
reg.remove("c1");
assert!(reg.get("c1").is_none());
}
#[test]
fn sqlite_interrupt_aborts_running_query() {
use std::sync::{Arc, Mutex};
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
.unwrap();
let handle = conn.get_interrupt_handle();
let conn2 = Arc::new(Mutex::new(conn));
let c = conn2.clone();
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
let d = done.clone();
let worker = std::thread::spawn(move || {
let l = c.lock().unwrap();
// `query()` binds params only — the first sqlite3_step (where the
// interrupt lands) happens in `rs.next()`, so errors must be
// propagated with `?` rather than swallowed by `is_ok()`.
let r = l
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
.unwrap()
.query([])
.and_then(|mut rs| {
let mut n = 0;
while rs.next()?.is_some() {
n += 1;
}
Ok(n)
});
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
worker.join().unwrap();
let outcome = done.lock().unwrap().clone();
assert!(
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
"cancelled query must report interrupted; got {outcome:?}"
);
}
#[test]
fn mysql_overwrites_single_active_slot() {
let reg = CancelRegistry::new();
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
match reg.get("c1") {
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
_ => panic!("expected MySql"),
}
}
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
sqlx::mysql::MySqlConnectOptions::new()
.host("127.0.0.1").port(1).username("u").password("p").database("d")
}
}
File diff suppressed because it is too large Load Diff
@@ -418,3 +418,117 @@ fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump"); let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); } if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
} }
// ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------
use rusqlite::Connection;
use std::io::Cursor;
fn seed_sqlite() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE INDEX users_name ON users(name);
INSERT INTO users (name) VALUES ('Alice'),('Bob');
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
INSERT INTO blobs VALUES (1, x'010203');",
)
.unwrap();
c
}
#[test]
fn sqlite_dump_and_restore_roundtrip() {
let src = seed_sqlite();
let mut buf: Vec<u8> = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("PRAGMA foreign_keys=OFF"));
assert!(text.contains("BEGIN TRANSACTION"));
assert!(text.contains("CREATE TABLE users"));
assert!(text.contains("INSERT INTO \"users\""));
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
assert!(text.contains("CREATE INDEX users_name"));
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
assert_eq!(n, 2);
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
assert_eq!(blobs, 1);
}
#[test]
fn sqlite_dump_preserves_autoincrement_sequence() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
assert_eq!(id, 3);
}
#[test]
fn sqlite_dump_fail_closed_for_virtual_tables() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
let mut buf = Vec::new();
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
}
#[test]
fn sqlite_restore_clean_drops_existing() {
let src = seed_sqlite();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
restore_sqlite(&dst, &text, true).unwrap();
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
}
// ------------------------------------------------------------------
// MySQL dump / restore / sync arg building (Task 2.3)
// ------------------------------------------------------------------
#[test]
fn mysql_dump_args_single_transaction_no_data_routines() {
let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
};
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop"));
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env)
assert!(args.iter().all(|a| !a.starts_with("--password")));
}
#[test]
fn mysql_restore_args_no_clean_flags() {
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
assert!(args.iter().any(|a| a == "--database=shop"));
assert!(args.iter().any(|a| a == "--host=h"));
assert!(args.iter().all(|a| a != "--force"));
}
#[test]
fn mysql_env_uses_mysql_pwd_not_password_arg() {
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
assert!(args.iter().all(|a| !a.starts_with("--password")));
}
@@ -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());
}
@@ -5,13 +5,15 @@
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle}; use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
use crate::models::db_viewer::{ use crate::models::db_viewer::{
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
QueryResult, SequenceInfo, TableInfo, TriggerInfo, HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
}; };
use bytes::BytesMut;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error;
use sqlx::Row; use sqlx::Row;
use tauri::State; use tauri::State;
use tokio_postgres::types::ToSql; use tokio_postgres::types::{Format, IsNull, ToSql, Type};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper functions // Helper functions
@@ -564,6 +566,14 @@ pub fn build_pg_insert_sql(
table: &str, table: &str,
columns: &[(String, serde_json::Value)], columns: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) { ) -> (String, Vec<serde_json::Value>) {
// A table with no editable columns (e.g. only a serial/identity PK) inserts
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
if columns.is_empty() {
return (
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
vec![],
);
}
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect(); let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
let mut params: Vec<serde_json::Value> = Vec::new(); let mut params: Vec<serde_json::Value> = Vec::new();
let placeholders: Vec<String> = columns let placeholders: Vec<String> = columns
@@ -585,26 +595,75 @@ pub fn build_pg_insert_sql(
) )
} }
/// A value bound to a PostgreSQL parameter in TEXT wire format, accepting any
/// parameter type.
///
/// The frontend ships edited values as plain strings (and PK locators as
/// numbers/booleans), but binding a native `String` fails for every non-text
/// column type — `String::accepts` only matches `varchar`/`text`/`bpchar`/
/// `name` — with `error serializing parameter N: cannot convert between the
/// Rust type ... and the Postgres type ...`. Binding a native `i64` is also
/// wrong because `i64::accepts` only matches `int8`.
///
/// `PgText` accepts *any* parameter type and writes the value as UTF-8 text in
/// text wire format, so PostgreSQL itself parses the literal into the target
/// column type. This works uniformly for `integer`, `bigint`, `numeric`,
/// `boolean`, `uuid`, `json`/`jsonb`, timestamps, enums, etc. — exactly what
/// PostgreSQL does for an `'<literal>'` in a query.
#[derive(Debug, Clone)]
struct PgText(String);
impl From<&str> for PgText {
fn from(s: &str) -> Self {
PgText(s.to_string())
}
}
impl ToSql for PgText {
fn to_sql(
&self,
_ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
out.extend_from_slice(self.0.as_bytes());
Ok(IsNull::No)
}
fn accepts(_ty: &Type) -> bool {
true
}
fn encode_format(&self, _ty: &Type) -> Format {
Format::Text
}
fn to_sql_checked(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
self.to_sql(ty, out)
}
}
/// Convert a JSON value into a boxed PostgreSQL-bindable value. /// Convert a JSON value into a boxed PostgreSQL-bindable value.
/// ///
/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors. /// Every non-null value is bound as a `PgText` (text wire format) so the server
/// Unknown/complex types are stringified as a fallback. /// parses it into the target column type; SQL `NULL` is bound as `None`. This
/// is deliberately type-agnostic — the caller already knows the column type
/// through the SQL context (e.g. `SET "col" = $1` lets PostgreSQL infer `$1`
/// as `col`'s type).
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> { fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
match v { if v.is_null() {
serde_json::Value::Null => Box::new(Option::<String>::None), return Box::new(Option::<PgText>::None);
serde_json::Value::Bool(b) => Box::new(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Box::new(i)
} else if let Some(f) = n.as_f64() {
Box::new(f)
} else {
Box::new(n.to_string())
}
}
serde_json::Value::String(s) => Box::new(s.clone()),
other => Box::new(other.to_string()),
} }
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
};
Box::new(PgText::from(text.as_str()))
} }
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding. /// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
@@ -891,6 +950,7 @@ pub(crate) async fn run_mysql_connect(
config: &crate::db::pool::DbConfig, config: &crate::db::pool::DbConfig,
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>, ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>, pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
cancel_registry: &crate::cancel::CancelRegistry,
) -> Result<(), String> { ) -> Result<(), String> {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode}; use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
if config.host.trim().is_empty() { if config.host.trim().is_empty() {
@@ -945,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 {
@@ -968,6 +1028,10 @@ pub(crate) async fn run_mysql_connect(
} }
} }
// Clone the (already final) options before `connect_with` consumes them so
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
let cancel_opts = opts.clone();
match MySqlPoolOptions::new() match MySqlPoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(10)) .acquire_timeout(std::time::Duration::from_secs(10))
@@ -979,6 +1043,13 @@ pub(crate) async fn run_mysql_connect(
.lock() .lock()
.await .await
.register(connection_id, crate::db::pool::DbHandle::MySql(pool)); .register(connection_id, crate::db::pool::DbHandle::MySql(pool));
cancel_registry.set_mysql(
connection_id,
crate::cancel::MySqlCancel {
conn_id: None,
connect_options: cancel_opts,
},
);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
@@ -1013,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(
@@ -1023,6 +1094,8 @@ pub async fn db_connect(
config.ssl_key_path.as_deref(), config.ssl_key_path.as_deref(),
) )
.map_err(|e| sanitize_error(&e))?; .map_err(|e| sanitize_error(&e))?;
// Snapshot before `match tls` consumes the Option below.
let cancel_cfg = tls.clone();
// SSH tunnel: if configured, open a loopback tunnel to the remote DB // SSH tunnel: if configured, open a loopback tunnel to the remote DB
// and connect through it. The blocking ssh2 handshake runs in // and connect through it. The blocking ssh2 handshake runs in
@@ -1080,6 +1153,18 @@ pub async fn db_connect(
match result { match result {
Ok((client, handle)) => { Ok((client, handle)) => {
// Capture a cancel token + the exact TLS decision/config used to
// build the connection so `cancel_query` can open an identical
// (short-lived) cancel connection later.
let pgtoken = client.cancel_token();
state.cancel_registry.set_pg(
&connection_id,
crate::cancel::PgCancel {
cancel_token: pgtoken,
tls_decision: decision,
tls_config: cancel_cfg,
},
);
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.register( pm.register(
&connection_id, &connection_id,
@@ -1101,18 +1186,25 @@ pub async fn db_connect(
} else if config.db_type == "sqlite" { } else if config.db_type == "sqlite" {
match rusqlite::Connection::open(&config.host) { match rusqlite::Connection::open(&config.host) {
Ok(conn) => { Ok(conn) => {
// Capture the per-connection interrupt handle so `cancel_query`
// can abort a running SQLite query from another thread.
let interrupt = conn.get_interrupt_handle();
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn)); pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
state
.cancel_registry
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
Ok(()) Ok(())
} }
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,
&state.ssh_manager, &state.ssh_manager,
&state.pool_manager, &state.pool_manager,
&state.cancel_registry,
) )
.await .await
} else { } else {
@@ -1130,6 +1222,7 @@ pub async fn db_disconnect(
) -> Result<(), String> { ) -> Result<(), String> {
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.remove(&connection_id); pm.remove(&connection_id);
state.cancel_registry.remove(&connection_id);
Ok(()) Ok(())
} }
@@ -1864,6 +1957,60 @@ pub async fn get_table_data(
.await .await
} }
/// Headless column introspection for a table, shared by the `get_table_columns`
/// command and integration tests (thin-command principle — no Tauri `State`).
/// Mirrors the `pg_columns_query` row mapping `get_table_data` uses.
pub(crate) async fn get_table_columns_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
) -> Result<Vec<crate::models::ColumnInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Table columns are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
Ok(rows
.iter()
.map(|r| {
let is_fk: bool = r.get::<_, Option<String>>(10).is_some();
let fk_schema: Option<String> = r.get(9);
let fk_table: Option<String> = r.get(10);
crate::models::ColumnInfo {
name: r.get(0),
data_type: r.get(1),
is_nullable: r.get::<_, String>(2) == "YES",
is_pk: r.get::<_, Option<String>>(8).as_deref() == Some("PRIMARY KEY"),
is_fk,
fk_ref: if is_fk {
Some((fk_schema.unwrap_or_default(), fk_table.unwrap_or_default()))
} else {
None
},
default_value: r.get::<_, Option<String>>(6),
editable: true,
is_generated: false,
}
})
.collect())
}
#[tauri::command]
pub async fn get_table_columns(
connection_id: String,
schema: String,
table: String,
state: State<'_, crate::AppState>,
) -> Result<Vec<crate::models::ColumnInfo>, String> {
get_table_columns_inner(&state.pool_manager, &connection_id, &schema, &table).await
}
#[tauri::command] #[tauri::command]
pub async fn get_fk_preview( pub async fn get_fk_preview(
connection_id: String, connection_id: String,
@@ -2110,14 +2257,15 @@ pub(crate) fn affected_count_error(n: u64) -> Option<String> {
} }
} }
#[tauri::command] /// Headless change-application shared by the `execute_change` command and
pub async fn execute_change( /// integration tests (thin-command principle — no Tauri `State`).
connection_id: String, pub(crate) async fn execute_change_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
change: Change, change: Change,
state: State<'_, crate::AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut pm = state.pool_manager.lock().await; let mut pm = pm.lock().await;
match pm.get(&connection_id) { match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
// Build the parameterized SQL + bound values from the change. // Build the parameterized SQL + bound values from the change.
let (sql, params): (String, Vec<serde_json::Value>) = match &change { let (sql, params): (String, Vec<serde_json::Value>) = match &change {
@@ -2159,6 +2307,23 @@ pub async fn execute_change(
client.execute(sql, &[]).await.map_err(|e| e.to_string())?; client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
Change::RebuildTable { sql, .. } => {
// Single transaction: all-or-nothing rebuild (multi-statement
// DDL only; VACUUM never reaches here). Rolls back on any
// statement failure.
let tx = client
.build_transaction()
.start()
.await
.map_err(|e| e.to_string())?;
tx.batch_execute(sql)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
tx.commit()
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::BulkInsert { Change::BulkInsert {
schema, schema,
table, table,
@@ -2251,8 +2416,15 @@ pub async fn execute_change(
conn.execute(sql, []).map_err(|e| e.to_string())?; conn.execute(sql, []).map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
Change::Ddl { .. } => { Change::Ddl { sql, .. } => {
return Err("Object management is PostgreSQL-only".to_string()); // Object DDL can be multi-statement; run as a batch.
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
}
Change::RebuildTable { sql, .. } => {
// Rebuild script (create tmp / copy / drop / rename).
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
} }
Change::BulkInsert { Change::BulkInsert {
table, table,
@@ -2329,6 +2501,9 @@ pub async fn execute_change(
Change::Ddl { .. } => { Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string()); return Err("Object management is PostgreSQL-only".to_string());
} }
Change::RebuildTable { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
}
Change::BulkInsert { Change::BulkInsert {
schema, schema,
table, table,
@@ -2384,6 +2559,15 @@ pub async fn execute_change(
} }
} }
#[tauri::command]
pub async fn execute_change(
connection_id: String,
change: Change,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
execute_change_inner(&state.pool_manager, &connection_id, change).await
}
#[tauri::command] #[tauri::command]
pub async fn refresh_connection( pub async fn refresh_connection(
connection_id: String, connection_id: String,
@@ -2456,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,
@@ -2926,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]
@@ -3195,6 +3440,78 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// PgText: universal text-format parameter binding (serialization fix)
// -----------------------------------------------------------------------
#[test]
fn pg_text_accepts_any_postgres_type() {
// String::accepts only matches text-ish types, so a plain String can't
// bind to an integer/boolean/uuid/jsonb column. PgText must accept all
// of these (the server parses the text into the column's own type).
for ty in [
Type::TEXT,
Type::VARCHAR,
Type::INT4,
Type::INT8,
Type::FLOAT8,
Type::NUMERIC,
Type::BOOL,
Type::UUID,
Type::JSON,
Type::JSONB,
Type::TIMESTAMP,
Type::TIMESTAMPTZ,
Type::DATE,
Type::TIME,
] {
assert!(
PgText::accepts(&ty),
"PgText must accept {:?}",
ty
);
}
// Sanity: a native String must NOT accept a non-text type (proving the
// root cause this fix addresses).
assert!(!<&str as ToSql>::accepts(&Type::INT4));
}
#[test]
fn pg_text_serializes_as_utf8_text() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::json!("42"));
let mut out = BytesMut::new();
let isnull = val.to_sql_checked(&Type::INT4, &mut out).unwrap();
assert!(matches!(isnull, IsNull::No));
assert_eq!(&out[..], b"42");
// Text wire format: the bytes are raw UTF-8, not binary-encoded int.
assert!(matches!(val.encode_format(&Type::INT4), Format::Text));
}
#[test]
fn pg_text_boxes_null_to_sql_null() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::Value::Null);
let mut out = BytesMut::new();
assert!(matches!(
val.to_sql_checked(&Type::TEXT, &mut out).unwrap(),
IsNull::Yes
));
assert!(out.is_empty());
}
#[test]
fn pg_text_stringifies_booleans_and_numbers() {
let b = pg_box_value(&serde_json::json!(true));
assert!(matches!(b.encode_format(&Type::BOOL), Format::Text));
let n = pg_box_value(&serde_json::json!(5));
assert!(matches!(n.encode_format(&Type::INT4), Format::Text));
}
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> { async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
tokio::sync::Mutex::new(ConnectionPoolManager::new()) tokio::sync::Mutex::new(ConnectionPoolManager::new())
} }
@@ -3222,8 +3539,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-empty-host"; let id = "mysql-empty-host";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await; let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!(res.is_err(), "empty host must fail before any network call"); assert!(res.is_err(), "empty host must fail before any network call");
let mut pmg = pm.lock().await; let mut pmg = pm.lock().await;
assert!(pmg.get(id).is_none(), "no handle registered on failure"); assert!(pmg.get(id).is_none(), "no handle registered on failure");
@@ -3282,8 +3600,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-unreachable"; let id = "mysql-unreachable";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await; let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!( assert!(
res.is_err(), res.is_err(),
"port 1 should refuse; must be a clean Err, not panic" "port 1 should refuse; must be a clean Err, not panic"
@@ -3332,8 +3651,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = format!("mysql-it-{}", uuid::Uuid::new_v4()); let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect"); run_mysql_connect(&id, &cfg, &ssh, &pm, &reg).await.expect("connect");
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables"); let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
assert!(!tables.is_empty(), "test DB must contain at least one table"); assert!(!tables.is_empty(), "test DB must contain at least one table");
let first = &tables[0]; let first = &tables[0];
@@ -3369,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 {
@@ -1,3 +1,4 @@
use crate::keychain_acl;
use tauri_plugin_keyring_store::KeyringExt; use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain. /// Store a connection password in the OS keychain.
@@ -11,7 +12,10 @@ pub fn save_connection_password(
app.keyring() app.keyring()
.store .store
.set_password(&connection_id, &password) .set_password(&connection_id, &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
keychain_acl::after_store(&app, &connection_id);
Ok(())
} }
/// Retrieve a connection password from the OS keychain. /// Retrieve a connection password from the OS keychain.
@@ -21,6 +25,9 @@ pub fn get_connection_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
// Repair a stale item ACL first: after a rebuild/path change macOS would
// otherwise ask for the keychain password on every access (once per secret).
keychain_acl::before_read(&app, &connection_id);
app.keyring() app.keyring()
.store .store
.get_password(&connection_id) .get_password(&connection_id)
@@ -33,6 +40,7 @@ pub fn get_connection_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, connection_id);
app.keyring() app.keyring()
.store .store
.get_password(connection_id) .get_password(connection_id)
@@ -55,7 +63,9 @@ pub fn save_connection_ssh_password(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("password", &connection_id), &password) .set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
Ok(())
} }
/// Retrieve an SSH tunnel password from the OS keychain. /// Retrieve an SSH tunnel password from the OS keychain.
@@ -65,6 +75,7 @@ pub fn get_connection_ssh_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", &connection_id)) .get_password(&ssh_account("password", &connection_id))
@@ -93,7 +104,9 @@ pub fn save_connection_ssh_passphrase(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase) .set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
Ok(())
} }
/// Retrieve an SSH private-key passphrase from the OS keychain. /// Retrieve an SSH private-key passphrase from the OS keychain.
@@ -103,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("passphrase", &connection_id)) .get_password(&ssh_account("passphrase", &connection_id))
@@ -127,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, &ssh_account("password", connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", connection_id)) .get_password(&ssh_account("password", connection_id))
@@ -0,0 +1,39 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::models::MaintenanceResult;
/// Run a table-scoped maintenance command via the SIMPLE query protocol
/// (never inside a transaction — VACUUM cannot run in a transaction block).
pub(crate) async fn run_maintenance_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
) -> Result<MaintenanceResult, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
crate::db::object_ddl::validate_object_name(&schema)?;
crate::db::object_ddl::validate_object_name(&table)?;
let verb = match action {
"vacuum" => "VACUUM",
"analyze" => "ANALYZE",
"reindex" => "REINDEX TABLE",
_ => return Err(format!("Unknown maintenance action: {action}")),
};
let sql = format!("{} {}.{}", verb,
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
let start = std::time::Instant::now();
client.simple_query(&sql).await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
}
#[tauri::command]
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
}
#[cfg(test)]
#[path = "maintenance.test.rs"]
mod maintenance_test;
@@ -0,0 +1,28 @@
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
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-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn run_maintenance_analyze_succeeds() {
let (pm, id) = pool().await;
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
assert!(res.duration_ms >= 0);
assert!(res.message.to_lowercase().contains("analyze"));
}
@@ -5,8 +5,11 @@ pub mod demo;
pub mod folders; pub mod folders;
pub mod import_export; pub mod import_export;
pub mod keychain; pub mod keychain;
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;
+587
View File
@@ -0,0 +1,587 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
exec_sql(pm, connection_id, create_schema_sql(name)?).await
}
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
}
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
}
#[tauri::command]
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
create_schema_inner(&state.pool_manager, &connection_id, &name).await
}
#[tauri::command]
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
}
#[tauri::command]
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
}
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
let sql = pg_object_search_query();
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ObjectSearchHit {
name: r.get(0), schema: r.get(1), object_type: r.get(2),
}).collect())
}
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
}
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
match object_type {
"function" | "procedure" => {
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"trigger" => {
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"index" => {
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) 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 t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"constraint" => {
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
}
"view" => {
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
}
"materialized view" => {
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
}
"sequence" => {
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
}
"enum" => {
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
}
"extension" => {
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
}
"table" => {
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
Err("table DDL uses get_table_ddl".into())
}
other => Err(format!("Unsupported object type for DDL: {other}")),
}
}
#[tauri::command]
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
if object_type == "table" || object_type == "TABLE" {
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
}
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
}
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
if object_type.eq_ignore_ascii_case("schema") {
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
return Ok(rows.iter().map(|r| DependencyInfo {
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
}).collect());
}
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| DependencyInfo {
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
}).collect())
}
#[tauri::command]
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
}
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
/// single-column unique index (excluding the PK autoindex).
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
let pragma_query = format!("PRAGMA table_info('{}')", table);
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, String>(2)?, // type
row.get::<_, bool>(3)?, // notnull
row.get::<_, bool>(5)?, // pk
row.get::<_, Option<String>>(4)?, // dflt_value
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
let autoinc = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|row| row.get::<_, String>(0),
)
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
.unwrap_or(false);
// Columns covered by a single-column unique index (origin != 'pk').
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
let indexes: Vec<(String, bool, String)> = idx_stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, bool>(2)?, // unique
row.get::<_, String>(3)?, // origin
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (idx_name, is_unique, origin) in indexes {
if !is_unique || origin == "pk" {
continue;
}
if let Ok(mut info_stmt) =
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
{
let cols: Vec<String> = info_stmt
.query_map([], |row| row.get::<_, String>(2))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if cols.len() == 1 {
unique_cols.insert(cols[0].clone());
}
}
}
}
Ok(col_meta
.iter()
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
name: name.clone(),
type_: dtype.clone(),
nullable: !notnull,
default: default.clone(),
is_pk: *is_pk,
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
unique: unique_cols.contains(name),
})
.collect())
}
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
/// (create / edit / rebuild ops on the `table` kind).
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
Some(DbHandle::Sqlite(conn)) => {
// params: { schema, name, action: { op, columns: [...] } }
let action = params.get("action").ok_or("missing action")?;
let op = action
.get("op")
.and_then(|v| v.as_str())
.ok_or("missing op")?;
let cols: Vec<SqliteColumn> = match action.get("columns") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
None => vec![],
};
let table = params
.get("name")
.and_then(|v| v.as_str())
.ok_or("missing name")?
.to_string();
match op {
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
"edit" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_column_diff_sql(&table, &old, &cols)
}
"rebuild" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_rebuild_script(&table, &old, &cols)
}
_ => Err(format!("unknown op {op}")),
}
}
Some(_) => Err("Object management is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
}
/// List extensions installable on this server (`pg_available_extensions`):
/// name + default version + comment. The picker uses name/version only;
/// `schema` is left empty (available extensions are schema-wide).
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ExtensionInfo {
name: r.get(0),
schema: String::new(),
version: r.get(1),
comment: r.get(2),
}).collect())
}
#[tauri::command]
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
get_available_extensions_inner(&state.pool_manager, &connection_id).await
}
/// Build a reorder-only table rebuild script (executed transactionally via
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
/// validates the new column list against the live snapshot (names + types must
/// be preserved), assembles a `RebuildInput` from live introspection, and
/// delegates to `rebuild_script`.
pub(crate) async fn build_rebuild_script_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
new_columns: serde_json::Value,
) -> Result<String, String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Sqlite(conn)) => {
let new_cols: Vec<SqliteColumn> =
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
let live = sqlite_live_columns(conn, table)?;
if let Some(reason) = sqlite_rebuild_refusal(&live) {
return Err(reason);
}
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
}
Some(DbHandle::Postgresql(client, _)) => {
let new_cols: Vec<TableColumn> =
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
// 1. live columns — validate reorder-only: the (name,type) multiset must be
// unchanged (attribute edits belong in the diff path, not the rebuild).
let live = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let mut live_pairs: Vec<(String, String)> = live
.iter()
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
.collect();
let mut new_pairs: Vec<(String, String)> = new_cols
.iter()
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
.collect();
live_pairs.sort();
new_pairs.sort();
if live_pairs != new_pairs {
return Err(
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
.into(),
);
}
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
let fk_out_rows = client
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let fk_in_rows = client
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let grant_rows = client
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let seq_rows = client
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let index_rows = client
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
.await
.map_err(|e| sanitize(&e.to_string()))?;
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
// separately as fks_out/fks_in so they are not double-applied.
let constraint_rows = client
.query(
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
ORDER BY c.conname",
&[&schema, &table],
)
.await
.map_err(|e| sanitize(&e.to_string()))?;
let input = RebuildInput {
schema: schema.to_string(),
name: table.to_string(),
constraints: constraint_rows
.iter()
.map(|r| RebuildConstraint {
name: r.get(0),
definition: r.get(4),
})
.collect(),
indexes: index_rows
.iter()
.filter(|r| r.get::<_, String>(2) == table)
.map(|r| RebuildIndex {
name: r.get(0),
definition: r.get(3),
})
.collect(),
fks_out: fk_out_rows
.iter()
.map(|r| RebuildFk {
name: r.get(0),
definition: r.get(1),
})
.collect(),
fks_in: fk_in_rows
.iter()
.map(|r| RebuildFkIn {
name: r.get(0),
own_schema: r.get(1),
own_table: r.get(2),
definition: r.get(3),
})
.collect(),
grants: grant_rows
.iter()
.map(|r| RebuildGrant {
grantee: r.get(0),
privileges: r.get(1),
grantable: r.get(2),
})
.collect(),
owned_sequences: seq_rows
.iter()
.map(|r| RebuildOwnedSequence {
seq_schema: r.get(0),
seq_name: r.get(1),
column: r.get(2),
})
.collect(),
};
rebuild_script(&input, &new_cols)
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn build_rebuild_script(
connection_id: String,
schema: String,
table: String,
new_columns: serde_json::Value,
state: State<'_, crate::AppState>,
) -> Result<String, String> {
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
.await
}
// ---------------------------------------------------------------------------
// Roles, privileges, rebuild readiness, tablespaces
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes, memberships grouped by member role.
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Roles are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
for r in roles {
let name: String = r.get("rolname");
by_name.insert(name.clone(), crate::models::RoleInfo {
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
memberships: vec![],
});
}
for m in mems {
let member: String = m.get("member");
if let Some(ri) = by_name.get_mut(&member) {
ri.memberships.push(crate::models::RoleMembership {
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
});
}
}
Ok(by_name.into_values().collect())
}
#[tauri::command]
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
get_roles_inner(&state.pool_manager, &connection_id).await
}
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
validate_object_name(role)?; // role is interpolated into the privilege queries
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
};
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
Ok(out)
}
#[tauri::command]
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
}
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Sqlite(conn)) => {
let live = sqlite_live_columns(conn, table)?;
Ok(match sqlite_rebuild_refusal(&live) {
Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
})
}
Some(DbHandle::Postgresql(client, _)) => {
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut reasons = Vec::new();
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
}
/// List non-system tablespaces for the table-options picker.
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
}
#[tauri::command]
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
get_tablespaces_inner(&state.pool_manager, &connection_id).await
}
#[cfg(test)]
#[path = "objects.test.rs"]
mod tests;
@@ -58,19 +58,62 @@ async fn object_ddl_for_sequence_enum_function() {
} }
#[tokio::test] #[tokio::test]
async fn build_object_ddl_inner_guards_postgresql_only() { async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new()); let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found // Missing connection -> Connection not found
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({ let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" } "schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err(); })).await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}"); assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error // SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap())); pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({ let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" } "schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err(); })).await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}"); assert!(err.contains("unknown op"), "{err}");
}
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
let mut pm = ConnectionPoolManager::new();
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
pm
}
#[tokio::test]
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
let pm = fresh_pool_with_sqlite();
let params = serde_json::json!({
"schema": "main", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
] }
});
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
}
#[tokio::test]
async fn execute_change_sqlite_ddl_runs_create() {
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
assert!(r.is_ok(), "{r:?}");
// the table really landed on the live connection
{
let mut g = pm.lock().await;
match g.get("c").unwrap() {
crate::db::pool::DbHandle::Sqlite(conn) => {
let n: i64 = conn
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
.unwrap();
assert_eq!(n, 1);
}
_ => panic!("expected a sqlite handle"),
}
}
} }
#[tokio::test] #[tokio::test]
@@ -114,4 +157,56 @@ async fn object_dependencies_for_table_includes_view() {
// schema contents path // schema contents path
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap(); let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents"); assert!(!contents.is_empty(), "public schema should list contents");
} }
#[tokio::test]
#[ignore]
async fn get_roles_returns_current_role_and_memberships() {
let (pm, id) = pool().await;
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
}
#[tokio::test]
#[ignore]
async fn rebuild_table_rolls_back_on_failure() {
let (pm, id) = pool().await;
// setup: a table with a PK + one row
{
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
}
}
// build a rebuild script whose final statement intentionally fails (syntax error)
// so the whole transaction rolls back and rebuild_t keeps its row.
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
DROP TABLE rebuild_t; \
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
THIS IS NOT SQL;";
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
// table still intact
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
}
}
#[tokio::test]
#[ignore = "requires live PG"]
async fn get_role_privileges_returns_grants_across_object_classes() {
let (pm, id) = pool().await;
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
let classes: std::collections::HashSet<String> =
out.iter().map(|p| p.object_class.clone()).collect();
assert!(classes.contains("table"), "expected a table grant: {out:?}");
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
assert!(classes.contains("database"), "expected a database grant: {out:?}");
}
@@ -10,13 +10,37 @@
//! 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;
use tauri::State; use tauri::State;
use uuid::Uuid; use uuid::Uuid;
// ---------------------------------------------------------------------------
// Cancel-error classification
// ---------------------------------------------------------------------------
// The wrapped→raw fallback exists for queries that can't be wrapped (CTEs,
// multi-statement, non-SELECT). A USER CANCELLATION is NOT a wrapping failure:
// swallowing it would re-run the very query the user just cancelled — and for
// SQLite the interrupt flag is consumed by the aborted step, so the re-run
// runs completely free. These helpers let the fallbacks propagate cancellations.
fn is_sqlite_cancel_error(e: &rusqlite::Error) -> bool {
e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
}
fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
}
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
match e.as_database_error().and_then(|d| d.code()) {
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
_ => e.to_string().to_lowercase().contains("interrupted"),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// QueryHistoryEntry // QueryHistoryEntry
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
pub(crate) async fn execute_query_inner( pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager, pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>, db_store: &std::sync::Mutex<crate::store::Store>,
cancel_registry: &crate::cancel::CancelRegistry,
connection_id: &str, connection_id: &str,
query: &str, query: &str,
page: i64, page: i64,
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
execute_pg_query(client, query, page, page_size).await execute_pg_query(client, query, page, page_size).await
} }
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size), Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await, Some(DbHandle::MySql(pool)) => {
// Run on a dedicated pooled connection so the query's MySQL
// CONNECTION_ID can be tracked for cancellation (`KILL QUERY ?`).
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
// CAST to SIGNED: MySQL returns CONNECTION_ID() as BIGINT UNSIGNED,
// which sqlx refuses to decode into i64 (ColumnDecode error) — a
// silent unwrap_or(-1) here would make KILL QUERY -1 fail.
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));
let result = execute_mysql_query_on(&mut *conn, query, page, page_size).await;
cancel_registry.set_mysql_conn_id(connection_id, None);
result
}
None => { None => {
let elapsed = start.elapsed().as_millis() as i64; let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string(); let err = "Connection not found".to_string();
@@ -210,6 +253,7 @@ async fn execute_pg_query(
// Try the wrapped count query first — if this fails we fall back to raw. // Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await { let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
Ok(row) => row.get::<_, i64>(0), Ok(row) => row.get::<_, i64>(0),
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
// Wrapping failed — fall back to raw execution. // Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await; return execute_pg_raw(client, trimmed, page, page_size, off).await;
@@ -219,6 +263,7 @@ async fn execute_pg_query(
// Now execute the wrapped data query. // Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await { let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows, Ok(rows) => rows,
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await; return execute_pg_raw(client, trimmed, page, page_size, off).await;
} }
@@ -381,6 +426,7 @@ fn execute_sqlite_query(
// Try the wrapped count query first. // Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) { let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n, Ok(n) => n,
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
// Wrapping failed — fall back to raw execution. // Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off); return execute_sqlite_raw(conn, trimmed, page, page_size, off);
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
// Execute the wrapped data query. // Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) { let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result, Ok(result) => result,
// The data-step error is already a String (mapped inside
// execute_sqlite_with_query); classify by the interrupt message.
Err(e) if e.to_lowercase().contains("interrupted") => {
return Err("Query cancelled".to_string());
}
Err(_) => { Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off); return execute_sqlite_raw(conn, trimmed, page, page_size, off);
} }
@@ -533,8 +584,8 @@ pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde
serde_json::Value::Null serde_json::Value::Null
} }
async fn execute_mysql_query( async fn execute_mysql_query_on(
pool: &sqlx::MySqlPool, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
page: i64, page: i64,
page_size: i64, page_size: i64,
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
// Try the wrapped count first; fall back to raw on failure. // Try the wrapped count first; fall back to raw on failure.
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed)) let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(pool) .fetch_one(&mut *conn)
.await .await
{ {
Ok(n) => n, Ok(n) => n,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed)) let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size) .bind(page_size)
.bind(off) .bind(off)
.fetch_all(pool) .fetch_all(&mut *conn)
.await .await
{ {
Ok(rows) => rows, Ok(rows) => rows,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let columns: Vec<ColumnInfo> = match data_rows.first() { let columns: Vec<ColumnInfo> = match data_rows.first() {
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
is_generated: false, is_generated: false,
}) })
.collect(), .collect(),
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, None => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let rows: Vec<Vec<serde_json::Value>> = data_rows let rows: Vec<Vec<serde_json::Value>> = data_rows
@@ -602,14 +655,14 @@ async fn execute_mysql_query(
/// mirroring the PG `simple_query` raw path. Used when wrapping fails /// mirroring the PG `simple_query` raw path. Used when wrapping fails
/// (e.g., multi-statement or non-selectable SQL). /// (e.g., multi-statement or non-selectable SQL).
async fn execute_mysql_raw( async fn execute_mysql_raw(
pool: &sqlx::MySqlPool, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
page: i64, page: i64,
page_size: i64, page_size: i64,
off: i64, off: i64,
) -> Result<QueryResult, String> { ) -> Result<QueryResult, String> {
let rows = sqlx::query(query) let rows = sqlx::query(query)
.fetch_all(pool) .fetch_all(&mut *conn)
.await .await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?; .map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
@@ -655,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -748,7 +1302,70 @@ pub async fn execute_query(
let p = page.unwrap_or(1); let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50); let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await execute_query_inner(
&mut pm,
&state.db_store,
&state.cancel_registry,
&connection_id,
&query,
p,
ps,
)
.await
}
/// Cancel a query currently running on the given connection.
///
/// - PostgreSQL: opens a short-lived cancel connection (reusing the exact TLS
/// decision/config from connect) and sends a cancel request keyed to the
/// original backend.
/// - MySQL: opens a fresh connection and runs `KILL QUERY <conn_id>` for the
/// connection currently running the query (registered per-query).
/// - SQLite: signals the per-connection `InterruptHandle` (thread-safe).
#[tauri::command]
pub async fn cancel_query(
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
use sqlx::ConnectOptions;
match state.cancel_registry.get(&connection_id) {
Some(crate::cancel::CancelHandle::Pg(pg)) => {
// A Verify/Require decision always carries a built rustls config,
// so the unwrap on the non-Disable branch is safe by construction.
match pg.tls_decision {
crate::db::tls::TlsDecision::Disable => {
pg.cancel_token.cancel_query(tokio_postgres::NoTls).await
}
_ => {
let connector = tokio_postgres_rustls::MakeRustlsConnect::new(
(*pg.tls_config.expect("tls config for non-disable decision")).clone(),
);
pg.cancel_token.cancel_query(connector).await
}
}
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
}
Some(crate::cancel::CancelHandle::MySql(m)) => {
let id = m
.conn_id
.ok_or_else(|| "No active query on this connection".to_string())?;
let mut c = m
.connect_options
.connect()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
sqlx::query(&format!("KILL QUERY {id}"))
.execute(&mut c)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(())
}
Some(crate::cancel::CancelHandle::Sqlite(s)) => {
s.interrupt();
Ok(())
}
None => Err("No active cancel handle for this connection".into()),
}
} }
#[tauri::command] #[tauri::command]
@@ -1138,4 +1755,131 @@ mod tests {
let q = mysql_wrap_count("SELECT * FROM t"); let q = mysql_wrap_count("SELECT * FROM t");
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt"); assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
} }
// ------------------------------------------------------------------
// Cancel propagation (v0.7.8 bugfix): a user cancel must NOT be swallowed
// by the wrapped→raw fallback (which would re-run the cancelled query).
// ------------------------------------------------------------------
#[test]
fn is_sqlite_cancel_error_classifies_interrupt() {
let interrupted = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
Some("interrupted".to_string()),
);
assert!(is_sqlite_cancel_error(&interrupted));
let other = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("SQL logic error".to_string()),
);
assert!(!is_sqlite_cancel_error(&other));
}
#[test]
fn sqlite_cancel_aborts_wrapped_query_without_rerun() {
use std::sync::mpsc;
// A slow query whose wrapped COUNT step takes seconds — interrupt() must
// abort it and surface "Query cancelled" instead of falling back to the
// raw re-run (which would consume the interrupt flag and run to
// completion, hiding the cancellation).
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
let slow = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 50000000) SELECT count(*) AS n FROM c";
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let res = execute_sqlite_query(&conn, slow, 1, 50);
let cancelled = match &res {
Err(msg) => msg.contains("Query cancelled"),
Ok(_) => false,
};
let _ = tx.send(cancelled);
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
let cancelled = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("query thread must finish");
assert!(cancelled, "cancel must abort the wrapped query with 'Query cancelled' instead of re-running it");
}
#[tokio::test]
#[ignore]
async fn pg_cancel_aborts_wrapped_query_without_rerun() {
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 token = client.cancel_token();
let run = tokio::spawn(async move {
execute_pg_query(&client, "SELECT pg_sleep(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
token
.cancel_query(tokio_postgres::NoTls)
.await
.expect("cancel request");
let res = run.await.expect("query task");
assert!(res.is_err(), "pg_sleep must be cancelled, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
let _ = handle;
}
#[tokio::test]
#[ignore]
async fn mysql_cancel_aborts_wrapped_query_without_rerun() {
use sqlx::ConnectOptions;
let h = std::env::var("GRIDLINE_TEST_MYSQL_HOST").expect("set GRIDLINE_TEST_MYSQL_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.unwrap_or_else(|_| "3306".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_MYSQL_USER").expect("set GRIDLINE_TEST_MYSQL_USER");
let pw = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").unwrap_or_default();
let opts = sqlx::mysql::MySqlConnectOptions::new()
.host(&h)
.port(p)
.username(&u)
.password(&pw)
.database(&db);
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_with(opts.clone())
.await
.expect("connect to test MySQL");
let mut conn = pool.acquire().await.expect("acquire");
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap();
let run = tokio::spawn(async move {
execute_mysql_query_on(&mut *conn, "SELECT SLEEP(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let mut killer = opts.connect().await.expect("killer connect");
sqlx::query(&format!("KILL QUERY {conn_id}"))
.execute(&mut killer)
.await
.expect("kill");
let res = run.await.expect("query task");
assert!(res.is_err(), "SLEEP(3) must be killed, not re-run; got {res:?}");
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() {}
+152
View File
@@ -0,0 +1,152 @@
use crate::models::Settings;
use crate::store::Store;
use std::collections::HashMap;
use std::sync::Mutex;
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_settings()
}
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.update_setting(key, value)
}
#[tauri::command]
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
get_settings_inner(&state.db_store)
}
#[tauri::command]
pub fn update_setting(
state: tauri::State<crate::AppState>,
key: String,
value: String,
) -> Result<(), String> {
update_setting_inner(&state.db_store, &key, &value)
}
// ---------------------------------------------------------------------------
// Settings export / import (v0.7.8)
// ---------------------------------------------------------------------------
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
/// `get_settings` filters back to `None`).
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
if let Some(f) = &s.default_folder_id {
m.insert("default_folder_id".into(), f.clone());
} else {
m.insert("default_folder_id".into(), "null".into());
}
m.insert("theme".into(), s.theme.clone());
m.insert("font_size".into(), s.font_size.clone());
m.insert("accent_color".into(), s.accent_color.clone());
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
m.insert("table_page_size".into(), s.table_page_size.to_string());
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
m.insert("editor_font_family".into(), s.editor_font_family.clone());
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
if let Some(o) = &s.tag_order {
m.insert("tag_order".into(), o.clone());
}
m.insert(
"default_ports".into(),
serde_json::to_string(&s.default_ports).unwrap_or_default(),
);
m.insert(
"shortcuts".into(),
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
);
m
}
#[tauri::command]
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
let s = get_settings_inner(&state.db_store)?;
serde_json::to_string(&crate::models::settings::SettingsExport {
schema_version: 1,
settings: s,
})
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_settings(
json: String,
state: tauri::State<crate::AppState>,
) -> Result<(), String> {
let env: crate::models::settings::SettingsExport =
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
let map = settings_to_kv(&env.settings);
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.apply_settings(&map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn get_settings_returns_defaults() {
let st = state();
let s = get_settings_inner(&st).unwrap();
assert_eq!(s.theme, "system");
assert_eq!(s.font_size, "medium");
assert_eq!(s.accent_color, "#2563EB");
}
#[test]
fn update_setting_persists() {
let st = state();
update_setting_inner(&st, "theme", "light").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
}
#[test]
fn update_accent_color_persists() {
let st = state();
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
}
#[test]
fn settings_export_envelope_camel_case() {
use std::collections::HashMap;
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
}};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"schemaVersion\":1"));
assert!(json.contains("\"settings\":"));
}
#[test]
fn store_apply_settings_writes_all_keys() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
let store = crate::store::Store::from_connection(conn);
let mut map = std::collections::HashMap::new();
map.insert("theme".to_string(), "light".to_string());
map.insert("accent_color".to_string(), "#EF4444".to_string());
store.apply_settings(&map).unwrap();
assert_eq!(store.get_settings().unwrap().theme, "light");
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
}
}
@@ -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:?}"
);
}
@@ -355,6 +355,165 @@ pub fn pg_constraints_query(_schema: &str) -> String {
) )
} }
// ---------------------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes used by the role form.
pub fn pg_roles_query() -> String {
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
.to_string()
}
/// All role-to-role memberships (member/admin/grantor).
pub fn pg_role_memberships_query() -> String {
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
grantor::regrole::text AS grantor, admin_option \
FROM pg_auth_members ORDER BY role"
.to_string()
}
/// Table/view/matview privileges for a grantee, grouped one row per object.
pub fn pg_table_privileges_query(role: &str) -> String {
format!(
"SELECT table_schema AS schema, table_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
GROUP BY table_schema, table_name \
ORDER BY table_schema, table_name",
role
)
}
/// Sequence privileges for a grantee, grouped one row per sequence.
/// aclexplode-based (role_sequence_grants was removed in PG 15).
pub fn pg_sequence_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS schema, c.relname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
LATERAL aclexplode(c.relacl) p \
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname, c.relname \
ORDER BY n.nspname, c.relname",
role
)
}
/// Routine (function/procedure) privileges for a grantee.
pub fn pg_routine_privileges_query(role: &str) -> String {
format!(
"SELECT routine_schema AS schema, routine_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.routine_privileges \
WHERE grantee = '{}' \
GROUP BY routine_schema, routine_name \
ORDER BY routine_schema, routine_name",
role
)
}
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
/// information_schema has no schema_privileges view.
pub fn pg_schema_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname ORDER BY n.nspname",
role
)
}
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
pub fn pg_database_privileges_query(role: &str) -> String {
format!(
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
bool_or(a.is_grantable) AS grantable \
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
AND d.datistemplate = false \
GROUP BY d.datname \
ORDER BY d.datname",
role
)
}
/// Non-system tablespaces for the table-options picker.
pub fn pg_tablespaces_query() -> String {
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
}
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
pub fn pg_rebuild_readiness_query() -> String {
"SELECT \
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
EXISTS(SELECT 1 FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
.to_string()
}
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
pub fn pg_table_fk_out_query() -> String {
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace n ON cl.relnamespace = n.oid \
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
}
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
pub fn pg_table_fk_in_query() -> String {
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
JOIN pg_class r ON c.confrelid = r.oid \
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
}
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
pub fn pg_table_grants_query() -> String {
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
GROUP BY grantee".to_string()
}
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
pub fn pg_table_owned_sequences_query() -> String {
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
JOIN pg_class t ON d.refobjid = t.oid \
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -641,4 +800,210 @@ mod tests {
assert!(sql.contains("comment"), "should select comment; got: {sql}"); assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}"); assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
} }
// ---------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------
#[test]
fn pg_roles_query_filters_pg_prefix() {
let sql = pg_roles_query();
assert!(sql.contains("pg_roles"));
assert!(sql.contains("rolname !~ '^pg_'"));
assert!(sql.contains("rolcanlogin"));
assert!(sql.contains("rolconnlimit::int8"));
}
#[test]
fn pg_role_memberships_query_uses_pg_auth_members() {
let sql = pg_role_memberships_query();
assert!(sql.contains("pg_auth_members"));
assert!(sql.contains("admin_option"));
}
#[test]
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
let sql = pg_table_privileges_query("appuser");
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("grantee = 'appuser'"));
assert!(sql.contains("array_agg"));
assert!(sql.contains("GROUP BY"));
}
#[test]
fn pg_database_privileges_query_uses_aclexplode() {
let sql = pg_database_privileges_query("appuser");
assert!(sql.contains("aclexplode"));
assert!(sql.contains("pg_database"));
assert!(sql.contains("appuser"));
}
#[test]
fn pg_rebuild_readiness_query_checks_blockers() {
let sql = pg_rebuild_readiness_query();
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("pg_policy"));
assert!(sql.contains("pg_inherits"));
assert!(sql.contains("pg_partitioned_table"));
assert!(sql.contains("is_generated"));
}
#[test]
fn pg_tablespaces_query_filters_pg_prefix() {
let sql = pg_tablespaces_query();
assert!(sql.contains("pg_tablespace"));
assert!(sql.contains("spcname !~ '^pg_'"));
}
#[test]
fn pg_table_fk_out_query_uses_pg_constraint_f() {
let sql = pg_table_fk_out_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("contype = 'f'"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_fk_in_query_finds_referencing_tables() {
let sql = pg_table_fk_in_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("confrel"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_grants_query_uses_table_privileges() {
let sql = pg_table_grants_query();
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("$1") && sql.contains("$2"));
assert!(sql.contains("array_agg"));
}
#[test]
fn pg_table_owned_sequences_query_uses_pg_depend() {
let sql = pg_table_owned_sequences_query();
assert!(sql.contains("pg_depend"));
assert!(sql.contains("pg_class"));
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()
} }
@@ -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};
@@ -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('\'', "''")
)
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,21 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups, //! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders. //! and synthesized object DDL. No DB I/O — deterministic string builders.
use serde::{Deserialize, Serialize};
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo}; use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Column model for the SQLite table editor. Mirrors the frontend payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SqliteColumn {
pub name: String,
#[serde(rename = "type")]
pub type_: String,
pub nullable: bool,
pub default: Option<String>,
pub is_pk: bool,
pub auto_increment: bool,
pub unique: bool,
}
/// Double-quote an identifier, doubling embedded quotes. /// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String { pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\"")) format!("\"{}\"", name.replace('"', "\"\""))
@@ -125,6 +139,98 @@ pub fn constraint_ddl(c: &ConstraintInfo) -> String {
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition) quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
} }
// --- SQLite table editor ---
fn validate_type_fragment(t: &str) -> Result<(), String> {
let l = t.trim().to_lowercase();
if l.is_empty() { return Err("column type is required".into()); }
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
Ok(())
}
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
validate_object_name(table)?;
let mut defs: Vec<String> = vec![];
let mut pk_cols: Vec<String> = vec![];
for c in cols {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
} else {
if !c.nullable { d.push_str(" NOT NULL"); }
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
}
defs.push(d);
}
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
// columns (single or composite) — the plan draft's `len > 1` condition would
// silently drop a single-column PK constraint.
if !pk_cols.is_empty() {
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
}
for (lc, refc) in fks {
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
}
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
}
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
/// for edits SQLite's ALTER TABLE can't express.
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
// rename detection: same position+type+nullable+default, name changed
for (i, n) in new.iter().enumerate() {
if let Some(o) = old.get(i) {
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
}
}
}
// add column (new tail column, safe only if nullable or defaulted)
if new.len() > old.len() {
if let Some(c) = new.last() {
if c.nullable || c.default.is_some() {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
if !c.nullable {
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
}
return Ok(vec![d]);
}
}
}
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
sqlite_rebuild_script(table, old, new)
}
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
validate_object_name(table)?;
let tmp = format!("_gl_{}_tmp", table);
let create = sqlite_create_table_sql(&tmp, new, &[])?;
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
Ok(vec![
create,
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
format!("DROP TABLE \"main\".{}", quote_ident(table)),
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
])
}
/// Fail-closed readiness reason, or None if rebuild is safe.
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
if old.iter().any(|c| c.auto_increment) {
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -258,4 +364,50 @@ mod tests {
let ddl = constraint_ddl(&c); let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)"); assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
} }
#[test]
fn sqlite_create_with_autoincrement_pk() {
let cols = vec![
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(sql.contains("\"name\" TEXT"));
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
}
#[test]
fn sqlite_create_rejects_bad_identifier() {
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
}
#[test]
fn sqlite_edit_add_column_when_safe() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
let new = vec![
old[0].clone(),
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
}
#[test]
fn sqlite_edit_rename_column() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
}
#[test]
fn sqlite_edit_typechange_requires_rebuild() {
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
}
} }
+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());
}

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