Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9611567daa | ||
|
|
0bc5110e05 | ||
|
|
2433697951 | ||
|
|
341354ecb9 | ||
|
|
37e6638854 | ||
|
|
7bd4e8d05f | ||
|
|
7a66b70c26 | ||
|
|
c4eaaa70ce | ||
|
|
5aadece5fa | ||
|
|
4cff3e2508 | ||
|
|
96d34a68a1 | ||
|
|
c22e675f2b | ||
|
|
73c8cdd79b | ||
|
|
d8bab12b51 | ||
|
|
501fed962c | ||
|
|
62eed15844 | ||
|
|
6854d889c5 | ||
|
|
d752642b4d | ||
|
|
2d6b576d3a | ||
|
|
6d1bbb0fa3 | ||
|
|
df3e3957a2 | ||
|
|
5bc31a14a6 | ||
|
|
38de9b4da1 | ||
|
|
091d9df6f0 | ||
|
|
751746f784 | ||
|
|
32a7b852ec | ||
|
|
9fb3222956 | ||
|
|
76cd1ded16 | ||
|
|
a424e31de8 | ||
|
|
7401e91a12 | ||
|
|
0c74d77e75 | ||
|
|
8d8ed78202 | ||
|
|
0c8c240384 | ||
|
|
0b8b11cef3 | ||
|
|
0d6c9a039a | ||
|
|
1d72a174dd | ||
|
|
cebd548568 | ||
|
|
d84c70a1be | ||
|
|
04e8ed3300 | ||
|
|
cbc54119ed | ||
|
|
89fd35f697 |
@@ -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
|
||||||
@@ -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. -->
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -7,12 +7,16 @@ 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: macOS bundles are AD-HOC signed via tauri.conf.json
|
||||||
# see README "Download a release"). tauri-action automatically signs +
|
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
|
||||||
# notarizes when the signing secrets are present, so the moment we add
|
# the linker-only signature that made macOS report the app as "damaged" and
|
||||||
# APPLE_CERTIFICATE / APPLE_API_KEY / WINDOWS_CERTIFICATE (or Azure Trusted
|
# refuse Finder launches — users now get the standard "Open Anyway" prompt
|
||||||
# Signing) to repo secrets, future builds are signed — no changes to this
|
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see
|
||||||
# file required.
|
# README). Still NOT Developer-ID signed / notarized (no certs yet).
|
||||||
|
# tauri-action automatically signs + notarizes when the signing secrets are
|
||||||
|
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
|
||||||
|
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
|
||||||
|
# builds are properly signed — no changes to this file required.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -58,17 +62,320 @@ 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
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
ubuntu-22.04)
|
||||||
|
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"
|
||||||
|
# Build the shared prerequisites (catalog headers + libpq/common/
|
||||||
|
# port archives) SERIALLY FIRST. Parallel `make -C` runs recurse
|
||||||
|
# into src/common concurrently and race `rm -f` / `ar crs` on the
|
||||||
|
# same .a — intermittently failing with 'ar: libpgcommon_srv.a:
|
||||||
|
# file format not recognized'. Once the archives exist, the tools'
|
||||||
|
# -j builds find them up to date and those recursions are no-ops.
|
||||||
|
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"/
|
||||||
|
# pg_dump links shared libpq (PG16 has no --disable-shared for the
|
||||||
|
# client tools); bundle libpq.so alongside and point the loader at
|
||||||
|
# the app's resource dir via $ORIGIN rpath.
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
macos-latest|macos-15-intel)
|
||||||
|
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}
|
||||||
|
# ac_cv_func_strchrnul=no: Xcode 16.4's SDK marks strchrnul as
|
||||||
|
# introduced in macOS 15.4, so configure detects it and PG's
|
||||||
|
# snprintf.c calls the system symbol — which fails to compile
|
||||||
|
# (-Werror=unguarded-availability-new) and would crash at runtime
|
||||||
|
# on macOS < 15.4. Forcing the check off makes PG use its own
|
||||||
|
# inline fallback instead.
|
||||||
|
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||||
|
# macOS SDKs always declare strchrnul in <string.h> (Xcode 16.4
|
||||||
|
# marks it 'introduced in macOS 15.4'), so even with HAVE_STRCHRNUL
|
||||||
|
# disabled PG16's static-inline fallback would collide with the
|
||||||
|
# header declaration on newer SDKs. Rename PG's fallback so the
|
||||||
|
# tools compile on any SDK and never touch the (15.4+ only)
|
||||||
|
# system symbol.
|
||||||
|
sed -i '' 's/strchrnul/pg_strchrnul/g' src/port/snprintf.c
|
||||||
|
# Serial shared-prerequisite build first (see the Linux block: the
|
||||||
|
# archives must exist before the -j tool builds recurse into
|
||||||
|
# src/common).
|
||||||
|
make -C src/backend generated-headers
|
||||||
|
make -C src/interfaces/libpq all
|
||||||
|
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
|
||||||
|
make -j"$(sysctl -n hw.ncpu)" -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.5.dylib "$OUT"/libpq.5.dylib
|
||||||
|
# Rewrite the absolute /tmp/pgbuild libpq install_name to a
|
||||||
|
# relative @loader_path so the tools find libpq next to themselves.
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
|
||||||
|
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
windows-latest)
|
||||||
|
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
||||||
|
curl -fsSL "$URL" -o /tmp/pg.zip
|
||||||
|
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"/
|
||||||
|
# pg_dump.exe needs far more than libpq.dll (libcrypto-3-x64.dll,
|
||||||
|
# libssl-3-x64.dll, libiconv-2.dll, libintl-9.dll,
|
||||||
|
# libwinpthread-1.dll, liblz4.dll, libzstd.dll, ICU DLLs, ...);
|
||||||
|
# copy every DLL next to the tools.
|
||||||
|
cp /tmp/pg/pgsql/bin/*.dll "$OUT"/
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
(cd "$OUT" && sha256sum * | tee checksums.txt)
|
||||||
|
# Resolve the per-platform binary suffix WITHOUT a command
|
||||||
|
# substitution: `$( [ ... ] && echo .exe )` returns exit 1 when the
|
||||||
|
# test is false, and under `set -e` that aborts the whole step.
|
||||||
|
if [ "${{ matrix.platform }}" = windows-latest ]; then
|
||||||
|
BIN_EXT=".exe"
|
||||||
|
else
|
||||||
|
BIN_EXT=""
|
||||||
|
fi
|
||||||
|
for b in pg_dump pg_restore psql; 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 wrong @loader_path / rpath before we ship a broken bundle.
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
||||||
|
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
|
||||||
|
codesign --force --sign - "$dylib" 2>/dev/null || true
|
||||||
|
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:
|
||||||
|
projectPath: desktop
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ github.ref_name }}
|
||||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
# 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
|
||||||
|
# README download tables link to them statically per release —
|
||||||
|
# remember to update both README tables when cutting a new version.
|
||||||
|
|
||||||
|
# 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_TOKEN secret (a PAT with `repo` scope on the tap
|
||||||
|
# repo — the default GITHUB_TOKEN cannot push to a different repo). The job
|
||||||
|
# no-ops until that secret is set, so releases stay green before this is
|
||||||
|
# configured.
|
||||||
|
bump-cask:
|
||||||
|
needs: publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version and DMG checksums
|
||||||
|
id: meta
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
|
||||||
|
gh release download "$GITHUB_REF_NAME" -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
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: AdrianBonpin/homebrew-gridline
|
||||||
|
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
path: tap
|
||||||
|
|
||||||
|
- name: Render and push cask
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,25 +154,57 @@ 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
|
||||||
|
|
||||||
|
**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
|
||||||
|
3. Open a PR into `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
|
||||||
|
|
||||||
|
Cut a release by tagging the **`prod`** branch once the PR is merged — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub).
|
||||||
|
|
||||||
|
**Before tagging**, keep everything in sync:
|
||||||
|
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
|
||||||
|
- `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.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.
|
||||||
|
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened".
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
@@ -171,7 +216,7 @@ cargo test # Rust tests
|
|||||||
|
|
||||||
## Constraints & Guardrails
|
## Constraints & Guardrails
|
||||||
|
|
||||||
- **Do NOT** implement `pg_dump` file format parsing — always shell out to system binaries
|
- **Do NOT** implement `pg_dump` file format parsing — always shell out to (bundled or system) binaries
|
||||||
- **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively
|
- **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively
|
||||||
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
|
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
|
||||||
- **Do NOT** log credentials, connection strings, or query data
|
- **Do NOT** log credentials, connection strings, or query data
|
||||||
@@ -200,10 +245,12 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| 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 |
|
||||||
|
| 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 |
|
||||||
| 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) |
|
||||||
|
| 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 |
|
||||||
@@ -225,7 +272,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 |
|
||||||
@@ -248,10 +295,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| 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) |
|
||||||
|
| 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 |
|
||||||
| 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 |
|
||||||
@@ -262,6 +310,19 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| 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 |
|
||||||
@@ -276,7 +337,15 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
|
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
|
||||||
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
|
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
|
||||||
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
|
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
|
||||||
|
| Schema CRUD | ✅ | Create / rename / drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning (v0.7.5) |
|
||||||
|
| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) |
|
||||||
|
| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) |
|
||||||
|
| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) |
|
||||||
|
| 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) |
|
||||||
| 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) |
|
||||||
|
|
||||||
### Query Editor
|
### Query Editor
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -287,6 +356,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| 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 | ❌ | |
|
||||||
|
| 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 |
|
||||||
@@ -301,7 +371,9 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
||||||
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
||||||
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
|
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
|
||||||
| SQLite .dump | ❌ | |
|
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
|
||||||
|
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
|
||||||
|
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
|
||||||
| Table structure export (DDL) | ❌ | |
|
| Table structure export (DDL) | ❌ | |
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -309,6 +381,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 |
|
||||||
@@ -321,7 +394,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 |
|
||||||
|
|||||||
@@ -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,22 +18,63 @@
|
|||||||
<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://git.ranio.xyz/adrianbonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-Gitea-4183C4.svg?style=for-the-badge" alt="Open source on Gitea" /></a>
|
||||||
</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://git.ranio.xyz/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
|
||||||
<a href="https://github.com/adrianbonpin/gridline/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://git.ranio.xyz/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.12**):
|
||||||
|
|
||||||
|
| OS | Architecture | Download |
|
||||||
|
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) |
|
||||||
|
| **macOS** | Intel | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) |
|
||||||
|
| **Windows** | x64 | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) |
|
||||||
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) |
|
||||||
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) |
|
||||||
|
| **Other Linux** | amd64 | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) |
|
||||||
|
|
||||||
|
**macOS via Homebrew** (recommended for Mac users):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask AdrianBonpin/gridline/gridline
|
||||||
|
# or, after a one-time `brew tap AdrianBonpin/gridline`:
|
||||||
|
brew install --cask gridline
|
||||||
|
```
|
||||||
|
|
||||||
|
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.)
|
||||||
|
|
||||||
|
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.12
|
||||||
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
|
new version's asset names, which are tauri-action's default naming:
|
||||||
|
Gridline_<version>_aarch64.dmg / _x64.dmg / _x64-setup.exe /
|
||||||
|
_amd64.deb / _amd64.AppImage and Gridline-<version>-1.x86_64.rpm
|
||||||
|
The .msi (Gridline_<version>_x64_en-US.msi) is not linked below.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is Gridline?
|
## What is Gridline?
|
||||||
|
|
||||||
Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives.
|
Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives.
|
||||||
|
|
||||||
- **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.
|
||||||
@@ -56,7 +96,23 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
- **2026-08-04:** v0.7.0 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
<details>
|
||||||
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
- **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-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
||||||
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
||||||
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
|
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
|
||||||
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
|
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
|
||||||
@@ -67,12 +123,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.
|
||||||
@@ -81,16 +142,33 @@ 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.
|
||||||
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
|
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
|
||||||
|
- **Schema CRUD** — create/rename/drop schemas from the object tree, with typed-name + dependency warning on CASCADE drops.
|
||||||
|
- **Global object search** — ⌘K, current schema, all object types; results open a table tab or jump to the Objects view.
|
||||||
|
- **Copy as DDL** — `CREATE` DDL for every browsable object type.
|
||||||
|
- **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.
|
||||||
@@ -101,8 +179,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`.
|
||||||
@@ -110,11 +193,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.
|
|
||||||
|
- **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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,41 +261,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 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -207,15 +303,24 @@ 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://git.ranio.xyz/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://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
||||||
|
|
||||||
#### Installers are unsigned (for now)
|
#### Installers are unsigned (for now)
|
||||||
|
|
||||||
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 is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
||||||
|
|
||||||
- **macOS:** right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches.
|
||||||
|
|
||||||
|
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
||||||
|
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app
|
||||||
|
```
|
||||||
|
|
||||||
|
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined.
|
||||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
||||||
- **Linux:** no warning — install and run normally.
|
- **Linux:** no warning — install and run normally.
|
||||||
|
|
||||||
@@ -223,16 +328,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
|
|||||||
|
|
||||||
#### 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. (If a newer version is available, swap `0.7.0` for the version shown in the release title.)
|
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.12**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :--- | :--- | :--- |
|
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.12_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.12-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | `Gridline_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.
|
||||||
|
|
||||||
@@ -242,25 +347,26 @@ 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.0
|
git tag v0.7.12
|
||||||
git push origin v0.7.0
|
git push origin v0.7.12
|
||||||
```
|
```
|
||||||
|
|
||||||
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**.
|
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
||||||
|
|
||||||
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`.
|
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://git.ranio.xyz/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
|
||||||
@@ -273,24 +379,28 @@ bun run tauri build
|
|||||||
- **Windows:** 10 or newer
|
- **Windows:** 10 or newer
|
||||||
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
|
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
|
||||||
- **RAM:** 8 GB recommended
|
- **RAM:** 8 GB recommended
|
||||||
- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features
|
- **PostgreSQL client tools:** `pg_dump`/`pg_restore`/`psql` are **bundled** with Gridline — no separate install required for backup/restore/sync. (System tools, if installed, are preferred.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
||||||
@@ -301,37 +411,39 @@ 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.0), 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:
|
||||||
|
|
||||||
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL
|
- **Admin follow-up** — PostgreSQL users/roles + grants management, and table maintenance actions (VACUUM / ANALYZE / REINDEX)
|
||||||
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
||||||
- **More database types** — MariaDB, TimescaleDB, and friends
|
- **More database types** — MariaDB, TimescaleDB, and friends
|
||||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
||||||
@@ -343,6 +455,7 @@ Highlights of what's next:
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Download](#download)
|
||||||
- [What is Gridline?](#what-is-gridline)
|
- [What is Gridline?](#what-is-gridline)
|
||||||
- [Who is it for?](#who-is-it-for)
|
- [Who is it for?](#who-is-it-for)
|
||||||
- [Recent Changes](#recent-changes)
|
- [Recent Changes](#recent-changes)
|
||||||
@@ -362,7 +475,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://git.ranio.xyz/adrianbonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
|
||||||
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,47 +6,32 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Shipped (0.7.0)
|
## ✅ Shipped (0.7.10)
|
||||||
|
|
||||||
- **New Connection screen revamp** — a single progressive flow: connection-string input + 2-column provider tab grid → expands into the full configuration form (label, tags/env/folder, General + SSH·SSL tabs). Removes the simple/detailed toggle.
|
- **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.
|
||||||
- **Full MySQL DB viewer support** — connect (incl. SSL + SSH), browse databases/tables/columns/FKs, run queries, paginate, inline cell editing + changes queue (insert/update/delete/bulk/empty/drop), DDL copy (`SHOW CREATE TABLE`), CSV/JSON import.
|
- **Version bump** 0.7.9 → **0.7.10**.
|
||||||
- **DB viewer capability gating** — pure `dbCapabilities.ts` matrix per DB type; unsupported views show a clean "not supported" state instead of broken UI. Redis browsing explicitly gated off.
|
|
||||||
- **Supabase & NeonDB managed-PG presets** — provider cards with in-app setup instructions; persist as `postgresql` with an SSL hint.
|
|
||||||
- **SQLite file-path mode** — URI field becomes a file-path input with `Browse…`.
|
|
||||||
- **Tag overflow scroll** — connection cards show ≤3 tags, then scroll horizontally.
|
|
||||||
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
|
||||||
- **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.
|
## ✅ Shipped (0.7.9)
|
||||||
|
|
||||||
## 🎯 Next up
|
- **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**.
|
||||||
|
|
||||||
### Full Object Management (PostgreSQL)
|
## 🎯 Next up (0.8.0) — TBD
|
||||||
|
|
||||||
Gridline can already **browse** every PostgreSQL object type (functions, triggers, sequences, enums, extensions, views, materialized views, procedures, indexes, constraints). Next up: full **CRUD** on those objects without ever touching the Query tab.
|
Scope to be determined.
|
||||||
|
|
||||||
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
|
## ✅ Shipped (0.7.8)
|
||||||
- Enums: add/remove values, rename types
|
|
||||||
- Functions & procedures: edit signature + body, drop overloads by signature
|
|
||||||
- Triggers: create/edit/disable/enable, timing + event pickers
|
|
||||||
- Sequences: alter increment/start/min/max/cycle, restart
|
|
||||||
- Extensions: install/uninstall, schema reassignment
|
|
||||||
- Views & materialized views: edit definition, refresh matviews
|
|
||||||
- Indexes & constraints: create/drop per table with column pickers
|
|
||||||
- Staged through the changes queue (with confirmation) — never a surprise DDL
|
|
||||||
- **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only
|
|
||||||
|
|
||||||
### Shipping alongside (companions)
|
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
||||||
|
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
||||||
- **Schema CRUD** — create/rename/drop schemas from the object tree
|
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||||
- **Global object search** — Cmd+K-style search across tables, functions, triggers, sequences, and extensions by name
|
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||||
- **Copy as DDL for any object** — `CREATE FUNCTION` / `CREATE TRIGGER` / … via the same DDL-generation used for edit/drop previews (also covers the "Table structure export" queue item)
|
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||||
- **Object dependencies** — `pg_depend`-based "what depends on this object?" view, shown before drops so nothing breaks silently
|
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
|
||||||
|
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||||
### Admin follow-up (after object management)
|
- **Version bump** 0.7.7 → **0.7.8**.
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## 📋 In the queue
|
## 📋 In the queue
|
||||||
|
|
||||||
@@ -76,17 +61,14 @@ Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
|||||||
### Query Workbench Upgrades
|
### Query Workbench Upgrades
|
||||||
|
|
||||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
- **Multiple result sets** — 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
|
- **Result streaming to file** — export 500k+ rows without loading them all into memory
|
||||||
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||||
|
|
||||||
### Schema & Data Tooling
|
### Schema & Data Tooling
|
||||||
|
|
||||||
- **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
|
- **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)*
|
- **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** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||||
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
|
|
||||||
|
|
||||||
## 🔮 Planned
|
## 🔮 Planned
|
||||||
|
|
||||||
@@ -126,8 +108,61 @@ Deferred from 0.7.0, slated for this bucket:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ 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)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Enable Keychain toggle** — wired end-to-end (default ON, opt-out): OFF = don't persist the DB password (session-only, re-prompt on connect) + purge the existing keychain entry; SSH secrets stay keychain-only.
|
||||||
|
- **Objects view tabbed workspace** — object details open as tabs in the shared tabbed workspace (per-type icons), with an inline manual query tab and the changes queue reachable from the tab bar.
|
||||||
|
- **⌘K object-search fixes** — clicking a result now switches to the Objects view / opens the table tab; arrow-key navigation + Enter to pick.
|
||||||
|
- **Version bump** 0.7.5 → **0.7.6**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.5)
|
||||||
|
|
||||||
|
- **Bundled PG client tools** — static `pg_dump`/`pg_restore`/`psql` ship with the app (system-first, bundled fallback) so backup/restore/sync work with no separate install.
|
||||||
|
- **Schema CRUD** — create/rename/drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning.
|
||||||
|
- **Global object search** — Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view.
|
||||||
|
- **Copy as DDL for any object** — `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized).
|
||||||
|
- **Object dependencies** — `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop).
|
||||||
|
- **Version bump** 0.7.0 → **0.7.5**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.0)
|
||||||
|
|
||||||
|
- **New Connection screen revamp** — a single progressive flow: connection-string input + 2-column provider tab grid → expands into the full configuration form (label, tags/env/folder, General + SSH·SSL tabs). Removes the simple/detailed toggle.
|
||||||
|
- **Full MySQL DB viewer support** — connect (incl. SSL + SSH), browse databases/tables/columns/FKs, run queries, paginate, inline cell editing + changes queue (insert/update/delete/bulk/empty/drop), DDL copy (`SHOW CREATE TABLE`), CSV/JSON import.
|
||||||
|
- **DB viewer capability gating** — pure `dbCapabilities.ts` matrix per DB type; unsupported views show a clean "not supported" state instead of broken UI. Redis browsing explicitly gated off.
|
||||||
|
- **Supabase & NeonDB managed-PG presets** — provider cards with in-app setup instructions; persist as `postgresql` with an SSL hint.
|
||||||
|
- **SQLite file-path mode** — URI field becomes a file-path input with `Browse…`.
|
||||||
|
- **Tag overflow scroll** — connection cards show ≤3 tags, then scroll horizontally.
|
||||||
|
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
||||||
|
- **Version bump** 0.6.0 → **0.7.0**.
|
||||||
|
|
||||||
|
**Not in 0.7.0** (deferred, tracked above): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
|
||||||
|
|
||||||
## ✅ Shipped
|
## ✅ 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
|
- PostgreSQL and SQLite browse/query support
|
||||||
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
||||||
|
|||||||
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "gridline-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.7.12",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1783,8 +1783,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.0"
|
version = "0.7.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
@@ -3047,6 +3048,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 +3065,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.0"
|
version = "0.7.12"
|
||||||
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,14 @@ 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"
|
||||||
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 }
|
||||||
|
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 |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 37 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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Bundled PostgreSQL client tools
|
||||||
|
|
||||||
|
This directory is populated **at build time by CI** (`.github/workflows/release.yml`)
|
||||||
|
with statically-linked `pg_dump`, `pg_restore`, and `psql` for the current target.
|
||||||
|
Binaries are NOT committed to the repo.
|
||||||
|
|
||||||
|
In `tauri dev`, this dir is usually empty — the app falls back to any `pg_dump`/`pg_restore`
|
||||||
|
on `PATH` (system-first resolution). Admin features degrade with a clear error if neither is present.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_pg_tools_does_not_panic() {
|
fn detect_pg_tools_does_not_panic() {
|
||||||
let status = detect_pg_tools();
|
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
|
||||||
|
// status-shaping logic testable without one.
|
||||||
|
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
|
||||||
// May or may not find tools, but the call itself must not panic
|
// May or may not find tools, but the call itself must not panic
|
||||||
let _ = status.pg_dump_found;
|
let _ = status.pg_dump_found;
|
||||||
let _ = status.pg_restore_found;
|
let _ = status.pg_restore_found;
|
||||||
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
|
|||||||
pg_restore_found: false,
|
pg_restore_found: false,
|
||||||
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
|
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
|
||||||
pg_restore_version: None,
|
pg_restore_version: None,
|
||||||
|
pg_dump_source: None,
|
||||||
|
pg_restore_source: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&status).unwrap();
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
assert!(json.contains("pg_dump_found"));
|
assert!(json.contains("pg_dump_found"));
|
||||||
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
|
|||||||
tables: None,
|
tables: None,
|
||||||
no_owner: true,
|
no_owner: true,
|
||||||
};
|
};
|
||||||
run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed");
|
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_dump should succeed");
|
||||||
|
|
||||||
// --- 2. Restore into target ---
|
// --- 2. Restore into target ---
|
||||||
let restore_opts = RestoreOptions {
|
let restore_opts = RestoreOptions {
|
||||||
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
|
|||||||
clean: true,
|
clean: true,
|
||||||
schema: None,
|
schema: None,
|
||||||
};
|
};
|
||||||
run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed");
|
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_restore should succeed");
|
||||||
|
|
||||||
// --- 3. Verify data landed in target ---
|
// --- 3. Verify data landed in target ---
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
|
|||||||
// --- 4. Sync source -> target (target already has tables from the restore
|
// --- 4. Sync source -> target (target already has tables from the restore
|
||||||
// above — db_sync now passes --clean --if-exists, so it must succeed into a
|
// above — db_sync now passes --clean --if-exists, so it must succeed into a
|
||||||
// non-empty target). ---
|
// non-empty target). ---
|
||||||
run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed");
|
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("db_sync should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
||||||
3,
|
3,
|
||||||
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
|
|||||||
tables: None,
|
tables: None,
|
||||||
no_owner: true,
|
no_owner: true,
|
||||||
};
|
};
|
||||||
run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed");
|
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_dump (plain) should succeed");
|
||||||
|
|
||||||
// --- 2. Restore into target (plain -> psql path) ---
|
// --- 2. Restore into target (plain -> psql path) ---
|
||||||
let restore_opts = RestoreOptions {
|
let restore_opts = RestoreOptions {
|
||||||
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
|
|||||||
clean: false,
|
clean: false,
|
||||||
schema: None,
|
schema: None,
|
||||||
};
|
};
|
||||||
run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed");
|
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_restore (plain/psql) should succeed");
|
||||||
|
|
||||||
// --- 3. Verify data landed in target ---
|
// --- 3. Verify data landed in target ---
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -392,3 +401,134 @@ fn integration_plain_dump_restore() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&dump_path);
|
let _ = std::fs::remove_file(&dump_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Tool resolution (Task 2.1: system-first, bundled-fallback)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_tool_prefers_system_then_bundled_then_bare() {
|
||||||
|
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
|
||||||
|
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
|
||||||
|
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_bin_name_appends_exe_on_windows() {
|
||||||
|
let name = bundled_bin_name("pg_dump");
|
||||||
|
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SQLite .dump / restore / sync core (Task 2.2)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
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")));
|
||||||
|
}
|
||||||
@@ -223,6 +223,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||||
assert_eq!(result.name, "Prod");
|
assert_eq!(result.name, "Prod");
|
||||||
@@ -254,6 +255,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
assert!(create_connection_inner(&st, input).is_err());
|
assert!(create_connection_inner(&st, input).is_err());
|
||||||
}
|
}
|
||||||
@@ -283,6 +285,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let conn = create_connection_inner(&st, input).unwrap();
|
let conn = create_connection_inner(&st, input).unwrap();
|
||||||
delete_connection_inner(&st, &conn.id).unwrap();
|
delete_connection_inner(&st, &conn.id).unwrap();
|
||||||
@@ -300,6 +303,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -331,6 +335,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -8,10 +8,12 @@ use crate::models::db_viewer::{
|
|||||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
||||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
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
|
||||||
@@ -149,19 +151,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether the system `pg_dump` binary is on PATH.
|
/// Check whether the `pg_dump` binary at the given path (bare name or
|
||||||
pub fn pg_dump_available() -> bool {
|
/// absolute path) is executable and reports a version.
|
||||||
std::process::Command::new("pg_dump")
|
pub fn pg_dump_available_at(path: &str) -> bool {
|
||||||
|
std::process::Command::new(path)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
||||||
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
|
/// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
|
||||||
/// `PGPASSWORD` environment variable only — never as argv — and are never
|
/// Credentials are supplied via the `PGPASSWORD` environment variable only —
|
||||||
/// logged. Execution requires a reachable PostgreSQL server plus an installed
|
/// never as argv — and are never logged. Execution requires a reachable
|
||||||
/// `pg_dump`; unit tests cover the argument construction instead.
|
/// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
|
||||||
|
/// argument construction instead.
|
||||||
pub fn get_pg_ddl_via_dump(
|
pub fn get_pg_ddl_via_dump(
|
||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -170,13 +174,14 @@ pub fn get_pg_ddl_via_dump(
|
|||||||
user: &str,
|
user: &str,
|
||||||
db: &str,
|
db: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
|
pg_dump_path: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
if !pg_dump_available() {
|
if !pg_dump_available_at(pg_dump_path) {
|
||||||
return Err(
|
return Err(
|
||||||
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
|
"pg_dump not found. Install PostgreSQL client tools or use the bundled tools to copy table schema.".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut cmd = std::process::Command::new("pg_dump");
|
let mut cmd = std::process::Command::new(pg_dump_path);
|
||||||
cmd.args([
|
cmd.args([
|
||||||
format!("--host={host}"),
|
format!("--host={host}"),
|
||||||
format!("--port={port}"),
|
format!("--port={port}"),
|
||||||
@@ -582,26 +587,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.
|
||||||
@@ -888,6 +942,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() {
|
||||||
@@ -965,6 +1020,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))
|
||||||
@@ -976,6 +1035,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) => {
|
||||||
@@ -1020,6 +1086,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
|
||||||
@@ -1077,6 +1145,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,
|
||||||
@@ -1098,8 +1178,14 @@ 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)),
|
||||||
@@ -1110,6 +1196,7 @@ pub async fn db_connect(
|
|||||||
&config,
|
&config,
|
||||||
&state.ssh_manager,
|
&state.ssh_manager,
|
||||||
&state.pool_manager,
|
&state.pool_manager,
|
||||||
|
&state.cancel_registry,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -1127,6 +1214,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1861,6 +1949,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,
|
||||||
@@ -2107,14 +2249,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 {
|
||||||
@@ -2152,6 +2295,27 @@ 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::Ddl { sql, .. } => {
|
||||||
|
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||||
|
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,
|
||||||
@@ -2244,6 +2408,16 @@ 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 { sql, .. } => {
|
||||||
|
// 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,
|
||||||
columns,
|
columns,
|
||||||
@@ -2316,6 +2490,12 @@ pub async fn execute_change(
|
|||||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { .. } => {
|
||||||
|
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,
|
||||||
@@ -2371,6 +2551,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,
|
||||||
@@ -2715,10 +2904,12 @@ pub async fn get_table_ddl(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
||||||
// travel via PGPASSWORD, never argv.
|
// travel via PGPASSWORD, never argv. Resolve system-first,
|
||||||
|
// bundled-fallback, before moving into the closure.
|
||||||
|
let pg_dump_path = crate::commands::backup::resolve_tool(&app, "pg_dump").0;
|
||||||
let ddl = tokio::task::spawn_blocking(move || {
|
let ddl = tokio::task::spawn_blocking(move || {
|
||||||
get_pg_ddl_via_dump(
|
get_pg_ddl_via_dump(
|
||||||
&schema, &table, &dump_host, dump_port, &user, &db, &password,
|
&schema, &table, &dump_host, dump_port, &user, &db, &password, &pg_dump_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2815,6 +3006,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_serialization_uses_snake_case_tag() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-1".to_string(),
|
||||||
|
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains(r#""type":"ddl""#),
|
||||||
|
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
|
||||||
|
json
|
||||||
|
);
|
||||||
|
assert!(json.contains(r#""id":"chg-ddl-1""#));
|
||||||
|
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_id_is_accessible() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-42".to_string(),
|
||||||
|
sql: "DROP INDEX public.i".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(change.id(), "chg-ddl-42");
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `build_update_sql` produces valid SQL with all required
|
/// Verify that `build_update_sql` produces valid SQL with all required
|
||||||
/// clauses.
|
/// clauses.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3153,6 +3371,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())
|
||||||
}
|
}
|
||||||
@@ -3180,8 +3470,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, ®).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");
|
||||||
@@ -3240,8 +3531,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, ®).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"
|
||||||
@@ -3290,8 +3582,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, ®).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];
|
||||||
@@ -3319,4 +3612,11 @@ mod tests {
|
|||||||
.expect("columns");
|
.expect("columns");
|
||||||
assert_eq!(cols.len(), data.columns.len());
|
assert_eq!(cols.len(), data.columns.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_dump_available_at_checks_given_path() {
|
||||||
|
// A bare name that resolves on PATH passes; a bogus path fails.
|
||||||
|
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
|
||||||
|
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
|||||||
database: None,
|
database: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
environment: Some("development".to_string()),
|
environment: Some("development".to_string()),
|
||||||
ssh_host: None,
|
ssh_host: None,
|
||||||
ssh_port: None,
|
ssh_port: None,
|
||||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
match store.create_connection(input) {
|
match store.create_connection(input) {
|
||||||
Ok(_) => imported += 1,
|
Ok(_) => imported += 1,
|
||||||
@@ -214,6 +215,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
});
|
});
|
||||||
let json = export_connections_inner(&st).unwrap();
|
let json = export_connections_inner(&st).unwrap();
|
||||||
assert!(json.contains("\"name\""));
|
assert!(json.contains("\"name\""));
|
||||||
@@ -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,6 +5,8 @@ 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 query;
|
pub mod query;
|
||||||
pub mod schema_graph;
|
pub mod schema_graph;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
use super::*;
|
||||||
|
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 schema_crud_create_rename_drop() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let name = "gridline_test_schema";
|
||||||
|
create_schema_inner(&pm, &id, name).await.unwrap();
|
||||||
|
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
|
||||||
|
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
|
||||||
|
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn search_objects_finds_table_and_function() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
|
||||||
|
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
|
||||||
|
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
|
||||||
|
// substring match across types; assert it returns a Vec<ObjectSearchHit>
|
||||||
|
assert!(fns.iter().all(|h| h.object_type != ""));
|
||||||
|
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
|
||||||
|
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
|
||||||
|
assert!(all.len() <= 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn object_ddl_for_sequence_enum_function() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
// demo has users_id_seq, an enum, and a function
|
||||||
|
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
|
||||||
|
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
|
||||||
|
// function: pg_get_functiondef passthrough
|
||||||
|
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
|
||||||
|
assert!(f.is_ok());
|
||||||
|
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// 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()));
|
||||||
|
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_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]
|
||||||
|
#[ignore]
|
||||||
|
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap();
|
||||||
|
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||||
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||||
|
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn get_available_extensions_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||||
|
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||||
|
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn object_dependencies_for_table_includes_view() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
// demo has order_summary VIEW depending on orders — drop would break it
|
||||||
|
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
|
||||||
|
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
|
||||||
|
// schema contents path
|
||||||
|
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
@@ -17,6 +17,30 @@ 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}")))?;
|
||||||
|
|
||||||
@@ -748,7 +801,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]
|
||||||
@@ -1060,6 +1176,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -1137,4 +1254,127 @@ 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query available (installable) extensions with default version + comment.
|
||||||
|
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
|
||||||
|
pub fn pg_available_extensions_query() -> String {
|
||||||
|
"SELECT name, default_version::text AS version, comment \
|
||||||
|
FROM pg_available_extensions ORDER BY name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Query indexes in a schema.
|
/// Query indexes in a schema.
|
||||||
///
|
///
|
||||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||||
@@ -347,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -624,4 +791,99 @@ mod tests {
|
|||||||
let sql = pg_extensions_query();
|
let sql = pg_extensions_query();
|
||||||
assert!(sql.contains("pg_extension"));
|
assert!(sql.contains("pg_extension"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_available_extensions_query_has_name_default_version_comment() {
|
||||||
|
let sql = pg_available_extensions_query();
|
||||||
|
assert!(sql.contains("pg_available_extensions"));
|
||||||
|
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
|
||||||
|
assert!(sql.contains("comment"), "should select comment; 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod object_crud;
|
||||||
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
|
||||||
|
//! and synthesized object DDL. No DB I/O — deterministic string builders.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
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.
|
||||||
|
pub fn quote_ident(name: &str) -> String {
|
||||||
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
|
||||||
|
pub fn validate_object_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("Name must not be empty".into());
|
||||||
|
}
|
||||||
|
for bad in [";", "--", "/*", "'", "\"", "\\"] {
|
||||||
|
if name.contains(bad) {
|
||||||
|
return Err(format!("Name contains forbidden character(s): {bad}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_schema_sql(name: &str) -> Result<String, String> {
|
||||||
|
validate_object_name(name)?;
|
||||||
|
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
|
||||||
|
}
|
||||||
|
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
|
||||||
|
validate_object_name(old)?;
|
||||||
|
validate_object_name(new)?;
|
||||||
|
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
|
||||||
|
}
|
||||||
|
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
|
||||||
|
validate_object_name(name)?;
|
||||||
|
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
|
||||||
|
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
|
||||||
|
pub fn pg_object_search_query() -> String {
|
||||||
|
"SELECT name, schema, type FROM (
|
||||||
|
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT i.relname, ns.nspname, 'INDEX' 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 ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.conname, ns.nspname, 'CONSTRAINT' 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=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
|
||||||
|
) AS hits ORDER BY type, name LIMIT 100".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
|
||||||
|
pub fn pg_object_oid_query(object_type: &str) -> String {
|
||||||
|
match object_type {
|
||||||
|
"table" | "view" | "materialized view" | "sequence" | "index" =>
|
||||||
|
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
"function" | "procedure" =>
|
||||||
|
"SELECT 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".to_string(),
|
||||||
|
"trigger" =>
|
||||||
|
"SELECT 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".to_string(),
|
||||||
|
"enum" =>
|
||||||
|
"SELECT t.oid 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".to_string(),
|
||||||
|
"extension" =>
|
||||||
|
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
"constraint" =>
|
||||||
|
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
|
||||||
|
pub fn pg_depend_query() -> String {
|
||||||
|
"SELECT d.deptype::text AS deptype,
|
||||||
|
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
|
||||||
|
ELSE d.classid::regclass::text END AS class,
|
||||||
|
CASE
|
||||||
|
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
|
||||||
|
ELSE ''
|
||||||
|
END AS name
|
||||||
|
FROM pg_depend d
|
||||||
|
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
|
||||||
|
ORDER BY d.deptype, name".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
|
||||||
|
pub fn pg_schema_contents_query() -> String {
|
||||||
|
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Synthesized DDL ---
|
||||||
|
pub fn sequence_ddl(s: &SequenceInfo) -> String {
|
||||||
|
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
|
||||||
|
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
|
||||||
|
if s.cycle { "CYCLE" } else { "NO CYCLE" })
|
||||||
|
}
|
||||||
|
pub fn enum_ddl(e: &EnumInfo) -> String {
|
||||||
|
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
|
||||||
|
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
|
||||||
|
}
|
||||||
|
pub fn extension_ddl(x: &ExtensionInfo) -> String {
|
||||||
|
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
|
||||||
|
}
|
||||||
|
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||||
|
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||||
|
}
|
||||||
|
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||||
|
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||||
|
}
|
||||||
|
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
|
||||||
|
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
|
||||||
|
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)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_ident_doubles_embedded_quotes() {
|
||||||
|
assert_eq!(quote_ident("public"), "\"public\"");
|
||||||
|
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_object_name_rejects_dangerous_chars() {
|
||||||
|
assert!(validate_object_name("public").is_ok());
|
||||||
|
assert!(validate_object_name("").is_err());
|
||||||
|
assert!(validate_object_name("a; DROP").is_err());
|
||||||
|
assert!(validate_object_name("a--b").is_err());
|
||||||
|
assert!(validate_object_name("a'b").is_err());
|
||||||
|
assert!(validate_object_name("a\"b").is_err());
|
||||||
|
assert!(validate_object_name("a\\b").is_err());
|
||||||
|
assert!(validate_object_name("/*x*/").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_schema_sql_quotes_name() {
|
||||||
|
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
|
||||||
|
assert!(create_schema_sql("bad; name").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_schema_sql_quotes_both() {
|
||||||
|
assert_eq!(
|
||||||
|
rename_schema_sql("old", "new").unwrap(),
|
||||||
|
"ALTER SCHEMA \"old\" RENAME TO \"new\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_schema_sql_cascade_flag() {
|
||||||
|
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
|
||||||
|
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_search_query_unions_types_and_uses_position_match() {
|
||||||
|
let sql = pg_object_search_query();
|
||||||
|
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
|
||||||
|
assert!(sql.contains("$2"), "schema is $2");
|
||||||
|
// covers every browsable type
|
||||||
|
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
|
||||||
|
assert!(sql.contains(t), "search must cover {t}");
|
||||||
|
}
|
||||||
|
assert!(sql.contains("LIMIT 100"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_oid_query_branches_per_type() {
|
||||||
|
assert!(pg_object_oid_query("table").contains("pg_class"));
|
||||||
|
assert!(pg_object_oid_query("function").contains("pg_proc"));
|
||||||
|
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
|
||||||
|
assert!(pg_object_oid_query("enum").contains("pg_type"));
|
||||||
|
assert!(pg_object_oid_query("extension").contains("pg_extension"));
|
||||||
|
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depend_query_filters_internal_and_resolves_names() {
|
||||||
|
let sql = pg_depend_query();
|
||||||
|
assert!(sql.contains("refobjid = $1"));
|
||||||
|
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
|
||||||
|
assert!(sql.contains("pg_proc'::regclass"));
|
||||||
|
assert!(sql.contains("pg_trigger'::regclass"));
|
||||||
|
assert!(sql.contains("pg_constraint'::regclass"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depend_query_maps_rewrite_rules_to_the_view_class() {
|
||||||
|
// View dependencies surface in pg_depend as rewrite-rule rows
|
||||||
|
// (classid = pg_rewrite). The dependent object a user cares about is
|
||||||
|
// the VIEW itself, so the class must be reported as pg_class and the
|
||||||
|
// name resolved through ev_class (the view's relation).
|
||||||
|
let sql = pg_depend_query();
|
||||||
|
assert!(
|
||||||
|
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
|
||||||
|
"pg_rewrite rows must be reported as pg_class: {sql}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
|
||||||
|
"pg_rewrite name resolves through ev_class: {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_ddl_is_synthesized() {
|
||||||
|
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
|
||||||
|
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
|
||||||
|
increment: "1".into(), current_value: "5".into(), cycle: false };
|
||||||
|
let ddl = sequence_ddl(&s);
|
||||||
|
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
|
||||||
|
assert!(ddl.contains("INCREMENT BY 1"));
|
||||||
|
assert!(ddl.contains("NO CYCLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enum_ddl_lists_labels_quoted() {
|
||||||
|
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
|
||||||
|
let ddl = enum_ddl(&e);
|
||||||
|
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
|
||||||
|
assert!(ddl.contains("'admin'"));
|
||||||
|
assert!(ddl.contains("'user''s'"), "single quotes doubled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extension_ddl_is_synthesized() {
|
||||||
|
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
|
||||||
|
let ddl = extension_ddl(&x);
|
||||||
|
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
|
||||||
|
assert!(ddl.contains("WITH SCHEMA \"public\""));
|
||||||
|
assert!(ddl.contains("VERSION '1.3'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_ddl_wraps_selectdef() {
|
||||||
|
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
|
||||||
|
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constraint_ddl_wraps_definition() {
|
||||||
|
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
|
||||||
|
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
|
||||||
|
let ddl = constraint_ddl(&c);
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// of runtime usage, producing expected dead_code/unused warnings during development.
|
// of runtime usage, producing expected dead_code/unused warnings during development.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod cancel;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod models;
|
mod models;
|
||||||
@@ -17,11 +18,12 @@ pub struct AppState {
|
|||||||
pub db_store: StdMutex<Store>,
|
pub db_store: StdMutex<Store>,
|
||||||
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
||||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||||
|
pub cancel_registry: crate::cancel::CancelRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph,
|
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||||
settings, tags,
|
schema_graph, settings, tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
@@ -36,20 +38,33 @@ pub fn run() {
|
|||||||
// another provider is already installed).
|
// another provider is already installed).
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
let store = Store::open("gridline.db").expect("failed to open db");
|
|
||||||
let store_ref = StdMutex::new(store);
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_keyring_store::init())
|
.plugin(tauri_plugin_keyring_store::init())
|
||||||
.manage(AppState {
|
|
||||||
db_store: store_ref,
|
|
||||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
|
||||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
|
||||||
})
|
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
// Open the local store under the OS app-data directory. When the
|
||||||
|
// app is launched from Finder/LaunchServices the working directory
|
||||||
|
// is `/`, so a relative "gridline.db" path panics ("failed to
|
||||||
|
// open db", exit 101) before the UI ever starts. The demo DB uses
|
||||||
|
// the same directory (see commands/demo.rs).
|
||||||
|
let data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("failed to resolve app data dir: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&data_dir)
|
||||||
|
.map_err(|e| format!("failed to create app data dir: {e}"))?;
|
||||||
|
let store =
|
||||||
|
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||||
|
|
||||||
|
app.manage(AppState {
|
||||||
|
db_store: StdMutex::new(store),
|
||||||
|
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||||
|
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||||
|
cancel_registry: crate::cancel::CancelRegistry::default(),
|
||||||
|
});
|
||||||
|
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
demo::ensure_demo_db(app.handle(), &state.db_store)
|
demo::ensure_demo_db(app.handle(), &state.db_store)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -69,6 +84,9 @@ pub fn run() {
|
|||||||
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||||
mgr.close_tunnel(id);
|
mgr.close_tunnel(id);
|
||||||
}
|
}
|
||||||
|
// Drop the cancel handles for the evicted connection
|
||||||
|
// (tokens/interrupts outlive the pool otherwise).
|
||||||
|
s.cancel_registry.remove(id);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -105,6 +123,7 @@ pub fn run() {
|
|||||||
db_viewer::get_schemas,
|
db_viewer::get_schemas,
|
||||||
db_viewer::get_tables,
|
db_viewer::get_tables,
|
||||||
db_viewer::get_table_data,
|
db_viewer::get_table_data,
|
||||||
|
db_viewer::get_table_columns,
|
||||||
db_viewer::get_fk_preview,
|
db_viewer::get_fk_preview,
|
||||||
db_viewer::execute_change,
|
db_viewer::execute_change,
|
||||||
db_viewer::get_table_ddl,
|
db_viewer::get_table_ddl,
|
||||||
@@ -116,6 +135,17 @@ pub fn run() {
|
|||||||
db_viewer::get_extensions,
|
db_viewer::get_extensions,
|
||||||
db_viewer::get_indexes,
|
db_viewer::get_indexes,
|
||||||
db_viewer::get_constraints,
|
db_viewer::get_constraints,
|
||||||
|
objects::create_schema,
|
||||||
|
objects::rename_schema,
|
||||||
|
objects::drop_schema,
|
||||||
|
objects::search_objects,
|
||||||
|
objects::get_object_ddl,
|
||||||
|
objects::get_object_dependencies,
|
||||||
|
objects::build_object_ddl,
|
||||||
|
objects::build_rebuild_script,
|
||||||
|
objects::get_available_extensions,
|
||||||
|
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
|
||||||
|
maintenance::run_maintenance,
|
||||||
keychain::save_connection_password,
|
keychain::save_connection_password,
|
||||||
keychain::get_connection_password,
|
keychain::get_connection_password,
|
||||||
keychain::delete_connection_password,
|
keychain::delete_connection_password,
|
||||||
@@ -131,8 +161,18 @@ pub fn run() {
|
|||||||
backup::pg_dump,
|
backup::pg_dump,
|
||||||
backup::pg_restore,
|
backup::pg_restore,
|
||||||
backup::db_sync,
|
backup::db_sync,
|
||||||
|
backup::detect_mysql_tools,
|
||||||
|
backup::mysql_dump,
|
||||||
|
backup::mysql_restore,
|
||||||
|
backup::mysql_sync,
|
||||||
|
backup::sqlite_dump,
|
||||||
|
backup::sqlite_restore,
|
||||||
|
backup::sqlite_sync,
|
||||||
|
settings::export_settings,
|
||||||
|
settings::import_settings,
|
||||||
schema_graph::get_schema_graph,
|
schema_graph::get_schema_graph,
|
||||||
query::execute_query,
|
query::execute_query,
|
||||||
|
query::cancel_query,
|
||||||
query::get_query_history,
|
query::get_query_history,
|
||||||
query::clear_query_history,
|
query::clear_query_history,
|
||||||
query::set_history_favorite,
|
query::set_history_favorite,
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackupOptions {
|
||||||
|
pub format: String, // "plain" | "custom" | "tar" | "directory"
|
||||||
|
pub file_path: String,
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub tables: Option<Vec<String>>,
|
||||||
|
pub no_owner: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RestoreOptions {
|
||||||
|
pub format: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub clean: bool,
|
||||||
|
pub schema: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncOptions {
|
||||||
|
pub source_connection_id: String,
|
||||||
|
pub target_connection_id: String,
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub tables: Option<Vec<String>>,
|
||||||
|
#[serde(default = "default_sync_db_type")]
|
||||||
|
pub db_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_sync_db_type() -> String {
|
||||||
|
"postgresql".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PgToolStatus {
|
||||||
|
pub pg_dump_found: bool,
|
||||||
|
pub pg_restore_found: bool,
|
||||||
|
pub pg_dump_version: Option<String>,
|
||||||
|
pub pg_restore_version: Option<String>,
|
||||||
|
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
|
||||||
|
pub pg_restore_source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PgToolPaths {
|
||||||
|
pub pg_dump: String,
|
||||||
|
pub pg_restore: String,
|
||||||
|
pub psql: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection params for a MySQL server (decoupled from store/keychain so the
|
||||||
|
/// dump/restore/sync core stays headless-testable). Mirrors `PgConnParams`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MySqlConnParams {
|
||||||
|
pub host: String,
|
||||||
|
pub port: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub database: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MySqlConnParams {
|
||||||
|
pub fn new(host: String, port: i64, username: String, database: String, password: String) -> Self {
|
||||||
|
Self { host, port, username, database, password }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MySqlBackupOptions {
|
||||||
|
pub database: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub single_transaction: bool,
|
||||||
|
pub no_data: bool,
|
||||||
|
pub routines: bool,
|
||||||
|
pub triggers: bool,
|
||||||
|
pub events: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MySqlRestoreOptions {
|
||||||
|
pub database: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub clean: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SqliteBackupOptions {
|
||||||
|
pub file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SqliteRestoreOptions {
|
||||||
|
pub file_path: String,
|
||||||
|
pub clean: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MySqlToolStatus {
|
||||||
|
pub mysqldump_found: bool,
|
||||||
|
pub mysql_found: bool,
|
||||||
|
pub mysqldump_version: Option<String>,
|
||||||
|
pub mysql_version: Option<String>,
|
||||||
|
pub mysqldump_source: Option<String>,
|
||||||
|
pub mysql_source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MySqlToolPaths {
|
||||||
|
pub mysqldump: String,
|
||||||
|
pub mysql: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BackupJob {
|
||||||
|
pub id: String,
|
||||||
|
pub connection_id: String,
|
||||||
|
pub r#type: String, // "dump" | "restore" | "sync"
|
||||||
|
pub format: Option<String>,
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
pub source_connection_id: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub size_bytes: Option<i64>,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BackupProgressEvent {
|
||||||
|
pub job_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub progress: Option<f64>,
|
||||||
|
pub output_line: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_options_serialization() {
|
||||||
|
let opts = BackupOptions {
|
||||||
|
format: "plain".into(),
|
||||||
|
file_path: "/tmp/dump.sql".into(),
|
||||||
|
schema: Some("public".into()),
|
||||||
|
tables: None,
|
||||||
|
no_owner: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&opts).unwrap();
|
||||||
|
assert!(json.contains("plain"));
|
||||||
|
assert!(json.contains("noOwner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_job_serialization() {
|
||||||
|
let job = BackupJob {
|
||||||
|
id: "job-1".into(),
|
||||||
|
connection_id: "conn-1".into(),
|
||||||
|
r#type: "dump".into(),
|
||||||
|
format: Some("plain".into()),
|
||||||
|
file_path: Some("/tmp/dump.sql".into()),
|
||||||
|
source_connection_id: None,
|
||||||
|
status: "completed".into(),
|
||||||
|
error_message: None,
|
||||||
|
size_bytes: Some(1024),
|
||||||
|
started_at: "2025-07-28T10:00:00Z".into(),
|
||||||
|
completed_at: Some("2025-07-28T10:01:00Z".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&job).unwrap();
|
||||||
|
assert!(json.contains("dump"));
|
||||||
|
assert!(json.contains("completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_tool_status_reports_source() {
|
||||||
|
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
|
||||||
|
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
|
||||||
|
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"pg_dump_source\":\"system\""));
|
||||||
|
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_backup_options_serialize_camel_case() {
|
||||||
|
let opts = MySqlBackupOptions {
|
||||||
|
database: "shop".into(),
|
||||||
|
file_path: "/tmp/dump.sql".into(),
|
||||||
|
single_transaction: true,
|
||||||
|
no_data: false,
|
||||||
|
routines: true,
|
||||||
|
triggers: true,
|
||||||
|
events: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&opts).unwrap();
|
||||||
|
assert!(json.contains("\"singleTransaction\":true"));
|
||||||
|
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
|
||||||
|
assert!(!json.contains("no_owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_restore_options_serialize_camel_case() {
|
||||||
|
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
|
||||||
|
let json = serde_json::to_string(&opts).unwrap();
|
||||||
|
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
|
||||||
|
assert!(json.contains("\"clean\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_tool_status_reports_source() {
|
||||||
|
let s = MySqlToolStatus {
|
||||||
|
mysqldump_found: true,
|
||||||
|
mysql_found: true,
|
||||||
|
mysqldump_version: Some("mariadb-dump 10.6".into()),
|
||||||
|
mysql_version: Some("mariadb 10.6".into()),
|
||||||
|
mysqldump_source: Some("bundled".into()),
|
||||||
|
mysql_source: Some("system".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_options_carry_db_type() {
|
||||||
|
let s = SyncOptions {
|
||||||
|
source_connection_id: "a".into(),
|
||||||
|
target_connection_id: "b".into(),
|
||||||
|
schema: None,
|
||||||
|
tables: None,
|
||||||
|
db_type: "mysql".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"dbType\":\"mysql\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub struct Connection {
|
|||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
pub tag_ids: Vec<String>,
|
pub tag_ids: Vec<String>,
|
||||||
|
pub use_keychain: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
|
|||||||
pub ssl_ca_path: Option<String>,
|
pub ssl_ca_path: Option<String>,
|
||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_keychain: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -66,6 +73,7 @@ mod tests {
|
|||||||
username: Some("admin".to_string()),
|
username: Some("admin".to_string()),
|
||||||
folder_id: Some("folder1".to_string()),
|
folder_id: Some("folder1".to_string()),
|
||||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||||
|
use_keychain: true,
|
||||||
password: Some("secret123".to_string()),
|
password: Some("secret123".to_string()),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
ssh_host: Some("jumphost.example.com".to_string()),
|
ssh_host: Some("jumphost.example.com".to_string()),
|
||||||
@@ -133,6 +141,7 @@ mod tests {
|
|||||||
environment: None,
|
environment: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
@@ -177,6 +186,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".into(),
|
created_at: "2024-01-01T00:00:00Z".into(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".into(),
|
updated_at: "2024-01-01T00:00:00Z".into(),
|
||||||
@@ -184,4 +194,18 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&conn).unwrap();
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
assert!(json.contains("\"favorite\":true"));
|
assert!(json.contains("\"favorite\":true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_defaults_true_when_absent() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_preserves_false() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!input.use_keychain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,69 @@ pub struct ExtensionInfo {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ObjectSearchHit {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DependencyInfo {
|
||||||
|
pub deptype: String, // "n" (normal) | "a" (auto)
|
||||||
|
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
|
||||||
|
pub name: String, // resolved dependent object name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoleMembership {
|
||||||
|
pub role: String,
|
||||||
|
pub member: String,
|
||||||
|
pub grantor: String,
|
||||||
|
pub admin_option: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoleInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub superuser: bool,
|
||||||
|
pub inherit: bool,
|
||||||
|
pub create_db: bool,
|
||||||
|
pub create_role: bool,
|
||||||
|
pub can_login: bool,
|
||||||
|
pub replication: bool,
|
||||||
|
pub bypass_rls: bool,
|
||||||
|
pub connection_limit: i64,
|
||||||
|
pub valid_until: Option<String>,
|
||||||
|
pub memberships: Vec<RoleMembership>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PrivilegeEntry {
|
||||||
|
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub privileges: Vec<String>,
|
||||||
|
pub grantable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RebuildReadiness {
|
||||||
|
pub ok: bool,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MaintenanceResult {
|
||||||
|
pub duration_ms: i64,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TablespaceInfo {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum Change {
|
pub enum Change {
|
||||||
@@ -179,6 +242,14 @@ pub enum Change {
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
},
|
},
|
||||||
|
Ddl {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
|
RebuildTable {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Change {
|
impl Change {
|
||||||
@@ -190,7 +261,9 @@ impl Change {
|
|||||||
| Change::AlterTable { id, .. }
|
| Change::AlterTable { id, .. }
|
||||||
| Change::BulkInsert { id, .. }
|
| Change::BulkInsert { id, .. }
|
||||||
| Change::DropTable { id, .. }
|
| Change::DropTable { id, .. }
|
||||||
| Change::EmptyTable { id, .. } => id,
|
| Change::EmptyTable { id, .. }
|
||||||
|
| Change::Ddl { id, .. }
|
||||||
|
| Change::RebuildTable { id, .. } => id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,4 +656,78 @@ mod tests {
|
|||||||
assert!(json.contains("users"), "should contain referenced table");
|
assert!(json.contains("users"), "should contain referenced table");
|
||||||
assert!(json.contains("id"), "should contain referenced column");
|
assert!(json.contains("id"), "should contain referenced column");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_search_hit_tagged_roundtrip() {
|
||||||
|
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
|
||||||
|
let json = serde_json::to_string(&hit).unwrap();
|
||||||
|
assert!(json.contains("\"object_type\":\"TABLE\""));
|
||||||
|
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.name, "users");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dependency_info_roundtrip() {
|
||||||
|
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
|
||||||
|
let json = serde_json::to_string(&d).unwrap();
|
||||||
|
assert!(json.contains("\"deptype\":\"n\""));
|
||||||
|
assert!(json.contains("v_users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_rebuild_table_serializes_with_snake_case_tag() {
|
||||||
|
let change = Change::RebuildTable {
|
||||||
|
id: "chg-rb1".to_string(),
|
||||||
|
sql: "CREATE TABLE _t ...".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains(r#""type":"rebuild_table""#),
|
||||||
|
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
|
||||||
|
);
|
||||||
|
assert_eq!(change.id(), "chg-rb1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_rebuild_table_roundtrip() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
|
||||||
|
});
|
||||||
|
let c: Change = serde_json::from_value(json).unwrap();
|
||||||
|
match c {
|
||||||
|
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
|
||||||
|
_ => panic!("expected RebuildTable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_info_roundtrip() {
|
||||||
|
let r = RoleInfo {
|
||||||
|
name: "app".into(), superuser: false, inherit: true, create_db: false,
|
||||||
|
create_role: false, can_login: true, replication: false, bypass_rls: false,
|
||||||
|
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
|
||||||
|
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
assert!(json.contains(r#""name":"app""#));
|
||||||
|
assert!(json.contains(r#""can_login":true"#));
|
||||||
|
let back: RoleInfo = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.memberships.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn privilege_entry_and_readiness_roundtrip() {
|
||||||
|
let pe = PrivilegeEntry {
|
||||||
|
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
|
||||||
|
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
|
||||||
|
};
|
||||||
|
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
|
||||||
|
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
|
||||||
|
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
|
||||||
|
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
|
||||||
|
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
|
||||||
|
let ts = TablespaceInfo { name: "pg_default".into() };
|
||||||
|
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ pub mod tag;
|
|||||||
|
|
||||||
pub use connection::{Connection, ConnectionInput};
|
pub use connection::{Connection, ConnectionInput};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
|
||||||
pub use folder::{Folder, FolderInput};
|
pub use folder::{Folder, FolderInput};
|
||||||
pub use recent::RecentConnection;
|
pub use recent::RecentConnection;
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
@@ -20,3 +20,10 @@ pub struct Settings {
|
|||||||
pub editor_minimap: bool,
|
pub editor_minimap: bool,
|
||||||
pub editor_tab_size: i64,
|
pub editor_tab_size: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SettingsExport {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub settings: Settings,
|
||||||
|
}
|
||||||
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v8: use_keychain opt-out flag on connections
|
||||||
|
if current_ver < 8 {
|
||||||
|
let existing: Vec<String> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !existing.iter().any(|c| c == "use_keychain") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +316,49 @@ mod tests {
|
|||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 6);
|
assert_eq!(count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_adds_use_keychain_column_default_1() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
// v7 baseline
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// simulate an existing connection row (pre-v8 shape had no use_keychain)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let uses: i64 = conn
|
||||||
|
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_is_idempotent() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let v8: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v8, 1);
|
||||||
|
// PRAGMA confirms exactly one use_keychain column
|
||||||
|
let cols: Vec<String> = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| r.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -541,12 +607,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v7_bumps_schema_version_to_7() {
|
fn v8_bumps_schema_version_to_8() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
run_migrations(&conn).unwrap();
|
run_migrations(&conn).unwrap();
|
||||||
let ver: i64 = conn
|
let ver: i64 = conn
|
||||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ver, 7);
|
assert_eq!(ver, 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||