Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
092b3b2831 | ||
|
|
88e7e45818 | ||
|
|
ce4a73b0bb | ||
|
|
8b08ad0e46 | ||
|
|
b17bf95b6d | ||
|
|
9089f313f8 | ||
|
|
3454ce67fe | ||
|
|
f807c22a36 | ||
|
|
e4de489f53 | ||
|
|
fdbfd3d0d2 | ||
|
|
228660044a | ||
|
|
c4dc1c1c83 | ||
|
|
f1d9fdae3a | ||
|
|
18157f453f | ||
|
|
5607773299 | ||
|
|
60d472dde2 | ||
|
|
bdf2bb543b | ||
|
|
67f3532938 | ||
|
|
1d8dfce99a | ||
|
|
8bcdc0888f | ||
|
|
fd451d219d | ||
|
|
a86f5f7199 | ||
|
|
2fccb7b405 | ||
|
|
0efabbdc2e | ||
|
|
294e72e6ff | ||
|
|
15f4dc9bac | ||
|
|
867df98958 | ||
|
|
e66d7ac102 | ||
|
|
84a6f8d41c | ||
|
|
08b0f79f07 | ||
|
|
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 |
@@ -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,23 +7,65 @@ name: Release
|
|||||||
# git checkout prod && git pull
|
# git checkout prod && git pull
|
||||||
# git tag v0.5.0 && git push origin v0.5.0
|
# git tag v0.5.0 && git push origin v0.5.0
|
||||||
#
|
#
|
||||||
# SIGNING STATUS: builds are UNSIGNED for now (no code-signing certs yet —
|
# SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
|
||||||
# see README "Download a release"). tauri-action automatically signs +
|
# signed + notarized in CI when the Apple secrets below are configured (see
|
||||||
# notarizes when the signing secrets are present, so the moment we add
|
# README "macOS signing & notarization"). When the secrets are absent the mac
|
||||||
# APPLE_CERTIFICATE / APPLE_API_KEY / WINDOWS_CERTIFICATE (or Azure Trusted
|
# jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
|
||||||
# Signing) to repo secrets, future builds are signed — no changes to this
|
# remain unsigned.
|
||||||
# file required.
|
#
|
||||||
|
# Required secrets for signed + notarized macOS builds:
|
||||||
|
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
|
||||||
|
# APPLE_CERTIFICATE_PASSWORD password for that .p12
|
||||||
|
# APPLE_SIGNING_IDENTITY "Developer ID Application: Name (TEAMID)"
|
||||||
|
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
|
||||||
|
# APPLE_API_KEY App Store Connect Key ID
|
||||||
|
# APPLE_API_ISSUER App Store Connect Issuer ID
|
||||||
|
# APPLE_API_KEY_CONTENT contents of the AuthKey_<KeyID>.p8
|
||||||
|
#
|
||||||
|
# The cask bump runs only after the draft release is PUBLISHED (the GitHub
|
||||||
|
# releases/tags API does not serve drafts), so it is wired to the `release:
|
||||||
|
# published` event rather than the tag push. It pushes with a write-enabled
|
||||||
|
# deploy key scoped to ONLY the tap repo (HOMEBREW_TAP_DEPLOY_KEY), so no
|
||||||
|
# broad personal access token is stored — the default GITHUB_TOKEN cannot
|
||||||
|
# push to a different repository.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# Rebuild an EXISTING tag without moving it — e.g. to re-run a release after
|
||||||
|
# a workflow fix. Dispatch from `prod` and pass the tag to build; the tag
|
||||||
|
# itself is never rewritten, so the release ref stays truthful.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to build or bump the cask for, e.g. v0.8.0'
|
||||||
|
required: true
|
||||||
|
bump_cask:
|
||||||
|
description: 'Only bump the Homebrew cask for `tag` (skip the build)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
# workflow_dispatch passes the tag as an input; push/release events derive it
|
||||||
|
# from the ref. Drives the checkout ref and tauri-action's tagName.
|
||||||
|
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
|
# Build + draft release happen on a tag push or a dispatch that isn't a
|
||||||
|
# cask-only run. The `release: published` event exists only to trigger the
|
||||||
|
# cask bump below.
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask != true)
|
||||||
|
env:
|
||||||
|
# Empty when the secret is unset — the tool steps below test for a
|
||||||
|
# non-empty value before re-signing.
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -40,6 +82,69 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ env.RELEASE_TAG }}
|
||||||
|
|
||||||
|
# ---- Apple signing setup (macOS only, skipped when secrets absent) ---
|
||||||
|
- name: Detect Apple signing secrets
|
||||||
|
id: apple
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::APPLE_CERTIFICATE not set — macOS build will be ad-hoc signed and NOT notarized."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The bundled pg/mariadb tools are re-signed in their own steps below, so
|
||||||
|
# the identity must be in a keychain BEFORE those steps run. tauri-action
|
||||||
|
# is deliberately NOT given APPLE_CERTIFICATE (only the identity), so it
|
||||||
|
# does not create a second keychain.
|
||||||
|
- name: Import Apple Developer ID certificate
|
||||||
|
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
KEYCHAIN="gridline-signing.keychain-db"
|
||||||
|
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||||
|
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
|
||||||
|
EXISTING="$(security list-keychain -d user | tr -d ' \"')"
|
||||||
|
security list-keychain -d user -s "$KEYCHAIN" $EXISTING
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
security find-identity -v -p codesigning
|
||||||
|
|
||||||
|
- name: Prepare notarization credentials
|
||||||
|
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
KEY_PATH="$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
|
||||||
|
mkdir -p "$(dirname "$KEY_PATH")"
|
||||||
|
# Apple's PEM parser expects a terminating newline, which
|
||||||
|
# `gh secret set` strips from the stored value — so write one
|
||||||
|
# explicitly. A doubled trailing newline is harmless to PEM parsers,
|
||||||
|
# but a missing one can make notarization fail to load the key.
|
||||||
|
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$KEY_PATH"
|
||||||
|
{
|
||||||
|
echo "APPLE_API_KEY=${APPLE_API_KEY}"
|
||||||
|
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}"
|
||||||
|
echo "APPLE_API_KEY_PATH=${KEY_PATH}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
@@ -58,7 +163,7 @@ jobs:
|
|||||||
- name: Cache Rust build artifacts
|
- name: Cache Rust build artifacts
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: './src-tauri -> target'
|
workspaces: './desktop/src-tauri -> target'
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -68,7 +173,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PG_VER="16.4"
|
PG_VER="16.4"
|
||||||
OUT="${GITHUB_WORKSPACE}/src-tauri/resources/pg_tools"
|
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
ubuntu-22.04)
|
ubuntu-22.04)
|
||||||
@@ -132,6 +237,16 @@ jobs:
|
|||||||
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
|
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
|
||||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
|
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
|
||||||
done
|
done
|
||||||
|
# install_name_tool invalidates the linker signature. Re-sign with
|
||||||
|
# the Developer ID identity + hardened runtime + secure timestamp:
|
||||||
|
# Apple rejects ad-hoc-signed nested executables at notarization.
|
||||||
|
# No-op until the Apple secrets are configured.
|
||||||
|
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||||
|
for f in "$OUT"/pg_dump "$OUT"/pg_restore "$OUT"/psql "$OUT"/libpq.5.dylib; do
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--sign "$APPLE_SIGNING_IDENTITY" "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
windows-latest)
|
windows-latest)
|
||||||
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
||||||
@@ -167,16 +282,290 @@ jobs:
|
|||||||
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Build MariaDB client tools (bundled)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MARIADB_VER="11.4.5"
|
||||||
|
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
ubuntu-22.04)
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y cmake build-essential libssl-dev libzstd-dev pkg-config libgnutls28-dev patchelf
|
||||||
|
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||||
|
cd /tmp/mariadb-server
|
||||||
|
# Client-only build: wolfSSL + zlib are vendored. The in-tree
|
||||||
|
# Connector C still probes GnuTLS on Unix regardless of WITH_SSL,
|
||||||
|
# so libgnutls28-dev is required at build time.
|
||||||
|
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DWITHOUT_SERVER=ON \
|
||||||
|
-DWITHOUT_TOKUDB=1 \
|
||||||
|
-DWITHOUT_ROCKSDB=1 \
|
||||||
|
-DWITHOUT_MROONGA=1 \
|
||||||
|
-DWITHOUT_SPIDER=1 \
|
||||||
|
-DWITHOUT_SEQUENCE=1 \
|
||||||
|
-DWITH_UNIT_TESTS=OFF \
|
||||||
|
-DWITH_SSL=bundled \
|
||||||
|
-DWITH_ZLIB=bundled .
|
||||||
|
make -j"$(nproc)" mariadb-dump mariadb
|
||||||
|
# Clients build into client/ (stable across MariaDB 10.x/11.x);
|
||||||
|
# fall back to a broad search if a future version moves them.
|
||||||
|
for b in mariadb-dump mariadb; do
|
||||||
|
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||||
|
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||||
|
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||||
|
cp "$src" "$OUT"/
|
||||||
|
done
|
||||||
|
# Bundle every non-core shared dependency (GnuTLS + its chain,
|
||||||
|
# libzstd) next to the clients and point the loader at $ORIGIN —
|
||||||
|
# the same pattern the pg_tools step uses for libpq.
|
||||||
|
for b in mariadb-dump mariadb; do
|
||||||
|
ldd "$OUT/$b" | awk '/=> \// {print $3}' | sort -u | while read -r lib; do
|
||||||
|
case "$(basename "$lib")" in
|
||||||
|
ld-linux*|libc.so*|libm.so*|libpthread*|libdl.so*|librt.so*|libgcc_s*|libstdc++*|libcrypt.so*|libresolv*|libutil*)
|
||||||
|
;; # core runtime — present on every glibc system
|
||||||
|
*)
|
||||||
|
cp -n "$lib" "$OUT/$(basename "$lib")" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
macos-latest|macos-15-intel)
|
||||||
|
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||||
|
cd /tmp/mariadb-server
|
||||||
|
# macOS connects the in-tree Connector C against brew OpenSSL
|
||||||
|
# (proven to build+run on the runners); the Linux-only GnuTLS
|
||||||
|
# probe does not fire here. Absolute brew paths are rewritten to
|
||||||
|
# @loader_path below so user machines resolve them from the
|
||||||
|
# resource dir.
|
||||||
|
if [ "$(uname -m)" = arm64 ]; then
|
||||||
|
SSL_DIR="/opt/homebrew/opt/openssl"
|
||||||
|
else
|
||||||
|
SSL_DIR="/usr/local/opt/openssl"
|
||||||
|
fi
|
||||||
|
[ -d "$SSL_DIR" ] || brew install openssl
|
||||||
|
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DWITHOUT_SERVER=ON \
|
||||||
|
-DWITHOUT_TOKUDB=1 \
|
||||||
|
-DWITHOUT_ROCKSDB=1 \
|
||||||
|
-DWITHOUT_MROONGA=1 \
|
||||||
|
-DWITHOUT_SPIDER=1 \
|
||||||
|
-DWITHOUT_SEQUENCE=1 \
|
||||||
|
-DWITH_UNIT_TESTS=OFF \
|
||||||
|
-DWITH_SSL="$SSL_DIR" \
|
||||||
|
-DWITH_ZLIB=bundled .
|
||||||
|
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
|
||||||
|
for b in mariadb-dump mariadb; do
|
||||||
|
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||||
|
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||||
|
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||||
|
cp "$src" "$OUT"/
|
||||||
|
done
|
||||||
|
# Rewrite absolute Homebrew dylib deps to @loader_path so the
|
||||||
|
# bundled clients (and OpenSSL + their own deps) resolve from the
|
||||||
|
# resource dir on user machines. Fixed-point loop: each pass
|
||||||
|
# copies newly-seen dylibs and rewrites their parents' refs.
|
||||||
|
# Ad-hoc re-sign after each edit — arm64 dyld refuses to load a
|
||||||
|
# dylib whose (Homebrew) code signature was invalidated.
|
||||||
|
for pass in 1 2 3 4 5 6; do
|
||||||
|
for dylib in "$OUT"/*.dylib "$OUT"/mariadb-dump "$OUT"/mariadb; do
|
||||||
|
[ -f "$dylib" ] || continue
|
||||||
|
# `|| true` — with pipefail, grep exits 1 on zero matches.
|
||||||
|
otool -L "$dylib" | awk 'NR>1 {print $1}' | grep -E '^/(usr/local|opt/homebrew)' | while read -r lib; do
|
||||||
|
base=$(basename "$lib")
|
||||||
|
if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi
|
||||||
|
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||||
|
done || true
|
||||||
|
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
|
||||||
|
else
|
||||||
|
codesign --force --sign - "$dylib" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
windows-latest)
|
||||||
|
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
|
||||||
|
curl -fsSL "$URL" -o /tmp/mariadb.zip
|
||||||
|
# TODO(RELEASE): pin the sha256 printed on the first CI run, then
|
||||||
|
# uncomment the check so later releases verify the download.
|
||||||
|
# EXPECTED="<sha256 of mariadb-${MARIADB_VER}-winx64.zip>"
|
||||||
|
# echo "$EXPECTED /tmp/mariadb.zip" | sha256sum -c -
|
||||||
|
sha256sum /tmp/mariadb.zip
|
||||||
|
unzip -o /tmp/mariadb.zip -d /tmp/mariadb
|
||||||
|
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
|
||||||
|
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
|
||||||
|
# The clients' only runtime dependency is the client library
|
||||||
|
# (lib/libmariadb.dll); the winx64 package statically links
|
||||||
|
# wolfSSL, so there are no OpenSSL DLLs to bundle. bin/*.dll is
|
||||||
|
# just the embedded server.dll, which the client tools don't need.
|
||||||
|
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
(cd "$OUT" && sha256sum * | tee checksums.txt)
|
||||||
|
# Resolve the per-platform binary suffix WITHOUT a command
|
||||||
|
# substitution (see the pg_tools step for why).
|
||||||
|
if [ "${{ matrix.platform }}" = windows-latest ]; then
|
||||||
|
BIN_EXT=".exe"
|
||||||
|
else
|
||||||
|
BIN_EXT=""
|
||||||
|
fi
|
||||||
|
for b in mariadb-dump mariadb; do
|
||||||
|
f="$OUT/${b}${BIN_EXT}"
|
||||||
|
test -f "$f" || { echo "missing $f"; exit 1; }
|
||||||
|
done
|
||||||
|
# Sanity: every tool must run (loader path is correct) — this catches
|
||||||
|
# a missing shared library before we ship a broken bundle.
|
||||||
|
for b in mariadb-dump mariadb; do
|
||||||
|
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
- name: Build and upload to GitHub Release
|
- name: Build and upload to GitHub Release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tagName: ${{ github.ref_name }}
|
projectPath: desktop
|
||||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
tagName: ${{ env.RELEASE_TAG }}
|
||||||
|
releaseName: 'Gridline ${{ env.RELEASE_TAG }}'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
# Assets use tauri-action's default versioned naming (e.g.
|
# Assets use tauri-action's default versioned naming (e.g.
|
||||||
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
|
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
|
||||||
# README download tables link to them statically per release —
|
# README download tables link to them statically per release —
|
||||||
# remember to update both README tables when cutting a new version.
|
# remember to update both README tables when cutting a new version.
|
||||||
|
|
||||||
|
# tauri-action notarizes + staples the .app but NOT the .dmg wrapping it,
|
||||||
|
# so Gatekeeper rejects the downloaded DMG as "Unnotarized Developer ID"
|
||||||
|
# even though the app inside is fine. Submit the DMG to the same notary
|
||||||
|
# service, staple the ticket, and replace the uploaded asset.
|
||||||
|
#
|
||||||
|
# Assets are replaced via the release ID rather than `gh release upload`:
|
||||||
|
# the GitHub releases/tags endpoint returns 404 for drafts, so uploading
|
||||||
|
# by tag cannot work while the release is still a draft.
|
||||||
|
- name: Notarize and staple the DMGs
|
||||||
|
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
dmgs=(desktop/src-tauri/target/*/release/bundle/dmg/*.dmg)
|
||||||
|
if [ ${#dmgs[@]} -eq 0 ]; then
|
||||||
|
echo "no DMG produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REL_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||||
|
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" | head -1)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
echo "could not resolve a release id for ${RELEASE_TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for dmg in "${dmgs[@]}"; do
|
||||||
|
name=$(basename "$dmg")
|
||||||
|
echo ">> notarizing $name (this can take a few minutes)"
|
||||||
|
xcrun notarytool submit "$dmg" \
|
||||||
|
--key "$APPLE_API_KEY_PATH" \
|
||||||
|
--key-id "$APPLE_API_KEY" \
|
||||||
|
--issuer "$APPLE_API_ISSUER" \
|
||||||
|
--wait
|
||||||
|
xcrun stapler staple "$dmg"
|
||||||
|
xcrun stapler validate "$dmg"
|
||||||
|
# Replace the copy tauri-action uploaded, which carries no DMG ticket.
|
||||||
|
old=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \
|
||||||
|
--jq ".[] | select(.name == \"${name}\") | .id" | head -1)
|
||||||
|
if [ -n "$old" ]; then
|
||||||
|
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${old}"
|
||||||
|
fi
|
||||||
|
echo ">> re-uploading stapled $name"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${GH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${dmg}" \
|
||||||
|
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${name}"
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
echo "all DMGs notarized and stapled"
|
||||||
|
|
||||||
|
# After the installers are uploaded, refresh the Homebrew cask in the
|
||||||
|
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
|
||||||
|
# render Casks/gridline.rb from the template, and push it. Users then get
|
||||||
|
# the new version via `brew upgrade --cask gridline`.
|
||||||
|
#
|
||||||
|
# Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
|
||||||
|
# key on the tap repo (the default GITHUB_TOKEN cannot push to a different
|
||||||
|
# repository, and a deploy key avoids storing a broad PAT). The job skips
|
||||||
|
# cleanly until that secret is set, so releases stay green before this is
|
||||||
|
# configured.
|
||||||
|
bump-cask:
|
||||||
|
# Normally fires on `release: published`, which requires the DMGs to be
|
||||||
|
# downloadable — the GitHub releases/tags API returns 404 for drafts, so
|
||||||
|
# this cannot run on the tag push. NOTE: a release whose DRAFT was created
|
||||||
|
# by tauri-action's GITHUB_TOKEN does not reliably emit a `release` event,
|
||||||
|
# so `workflow_dispatch` with bump_cask=true is the dependable path.
|
||||||
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask == true)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check for tap deploy key
|
||||||
|
id: tap
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::HOMEBREW_TAP_DEPLOY_KEY not set - skipping Homebrew cask bump."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
if: steps.tap.outputs.enabled == 'true'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version and DMG checksums
|
||||||
|
id: meta
|
||||||
|
if: steps.tap.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
|
||||||
|
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Checkout tap repo
|
||||||
|
if: steps.tap.outputs.enabled == 'true'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: AdrianBonpin/homebrew-gridline
|
||||||
|
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||||
|
path: tap
|
||||||
|
|
||||||
|
- name: Render and push cask
|
||||||
|
if: steps.tap.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
|
||||||
|
SHA_INTEL: ${{ steps.meta.outputs.sha_intel }}
|
||||||
|
run: |
|
||||||
|
mkdir -p tap/Casks
|
||||||
|
./scripts/render-cask.sh "$VERSION" "$SHA_ARM" "$SHA_INTEL" > tap/Casks/gridline.rb
|
||||||
|
cat tap/Casks/gridline.rb
|
||||||
|
cd tap
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add Casks/gridline.rb
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "cask already up to date"
|
||||||
|
else
|
||||||
|
git commit -m "gridline $VERSION"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
@@ -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, release-mac.sh) + Gitea mirror (mirror-gitea.sh, hooks/, install-git-hooks.sh)
|
||||||
└── AGENTS.md # This file
|
└── AGENTS.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -141,35 +154,88 @@ 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)
|
||||||
|
bun run icons # Regenerate desktop/src-tauri/icons/* from icon.svg
|
||||||
|
bun run icons:verify # Check the macOS icon isn't rendered on a legacy plate
|
||||||
|
cargo build # Rust backend only (from desktop/src-tauri/)
|
||||||
|
cargo test # Rust tests (from desktop/src-tauri/)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### App icon
|
||||||
|
|
||||||
|
`desktop/src-tauri/icons/icon.svg` is the source of truth; every PNG/ICNS/ICO beside it is generated output.
|
||||||
|
|
||||||
|
Regenerate **only** through the script — never with a bare `tauri icon`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run icons # tauri icon -> temp dir, copy back the desktop set, then fix the ICNS
|
||||||
|
bun run icons:verify # asserts AppKit renders the icon cleanly at 16px..512px
|
||||||
|
```
|
||||||
|
|
||||||
|
Two reasons the wrapper is mandatory:
|
||||||
|
|
||||||
|
1. `tauri icon` takes one output directory for all platforms, so running it in place would drop generated `android/` and `ios/` trees into the desktop-only icons directory. The script generates into a temp dir and copies back just the desktop files.
|
||||||
|
2. `tauri icon` emits legacy `is32`/`s8mk`/`il32`/`l8mk` (+ `ic12`) representations. **macOS 26+ then draws a grey "legacy plate" behind the icon for every render size below ~72px** — a white border around the app icon in the Dock and in any launcher that resolves icons via `NSWorkspace.icon(forFile:)` (Spotlight, Vicinae, Raycast). `scripts/fix-icns.mjs` strips those chunks, which makes the icon render cleanly at every size.
|
||||||
|
|
||||||
|
`scripts/verify-app-icon.mjs` is the regression check for that: it builds a throwaway `.app` around the committed `icon.icns`, asks AppKit for the icon (the same call launchers make) and fails if any size comes back plated. Run it after touching icons, and after bumping Tauri (a newer `tauri icon` may change what it emits).
|
||||||
|
|
||||||
|
### Branch & PR workflow
|
||||||
|
|
||||||
|
**GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first.
|
||||||
|
|
||||||
|
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
|
||||||
|
|
||||||
|
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
|
||||||
|
2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
|
||||||
|
3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base prod`) and merge it there
|
||||||
|
|
||||||
|
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
|
||||||
|
|
||||||
|
**The Gitea fallback keeps itself current — run `scripts/install-git-hooks.sh` once per clone.** It sets `core.hooksPath=scripts/hooks`, so:
|
||||||
|
|
||||||
|
- `pre-push` mirrors `prod` + all tags to `origin` (Gitea) whenever you push to the `github` remote,
|
||||||
|
- `post-merge` mirrors again after a `git pull`/merge while on `prod` (a PR squash-merged on GitHub only reaches the mirror when the local `prod` pulls it).
|
||||||
|
|
||||||
|
`scripts/mirror-gitea.sh` does the same by hand (`--branches` also pushes feature branches); it refuses to push to a GitHub remote and exits non-zero if the mirror could not be updated. The hooks downgrade that to a warning — **a Gitea outage never blocks a push to GitHub**.
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
|
|
||||||
Cut a release from the **`prod`** branch (never feature branches) by tagging it — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub).
|
**Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). The Gitea tag arrives automatically via the `pre-push` hook (or `scripts/mirror-gitea.sh`) — never `git push origin vN.M.N` expecting a build, and never publish a release on Gitea.
|
||||||
|
|
||||||
**Before tagging**, keep everything in sync:
|
**Before tagging**, keep everything in sync:
|
||||||
- Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`
|
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml` (+ the `gridline` entry in `desktop/src-tauri/Cargo.lock` — run any `cargo metadata`/`cargo build` to sync it; do NOT blanket-replace `0.8.x` in the lock, the sqlx family shares that version), `desktop/src-tauri/tauri.conf.json`
|
||||||
- `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version
|
- Landing page version strings: `www/src/pages/index.astro` and `www/public/landing.js` (both carry a `const version`), plus the new entry at the top of `www/src/data/changelog.ts`
|
||||||
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.7/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
- Tests that assert the version: `desktop/src/lib/version.test.ts`, `desktop/src/lib/bundle-config.test.ts`, `desktop/src/lib/docs-coverage.test.ts` (this one also asserts the README's asset names and the landing `const version`)
|
||||||
|
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.8.2/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables, the two "current release (**vX.Y.Z**)" banners, and the MAINTENANCE comment on every release.
|
||||||
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
||||||
|
- **macOS is Developer-ID signed + notarized in CI** from the repository secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_CONTENT`, `KEYCHAIN_PASSWORD`) — the workflow logs a `notice` when they are absent and falls back to ad-hoc, so **check the run output before publishing a draft**. Verify a published DMG with `xcrun stapler validate <dmg>` plus `spctl --assess --type execute` on the `.app` inside (`source=Notarized Developer ID`). `scripts/release-mac.sh` is the **local fallback**: it auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env`, signs + notarizes + staples both architectures, verifies the result, then uploads to the **GitHub** release with `gh release upload --clobber`.
|
||||||
|
- **After publishing the draft**, the `release: published` event runs the cask bump (`HOMEBREW_TAP_DEPLOY_KEY` pushes `Casks/gridline.rb` to `AdrianBonpin/homebrew-gridline`). Verify the cask `version` + `sha256` match the published DMGs.
|
||||||
|
|
||||||
### Adding a Tauri Command
|
### Adding a Tauri Command
|
||||||
|
|
||||||
1. Define the command function in the appropriate `src-tauri/src/commands/` module
|
1. Define the command function in the appropriate `desktop/src-tauri/src/commands/` module
|
||||||
2. Register it in `src-tauri/src/lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
2. Register it in `desktop/src-tauri/src/lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
||||||
3. Create a typed wrapper function in `src/lib/commands.ts`
|
3. Create a typed wrapper function in `desktop/src/lib/commands.ts`
|
||||||
4. Call the wrapper from your React component/store
|
4. Call the wrapper from your React component/store
|
||||||
|
|
||||||
### Adding a New Dependency
|
### Adding a New Dependency
|
||||||
|
|
||||||
- **Frontend:** `bun add <package>` (runtime) or `bun add -d <package>` (dev)
|
- **Frontend:** `bun add <package>` (runtime) or `bun add -d <package>` (dev)
|
||||||
- **Rust:** Add to `src-tauri/Cargo.toml` under `[dependencies]`
|
- **Rust:** Add to `desktop/src-tauri/Cargo.toml` under `[dependencies]`
|
||||||
|
|
||||||
|
**Website (`www/`) deps — keep the standalone lockfile in sync:** `www/` is a bun workspace member, so `bun add` updates the **root** `bun.lock`, but Railpack (Dokploy, build path `/www`) uses the **standalone `www/bun.lock`**. After adding/removing a dep in `www/`, regenerate `www/bun.lock` so the deployed build stays deterministic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TMP=$(mktemp -d) && cp www/package.json "$TMP/package.json" && cd "$TMP" && bun install --lockfile-only && cp bun.lock ../../www/bun.lock && cd - && rm -rf "$TMP"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust the `../../` relative path to point back at `www/` from the temp dir.) Commit both `bun.lock` and `www/bun.lock` together.
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
|
|
||||||
@@ -202,19 +268,20 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
### Connection Management
|
### Connection Management
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
| :--- | :---: | :--- |
|
| :--- | :---: | :--- |
|
||||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation |
|
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
|
||||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
|
| MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
|
||||||
|
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0)
|
||||||
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
||||||
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly. **All MySQL column types decode by their declared type** (`mysql_cell_kind` → `mysql_cell_to_json` in `commands/query.rs`, v0.8.2) — `int`/`bigint` (64-bit stringified so the value survives IPC), `decimal` (exact digits, never a float), `date`/`time`/`datetime`/`timestamp` (naive, never an instant — MySQL converts TIMESTAMP via the session time zone), `float`/`double`, `bit`, `year`, `json`, text and binary — where the previous blind `try_get` try-chain rendered every non-string type as NULL (issue #41: 21 of 30 types) |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
|
||||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) |
|
||||||
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
|
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins |
|
||||||
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
||||||
|
|
||||||
### Home Screen & Organization
|
### Home Screen & Organization
|
||||||
@@ -237,7 +304,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 |
|
||||||
@@ -255,20 +322,21 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||||
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
|
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8); plus Rust-side **Export all rows to file…** (full-result CSV/JSONL/JSON streaming with progress + cancel) (v0.8.0) |
|
||||||
|
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
|
||||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||||
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||||
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
||||||
@@ -286,6 +354,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section |
|
| 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 |
|
| 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) |
|
| 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 |
|
||||||
@@ -309,6 +378,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
||||||
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
|
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
|
||||||
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
|
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
|
||||||
|
| TimescaleDB hypertables | ✅ | Objects view type on PG connections with the extension; runtime availability gating (v0.8.0) |
|
||||||
|
|
||||||
### Query Editor
|
### Query Editor
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -318,7 +388,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
|
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
|
||||||
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
||||||
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
||||||
| Multiple result sets | ❌ | |
|
| Multiple result sets | ✅ | Stacked panels per statement via execute_query_multi; DML/DDL notices; stop-at-first-error (v0.8.0) |
|
||||||
|
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
|
||||||
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
||||||
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
||||||
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
|
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
|
||||||
@@ -333,8 +404,12 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
||||||
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
||||||
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
|
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
|
||||||
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
|
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5). `resources/mysql_tools` (mariadb-dump / mariadb) is **still bundled and still required** — for MySQL **backup** and **DB Sync** only; MySQL **restore** is native since v0.8.2 so no MySQL client is needed for it |
|
||||||
| SQLite .dump | ❌ | |
|
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
|
||||||
|
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL **backup** via bundled/system `mariadb-dump` (`mysqldump`-compatible, system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8). **MySQL restore runs in-process over the sqlx driver** (v0.8.2) — `run_mysql_restore_on` splits the script (`DELIMITER`-aware) and executes it on one connection, **no `mariadb` client and no subprocess**, so a MySQL 8 user authenticating with `caching_sha2_password` (the case the bundled client cannot reach) restores. Stop-at-first-error, cancellable, 100 MB / 1M-statement caps; MySQL **backup and DB Sync still shell out to the bundled MariaDB client** |
|
||||||
|
| MySQL `clean` restore semantics | ✅ | `Clean` drops **only the objects the dump recreates** (`CREATE TABLE`/`VIEW`/`PROCEDURE`/`FUNCTION`/`TRIGGER`/`EVENT`), matching `pg_restore --clean` — an unrelated table in the target survives (`db/mysql_clean.rs`, v0.8.2). Deliberately not `DROP DATABASE`. The drops run with `FOREIGN_KEY_CHECKS = 0` (restored right after) so ordering cannot fail the restore. **MySQL DDL auto-commits and cannot be rolled back**, so a failure stops at the failing statement (reported with its position) and the Restore form warns that a failed restore "may leave this database partially applied" |
|
||||||
|
| `.sql` file association (open with Gridline) | ✅ | `bundle.fileAssociations` in `tauri.conf.json` registers `.sql` as **SQL Script** (`role: Editor`, **`rank: Alternate`** — Gridline never hijacks the default handler, it only appears under *Open With*, v0.8.2). The OS path is validated (absolute, `.sql` extension, real file, ≤5 MB — `MAX_SQL_FILE_BYTES`, symlinks not followed) and queued in Rust, then drained by the **argument-free** `take_pending_sql_files` command, so the webview can never name a file to read. The text lands in a query tab named after the file and is **never executed** — Run still goes through the destructive-query guard. Re-opening the same file focuses the existing tab without discarding edits. With no connection open the file is stashed and Home shows a banner prompting for one (macOS `RunEvent::Opened`; Windows/Linux argv + single-instance forward) |
|
||||||
|
| Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) |
|
||||||
| Table structure export (DDL) | ❌ | |
|
| Table structure export (DDL) | ❌ | |
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -342,6 +417,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 |
|
||||||
@@ -354,7 +430,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 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -18,33 +18,46 @@
|
|||||||
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
|
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
|
||||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
|
||||||
<a href="https://github.com/adrianbonpin/gridline/stargazers"><img src="https://img.shields.io/github/stars/adrianbonpin/gridline?style=for-the-badge&logo=github&label=Stars" alt="GitHub stars" /></a>
|
<a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge&logo=github&logoColor=white" alt="Download Latest" /></a>
|
<a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
|
||||||
<a href="https://github.com/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge&logo=github&logoColor=white" alt="Open an Issue" /></a>
|
<a href="https://github.com/AdrianBonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.7**):
|
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.2**):
|
||||||
|
|
||||||
| OS | Architecture | Download |
|
| OS | Architecture | Download |
|
||||||
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) |
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.2_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_aarch64.dmg) |
|
||||||
| **macOS** | Intel | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) |
|
| **macOS** | Intel | [Gridline_0.8.2_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64.dmg) |
|
||||||
| **Windows** | x64 | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) |
|
| **Windows** | x64 | [Gridline_0.8.2_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64-setup.exe) |
|
||||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) |
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.2_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.deb) |
|
||||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) |
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.2-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline-0.8.2-1.x86_64.rpm) |
|
||||||
| **Other Linux** | amd64 | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) |
|
| **Other Linux** | amd64 | [Gridline_0.8.2_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.AppImage) |
|
||||||
|
|
||||||
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
|
**macOS via Homebrew** (recommended for Mac users):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time setup (the tap is hosted on GitHub):
|
||||||
|
brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
|
||||||
|
brew trust adrianbonpin/gridline
|
||||||
|
|
||||||
|
# install / upgrade:
|
||||||
|
brew install --cask gridline
|
||||||
|
```
|
||||||
|
|
||||||
|
The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
|
||||||
|
|
||||||
|
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.7
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.2
|
||||||
release assets, not at a moving "latest" target. On every new release,
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
update BOTH tables here (Download + Which file should I download?) to the
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
new version's asset names, which are tauri-action's default naming:
|
new version's asset names, which are tauri-action's default naming:
|
||||||
@@ -61,6 +74,10 @@ Gridline is a modern, open-source database GUI client built with [Tauri 2.0](htt
|
|||||||
|
|
||||||
- **No caps** on connections, tabs, or saved queries.
|
- **No caps** on connections, tabs, or saved queries.
|
||||||
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
|
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
|
||||||
|
- **Backup, restore & sync for every database** — PostgreSQL, MySQL (`mysqldump` / bundled MariaDB clients), and SQLite (`.dump`), with direct DB-to-DB sync between live connections.
|
||||||
|
- **Excel export** — CSV, JSON, SQL, Markdown, and `.xlsx` from any data grid.
|
||||||
|
- **Cancel long-running queries** — a per-connection cancel button instead of waiting it out or killing the app.
|
||||||
|
- **SQLite table editor** — the visual Create Table / Edit Table flow now works on SQLite too (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`).
|
||||||
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
|
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
|
||||||
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
|
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
|
||||||
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
|
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
|
||||||
@@ -85,6 +102,24 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
<details>
|
<details>
|
||||||
<summary>View the full changelog</summary>
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **v0.8.2** — **MySQL values actually show up**: every non-string MySQL column — ids and other integers, `DECIMAL`, `BIT`, `YEAR`, `DATE`/`TIME`/`DATETIME`/`TIMESTAMP`, `FLOAT`/`DOUBLE` — was rendering as `NULL`, so primary-key columns looked empty; all 30 column types now decode by their declared type, with 64-bit integers and `DECIMAL` carried as strings so no digits are lost and `DATETIME(6)` keeping its microseconds. **MySQL restore works against MySQL 8** — it no longer shells out to a bundled MariaDB client (which cannot authenticate with `caching_sha2_password`), running in-process over the same driver browsing uses, stopping at the first error with the failing statement's position, cancellable, and `DELIMITER`-aware so dumps with triggers and stored routines apply. **The `Clean` toggle is now real**: it drops only the objects the dump recreates, so an unrelated table in the target survives — and since MySQL DDL auto-commits, the Restore form warns a failed restore cannot be rolled back. **`.sql` files open with Gridline**: right-click → Open With loads the script into a query tab named after the file; it never runs on open (Run still confirms anything destructive), and with no connection open the file is held with a prompt until you pick one.
|
||||||
|
|
||||||
|
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
|
||||||
|
|
||||||
|
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||||
|
|
||||||
|
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||||
|
|
||||||
|
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
|
||||||
|
|
||||||
|
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
||||||
|
|
||||||
|
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
||||||
|
|
||||||
|
- **v0.7.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.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
|
||||||
|
|
||||||
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
|
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
|
||||||
@@ -178,12 +213,15 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
<details>
|
<details>
|
||||||
<summary>Show features</summary>
|
<summary>Show features</summary>
|
||||||
|
|
||||||
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress.
|
- **Visual Backup** — `pg_dump` (format selector, schema filter, no-owner), `mysqldump`, and SQLite `.dump` wrappers with real-time progress.
|
||||||
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
|
- **Visual Restore** — `pg_restore` / `mysql` / SQLite restore with clean toggle and destructive confirmation.
|
||||||
- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections.
|
- **DB-to-DB Sync** — pipe dump → restore between two live connections (PostgreSQL, MySQL, and SQLite).
|
||||||
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback.
|
- **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.
|
- **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.
|
- **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>
|
</details>
|
||||||
|
|
||||||
@@ -278,32 +316,48 @@ _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overc
|
|||||||
|
|
||||||
### Download a release
|
### Download a release
|
||||||
|
|
||||||
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/adrianbonpin/gridline/releases) page.
|
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
|
||||||
|
|
||||||
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/adrianbonpin/gridline/issues/new) when you hit them.
|
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
|
||||||
|
|
||||||
#### Installers are unsigned (for now)
|
#### macOS signing & notarization
|
||||||
|
|
||||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet. Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
|
||||||
|
|
||||||
- **macOS:** right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
**One-time setup** (only the repo owner needs to do this):
|
||||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
|
||||||
- **Linux:** no warning — install and run normally.
|
|
||||||
|
|
||||||
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed.
|
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
|
||||||
|
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
|
||||||
|
3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| :--------------------------- | :------------------------------------------------------------------------------------------------- |
|
||||||
|
| `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
|
||||||
|
| `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
|
||||||
|
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
|
||||||
|
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
|
||||||
|
| `APPLE_API_KEY` | App Store Connect **Key ID** |
|
||||||
|
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
|
||||||
|
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
|
||||||
|
|
||||||
|
When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
|
||||||
|
|
||||||
|
`scripts/release-mac.sh` is the **local fallback** path (handy for a signed build without burning CI minutes, or when Actions is unavailable). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, verifies the result, then uploads the DMGs to the **GitHub** release for the tag (`gh release upload --clobber`, so a locally signed pair supersedes the CI-built ones — publish the draft afterwards). If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||||
|
|
||||||
|
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
|
||||||
|
|
||||||
#### Which file should I download?
|
#### Which file should I download?
|
||||||
|
|
||||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.7**):
|
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.2**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.2_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.8.2_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.8.2_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.7_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.8.2_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.2_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.7-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.2-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline-0.8.2-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.2-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.8.2_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_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.
|
||||||
|
|
||||||
@@ -313,25 +367,28 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout prod && git pull
|
git checkout prod && git pull
|
||||||
git tag v0.7.7
|
git tag v0.7.15
|
||||||
git push origin v0.7.7
|
git push origin v0.7.15
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
|
GitHub Actions (`.github/workflows/release.yml`) builds the **macOS (Apple Silicon + Intel), Windows, and Linux** installers in parallel and opens a **draft release** on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page. Review the draft and hit **Publish release** — publishing triggers a follow-up job that bumps the Homebrew cask in [`AdrianBonpin/homebrew-gridline`](https://github.com/AdrianBonpin/homebrew-gridline) (pushed via a write-enabled deploy key on the tap, `HOMEBREW_TAP_DEPLOY_KEY`; skipped when unset).
|
||||||
|
|
||||||
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
|
The self-hosted Gitea mirror (`.gitea/workflows/release.yml`) still builds Linux and Windows for the Gitea release, and `scripts/release-mac.sh` can build macOS locally — both are kept as a fallback if GitHub is unavailable.
|
||||||
|
|
||||||
|
Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/adrianbonpin/gridline.git
|
git clone https://github.com/AdrianBonpin/gridline.git
|
||||||
cd gridline
|
cd gridline
|
||||||
|
|
||||||
# 2. Install frontend dependencies
|
# 2. Install all workspace dependencies
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# 3. Run in development mode with hot-reload
|
# 3. Run in development mode with hot-reload (from desktop/)
|
||||||
|
cd desktop
|
||||||
bun run tauri dev
|
bun run tauri dev
|
||||||
|
|
||||||
# 4. Build for production
|
# 4. Build for production
|
||||||
@@ -351,17 +408,21 @@ bun run tauri build
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend only (Vite dev server)
|
# Install all workspace dependencies (from the repo root)
|
||||||
bun run dev
|
bun install
|
||||||
|
|
||||||
# Full Tauri app with hot-reload
|
# Full Tauri app with hot-reload (from desktop/)
|
||||||
|
cd desktop
|
||||||
bun run tauri dev
|
bun run tauri dev
|
||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
bun run tauri build
|
bun run tauri build
|
||||||
|
|
||||||
|
# Frontend only (Vite dev server)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
# Rust backend only
|
# Rust backend only
|
||||||
cd src-tauri
|
cd desktop/src-tauri
|
||||||
cargo build
|
cargo build
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
@@ -372,33 +433,35 @@ cargo test
|
|||||||
|
|
||||||
```
|
```
|
||||||
gridline/
|
gridline/
|
||||||
├── src/ # React frontend
|
├── package.json # Bun workspace root (desktop + www)
|
||||||
│ ├── components/ # Reusable UI components
|
├── desktop/ # Tauri desktop app (React + Rust)
|
||||||
│ ├── stores/ # Zustand/Jotai state stores
|
│ ├── src/ # React frontend
|
||||||
│ ├── hooks/ # Custom React hooks
|
│ │ ├── components/ # Reusable UI components
|
||||||
│ ├── lib/ # Utilities, types, Tauri bindings
|
│ │ ├── stores/ # Zustand/Jotai state stores
|
||||||
│ ├── App.tsx # Root component
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
│ └── main.tsx # Entry point
|
│ │ ├── lib/ # Utilities, types, Tauri bindings
|
||||||
├── src-tauri/ # Rust backend
|
│ │ ├── App.tsx # Root component
|
||||||
│ ├── src/
|
│ │ └── main.tsx # Entry point
|
||||||
│ │ ├── main.rs # Entry point
|
│ ├── src-tauri/ # Rust backend
|
||||||
│ │ ├── lib.rs # Tauri command registration
|
│ │ ├── src/
|
||||||
│ │ ├── db/ # Database connection & pooling
|
│ │ │ ├── main.rs # Entry point
|
||||||
│ │ ├── commands/ # Tauri IPC command handlers
|
│ │ │ ├── lib.rs # Tauri command registration
|
||||||
│ │ └── models/ # Data structures & serde types
|
│ │ │ ├── db/ # Database connection & pooling
|
||||||
│ ├── Cargo.toml
|
│ │ │ ├── commands/ # Tauri IPC command handlers
|
||||||
│ └── tauri.conf.json
|
│ │ │ └── models/ # Data structures & serde types
|
||||||
├── public/ # Static assets
|
│ │ ├── Cargo.toml
|
||||||
├── package.json
|
│ │ └── tauri.conf.json
|
||||||
├── tsconfig.json
|
│ ├── package.json
|
||||||
└── vite.config.ts
|
│ ├── tsconfig.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
└── www/ # Website (Astro + Tailwind)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
The full plan — next-up (v0.7.7), 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:
|
||||||
|
|
||||||
@@ -434,7 +497,7 @@ Highlights of what's next:
|
|||||||
|
|
||||||
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
|
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
|
||||||
|
|
||||||
- Report a bug via the **[issue template](.github/ISSUE_TEMPLATE/bug_report.md)** — it walks you through environment details (OS, Gridline version, DB type/version, connection method) so we can reproduce issues quickly. Opening a [new issue](https://github.com/AdrianBonpin/gridline/issues/new) pre-fills the template automatically.
|
- Open an issue via the **[template chooser](https://github.com/AdrianBonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
|
||||||
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
|
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,6 +6,142 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Shipped (0.8.0)
|
||||||
|
|
||||||
|
- **Schema diff / compare** — same-engine structural diff (PG↔PG, MySQL-family↔MySQL-family, SQLite↔SQLite) with per-object sync SQL; non-destructive items stage through the changes queue, destructive items are copy-only. Target = current connection; source = any saved same-family connection.
|
||||||
|
- **Query Workbench: multiple result sets** — stacked result panels per statement with DML/DDL affected-count notices and stop-at-first-error *(deferred from 0.7.0)*
|
||||||
|
- **Query Workbench: result streaming to file** — full-result CSV/JSONL/JSON export with progress + cancel, constant memory (PG cursor fetch, MySQL/SQLite native streams)
|
||||||
|
- **MariaDB support** — first-class `db_type` reusing the MySQL driver path; provider card, filter, URI schemes, backup via the already-bundled mariadb tools
|
||||||
|
- **TimescaleDB support** — runtime extension detection; hypertables in the Objects view (dimensions, compression, chunks, size), read-only
|
||||||
|
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
|
||||||
|
- **Version bump** 0.7.15 → **0.8.0**.
|
||||||
|
|
||||||
|
## ✅ Shipped (unreleased)
|
||||||
|
|
||||||
|
- **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*.
|
||||||
|
- **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins.
|
||||||
|
- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
|
||||||
|
|
||||||
|
## 📋 In the queue
|
||||||
|
|
||||||
|
### Full Redis Support
|
||||||
|
|
||||||
|
Connection + test only today — browsing stays gated behind a clean "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
||||||
|
|
||||||
|
- Key browser (pattern search, filter by type, TTL display, key count)
|
||||||
|
- Value editors per type: string, list, hash, set, zset, stream, JSON
|
||||||
|
- Inline add / edit / delete keys, TTL management, flush
|
||||||
|
- Key expiry tracking and live refresh
|
||||||
|
|
||||||
|
### Additional Database Types
|
||||||
|
|
||||||
|
MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand).
|
||||||
|
|
||||||
|
### Managed Database Support (beyond Supabase / Neon)
|
||||||
|
|
||||||
|
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
|
|
||||||
|
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
|
||||||
|
|
||||||
|
### Query Workbench Upgrades
|
||||||
|
|
||||||
|
Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
|
|
||||||
|
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||||
|
|
||||||
|
### Schema & Data Tooling
|
||||||
|
|
||||||
|
Schema diff / compare moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
|
|
||||||
|
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(PostgreSQL-only today)*
|
||||||
|
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||||
|
|
||||||
|
## 🔮 Planned
|
||||||
|
|
||||||
|
### AI Integration (BYOK)
|
||||||
|
|
||||||
|
Bring-your-own-key — no bundled model, no paywall, key stored in the OS keychain like DB passwords.
|
||||||
|
|
||||||
|
- Natural-language → SQL generation (schema-aware)
|
||||||
|
- Chat with your database (explain query results, error messages)
|
||||||
|
- Query explanations and schema summaries
|
||||||
|
- AI-generated charts from result sets
|
||||||
|
- Privacy-first: only the SQL/text you choose is sent to your provider; write-queries blocked by default, destructive actions confirmed before execution
|
||||||
|
|
||||||
|
### Website & Docs
|
||||||
|
|
||||||
|
The marketing site (`www/`, Astro) is live: landing page with screenshots, FAQ, and a changelog feed. Remaining:
|
||||||
|
|
||||||
|
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
|
||||||
|
|
||||||
|
### UI/UX Improvements (rolling)
|
||||||
|
|
||||||
|
Continuous polish, tracked as issues rather than one-off milestones:
|
||||||
|
|
||||||
|
- Empty states, error surfacing, and microcopy
|
||||||
|
- Keyboard shortcut audit + more configurable actions
|
||||||
|
- Performance passes on the grid, object tree, and large schemas
|
||||||
|
- Accessibility (contrast, focus states, screen-reader labels)
|
||||||
|
- Session restore — reopen tabs and query state from the last session
|
||||||
|
- Light-theme parity pass — dark is first-class; polish the light theme to match
|
||||||
|
|
||||||
|
Still deferred from 0.7.0, slated for this bucket:
|
||||||
|
|
||||||
|
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
|
||||||
|
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
|
||||||
|
- **In-app changelog** — "What's new" panel fed from bundled release notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.10)
|
||||||
|
|
||||||
|
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
|
||||||
|
- **Version bump** 0.7.9 → **0.7.10**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.15)
|
||||||
|
|
||||||
|
- **Insert Row** — adds an editable pending row at the top of the data grid (visible accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply
|
||||||
|
- **Smart cell editors** — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and FKs already had dropdowns), applied to both new rows and inline editing
|
||||||
|
- **Landing page support channel** — coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature
|
||||||
|
- **Landing page copy refresh** — em dashes dropped, OSS section simplified, FAQ added for how the project is funded
|
||||||
|
- **Version bump** 0.7.14 → **0.7.15**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.14)
|
||||||
|
|
||||||
|
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
|
||||||
|
- **Install via Homebrew** — add the Homebrew tap (`brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
|
||||||
|
- **Version bump** 0.7.13 → **0.7.14**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.13)
|
||||||
|
|
||||||
|
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
|
||||||
|
- **Version bump** 0.7.12 → **0.7.13**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh)*
|
||||||
|
|
||||||
|
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type
|
||||||
|
- **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
|
||||||
|
- **Full CI release pipeline for Windows** — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux
|
||||||
|
- **Version bump** 0.7.11 → **0.7.12**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.9)
|
||||||
|
|
||||||
|
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
||||||
|
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
|
||||||
|
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
||||||
|
- **Version bump** 0.7.8 → **0.7.9**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.8)
|
||||||
|
|
||||||
|
- **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX
|
||||||
|
- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup/restore via `mysqldump` (system-first) and DB-to-DB sync beyond PostgreSQL
|
||||||
|
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||||
|
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||||
|
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||||
|
- **Windows/Linux title bar fix** — the macOS overlay drag strip (`h-7` in `App.tsx`) is now gated to macOS; Windows/Linux use the native title bar for dragging
|
||||||
|
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||||
|
- **Version bump** 0.7.7 → **0.7.8**.
|
||||||
|
|
||||||
## ✅ Shipped (0.7.7)
|
## ✅ Shipped (0.7.7)
|
||||||
|
|
||||||
### Admin follow-up
|
### Admin follow-up
|
||||||
@@ -49,112 +185,13 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
|||||||
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
||||||
- **Version bump** 0.6.0 → **0.7.0**.
|
- **Version bump** 0.6.0 → **0.7.0**.
|
||||||
|
|
||||||
**Not in 0.7.0** (deferred, tracked below): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
|
**Deferred from 0.7.0**: Redis key browsing and MySQL Objects/ERD views (still in the queue); SSH key management and onboarding tour (planned). Since shipped elsewhere: backup/restore/sync for MySQL & SQLite and settings import/export (0.7.8); multiple result sets (slated for 0.8.0).
|
||||||
|
|
||||||
## 🎯 Next up (0.7.8) — Quick wins
|
## ✅ Shipped — earlier releases (pre-0.7.0)
|
||||||
|
|
||||||
Scope: small, individually shippable items that round out tooling gaps. Picked to follow the v0.7.7 features milestone; tag **v0.7.8** when done.
|
|
||||||
|
|
||||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
|
||||||
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
|
||||||
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
|
||||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
|
||||||
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
|
||||||
|
|
||||||
## 📋 In the queue
|
|
||||||
|
|
||||||
### Full Redis Support
|
|
||||||
|
|
||||||
Connection + test work today; **key browsing is explicitly not part of 0.7.0** — it stays gated behind an "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
|
||||||
|
|
||||||
- Key browser (pattern search, filter by type, TTL display, key count)
|
|
||||||
- Value editors per type: string, list, hash, set, zset, stream, JSON
|
|
||||||
- Inline add / edit / delete keys, TTL management, flush
|
|
||||||
- Key expiry tracking and live refresh
|
|
||||||
|
|
||||||
### Additional Database Types
|
|
||||||
|
|
||||||
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work)
|
|
||||||
- **TimescaleDB** (PostgreSQL extension — largely free once PG browsing is solid; surface hypertables/compression in the object tree)
|
|
||||||
- Candidates after that: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand)
|
|
||||||
|
|
||||||
### Managed Database Support (beyond Supabase / Neon)
|
|
||||||
|
|
||||||
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
|
||||||
|
|
||||||
- **PlanetScale** (Vitess/MySQL)
|
|
||||||
- **Turso** (libSQL)
|
|
||||||
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
|
|
||||||
|
|
||||||
### Query Workbench Upgrades
|
|
||||||
|
|
||||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
|
||||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
|
||||||
- **Result streaming to file** — export 500k+ rows without loading them all into memory
|
|
||||||
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
|
||||||
|
|
||||||
### Schema & Data Tooling
|
|
||||||
|
|
||||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite
|
|
||||||
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
|
|
||||||
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
|
|
||||||
- **Backup/Restore/Sync for MySQL & SQLite** — pg_dump tooling is PostgreSQL-only today *(deferred from 0.7.0)*
|
|
||||||
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
|
|
||||||
|
|
||||||
## 🔮 Planned
|
|
||||||
|
|
||||||
### AI Integration (BYOK)
|
|
||||||
|
|
||||||
Bring-your-own-key — no bundled model, no paywall, key stored in the OS keychain like DB passwords.
|
|
||||||
|
|
||||||
- Natural-language → SQL generation (schema-aware)
|
|
||||||
- Chat with your database (explain query results, error messages)
|
|
||||||
- Query explanations and schema summaries
|
|
||||||
- AI-generated charts from result sets
|
|
||||||
- Privacy-first: only the SQL/text you choose is sent to your provider; write-queries blocked by default, destructive actions confirmed before execution
|
|
||||||
|
|
||||||
### Website & Docs
|
|
||||||
|
|
||||||
- Landing page with screenshots, feature tour, and download links
|
|
||||||
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
|
|
||||||
- Blog / changelog feed
|
|
||||||
|
|
||||||
### UI/UX Improvements (rolling)
|
|
||||||
|
|
||||||
Continuous polish, tracked as issues rather than one-off milestones:
|
|
||||||
|
|
||||||
- Empty states, error surfacing, and microcopy
|
|
||||||
- Keyboard shortcut audit + more configurable actions
|
|
||||||
- Performance passes on the grid, object tree, and large schemas
|
|
||||||
- Accessibility (contrast, focus states, screen-reader labels)
|
|
||||||
- Session restore — reopen tabs and query state from the last session
|
|
||||||
- Light-theme parity pass — dark is first-class; polish the light theme to match
|
|
||||||
|
|
||||||
Deferred from 0.7.0, slated for this bucket:
|
|
||||||
|
|
||||||
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
|
|
||||||
- **Settings export/import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
|
||||||
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
|
|
||||||
- **In-app changelog** — "What's new" panel fed from bundled release notes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Shipped
|
|
||||||
|
|
||||||
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
|
|
||||||
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
|
|
||||||
- Objects view tabbed workspace (v0.7.6)
|
|
||||||
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
|
|
||||||
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
|
|
||||||
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
|
|
||||||
- Copy as DDL for any object (v0.7.5)
|
|
||||||
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
|
|
||||||
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
||||||
- PostgreSQL and SQLite browse/query support
|
|
||||||
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
|
||||||
- New Connection screen revamp — provider grid + Supabase/NeonDB managed-pg presets (v0.7.0)
|
|
||||||
- DB viewer capability gating + Redis unsupported state (v0.7.0)
|
|
||||||
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
|
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
|
||||||
|
- PostgreSQL and SQLite browse/query support
|
||||||
- Workspace tree, folders, tags, favorites, recents
|
- Workspace tree, folders, tags, favorites, recents
|
||||||
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
|
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
|
||||||
- FK preview, JSON popover, inline cell editing, changes queue
|
- FK preview, JSON popover, inline cell editing, changes queue
|
||||||
@@ -163,4 +200,4 @@ Deferred from 0.7.0, slated for this bucket:
|
|||||||
- Full PostgreSQL object explorer + schema visualizer
|
- Full PostgreSQL object explorer + schema visualizer
|
||||||
- Backup, restore, and DB-to-DB sync tools
|
- Backup, restore, and DB-to-DB sync tools
|
||||||
- Settings redesign with theme, accent color, editor options
|
- Settings redesign with theme, accent color, editor options
|
||||||
- Built-in **Gridline Demo (SQLite)** database
|
- Built-in **Gridline Demo (SQLite)** database
|
||||||
|
|||||||
@@ -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.8.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -339,6 +339,19 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bigdecimal"
|
||||||
|
version = "0.4.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"libm",
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1783,10 +1796,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.7"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
"futures-util",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"redis",
|
"redis",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1794,6 +1810,8 @@ dependencies = [
|
|||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pemfile 2.2.0",
|
"rustls-pemfile 2.2.0",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -1804,6 +1822,7 @@ dependencies = [
|
|||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-keyring-store",
|
"tauri-plugin-keyring-store",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
"tokio-postgres",
|
||||||
"tokio-postgres-rustls",
|
"tokio-postgres-rustls",
|
||||||
@@ -3047,6 +3066,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 +3083,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"openssl-src",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
@@ -4410,8 +4439,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b"
|
checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
|
"bigdecimal",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
@@ -4492,9 +4523,11 @@ checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bigdecimal",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -4534,8 +4567,10 @@ checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bigdecimal",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
@@ -4551,6 +4586,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"md-5 0.10.6",
|
"md-5 0.10.6",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"num-bigint",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4571,6 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee"
|
checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
|
"chrono",
|
||||||
"flume",
|
"flume",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5005,6 +5042,22 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.7"
|
version = "0.8.2"
|
||||||
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,17 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
bytes = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
# chrono/bigdecimal: MySQL DECIMAL and DATE/TIME/DATETIME/TIMESTAMP have no
|
||||||
|
# decoder without them, so those columns silently rendered as NULL (#41).
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json", "chrono", "bigdecimal"] }
|
||||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||||
ssh2 = { version = "0.9" }
|
# vendored-openssl: link OpenSSL statically so the release binary has NO
|
||||||
|
# runtime dependency on the build machine's Homebrew openssl (@/opt/homebrew
|
||||||
|
# absolute LC_LOAD_DYLIB — dyld aborts on user machines without it).
|
||||||
|
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||||
deadpool-postgres = { version = "0.14" }
|
deadpool-postgres = { version = "0.14" }
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
||||||
@@ -45,3 +52,19 @@ rustls-pemfile = "2"
|
|||||||
rustls-pki-types = "1"
|
rustls-pki-types = "1"
|
||||||
rustls-native-certs = "0.7"
|
rustls-native-certs = "0.7"
|
||||||
|
|
||||||
|
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
|
||||||
|
# survive app re-signs without permission prompts) — macOS only.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
security-framework = "3.7"
|
||||||
|
security-framework-sys = "2.17"
|
||||||
|
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
|
||||||
|
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||||
|
core-foundation = "0.10"
|
||||||
|
|
||||||
|
# Windows/Linux deliver an OS "open with" request as argv. Without this a
|
||||||
|
# second double-click would launch a second app instead of forwarding the path
|
||||||
|
# to the running one. macOS needs no equivalent: LaunchServices routes to the
|
||||||
|
# running app via RunEvent::Opened.
|
||||||
|
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
|
||||||
|
app crashes under the hardened runtime. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<!-- The WebView allocates executable memory for JIT; allow it. -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 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,221 @@
|
|||||||
|
//! 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::atomic::{AtomicBool, Ordering};
|
||||||
|
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>>,
|
||||||
|
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
|
||||||
|
/// sets one for MySQL; long-running in-process operations (currently the
|
||||||
|
/// native restore engine) poll it between statements and clear it when
|
||||||
|
/// they finish. Kept in a separate map from the handles so it needs no
|
||||||
|
/// `Clone` on the registry and survives handle replacement.
|
||||||
|
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CancelRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get (or create) the cancellation flag for a connection. Polling callers
|
||||||
|
/// should clear it before starting and after finishing.
|
||||||
|
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
|
||||||
|
self.flags
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a connection as cancel-requested. Called by `cancel_query` before
|
||||||
|
/// it sends the driver-specific cancel so in-process consumers observe the
|
||||||
|
/// request even when the driver surfaces it as an unrecognized error.
|
||||||
|
pub fn mark_cancelled(&self, id: &str) {
|
||||||
|
self.cancel_flag(id).store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a cancel has been requested for this connection.
|
||||||
|
pub fn is_cancelled(&self, id: &str) -> bool {
|
||||||
|
self.flags
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(id)
|
||||||
|
.map(|f| f.load(Ordering::SeqCst))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the cancel-requested flag for this connection.
|
||||||
|
pub fn clear_cancelled(&self, id: &str) {
|
||||||
|
if let Some(f) = self.flags.lock().unwrap().get(id) {
|
||||||
|
f.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_flag_is_tracked_per_connection() {
|
||||||
|
let reg = CancelRegistry::new();
|
||||||
|
assert!(!reg.is_cancelled("c1"));
|
||||||
|
reg.mark_cancelled("c1");
|
||||||
|
assert!(reg.is_cancelled("c1"));
|
||||||
|
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
|
||||||
|
reg.clear_cancelled("c1");
|
||||||
|
assert!(!reg.is_cancelled("c1"));
|
||||||
|
// The same Arc is shared between getter and setter.
|
||||||
|
let flag = reg.cancel_flag("c1");
|
||||||
|
reg.mark_cancelled("c1");
|
||||||
|
assert!(flag.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||||
|
sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,3 +418,220 @@ fn bundled_bin_name_appends_exe_on_windows() {
|
|||||||
let name = bundled_bin_name("pg_dump");
|
let name = bundled_bin_name("pg_dump");
|
||||||
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
|
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SQLite .dump / restore / sync core (Task 2.2)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
fn seed_sqlite() -> Connection {
|
||||||
|
let c = Connection::open_in_memory().unwrap();
|
||||||
|
c.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
||||||
|
CREATE INDEX users_name ON users(name);
|
||||||
|
INSERT INTO users (name) VALUES ('Alice'),('Bob');
|
||||||
|
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
|
||||||
|
INSERT INTO blobs VALUES (1, x'010203');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_and_restore_roundtrip() {
|
||||||
|
let src = seed_sqlite();
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let mut cur = std::io::Cursor::new(&mut buf);
|
||||||
|
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(text.contains("PRAGMA foreign_keys=OFF"));
|
||||||
|
assert!(text.contains("BEGIN TRANSACTION"));
|
||||||
|
assert!(text.contains("CREATE TABLE users"));
|
||||||
|
assert!(text.contains("INSERT INTO \"users\""));
|
||||||
|
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
|
||||||
|
assert!(text.contains("CREATE INDEX users_name"));
|
||||||
|
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
restore_sqlite(&dst, &text, false).unwrap();
|
||||||
|
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(blobs, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_preserves_autoincrement_sequence() {
|
||||||
|
let src = Connection::open_in_memory().unwrap();
|
||||||
|
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
restore_sqlite(&dst, &text, false).unwrap();
|
||||||
|
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
|
||||||
|
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(id, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_fail_closed_for_virtual_tables() {
|
||||||
|
let src = Connection::open_in_memory().unwrap();
|
||||||
|
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
|
||||||
|
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_restore_clean_drops_existing() {
|
||||||
|
let src = seed_sqlite();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
|
||||||
|
restore_sqlite(&dst, &text, true).unwrap();
|
||||||
|
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
|
||||||
|
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// MySQL dump / restore / sync arg building (Task 2.3)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||||
|
let opts = MySqlBackupOptions {
|
||||||
|
database: "shop".into(), file_path: "/tmp/d.sql".into(),
|
||||||
|
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
|
||||||
|
};
|
||||||
|
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||||
|
assert!(args.iter().any(|a| a == "--single-transaction"));
|
||||||
|
assert!(args.iter().any(|a| a == "--no-data"));
|
||||||
|
assert!(args.iter().any(|a| a == "--routines"));
|
||||||
|
assert!(args.iter().any(|a| a == "--databases=shop"));
|
||||||
|
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
|
||||||
|
// no --password on the command line (uses MYSQL_PWD env)
|
||||||
|
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── native MySQL restore: error reporting contract ──────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_failure_names_the_statement_position() {
|
||||||
|
// The user must be able to find the offending statement in their file.
|
||||||
|
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
|
||||||
|
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_progress_total_counts_the_clean_phase() {
|
||||||
|
// clean adds one progress unit (the drop phase) so the bar never resets.
|
||||||
|
assert_eq!(restore_progress_total(5, false), 5);
|
||||||
|
assert_eq!(restore_progress_total(5, true), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
|
||||||
|
use sqlx::ConnectOptions;
|
||||||
|
use sqlx::Connection;
|
||||||
|
|
||||||
|
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
|
||||||
|
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
|
||||||
|
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||||
|
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||||
|
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
|
||||||
|
|
||||||
|
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||||
|
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||||
|
.disable_statement_logging()
|
||||||
|
.connect().await.expect("connect");
|
||||||
|
|
||||||
|
// Scratch table, removed again at the end, so the live DB is never harmed.
|
||||||
|
let table = "gl_restore_probe";
|
||||||
|
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||||
|
.execute(&mut conn).await.expect("pre-clean");
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
|
||||||
|
INSERT INTO `{table}` VALUES (1, 'first');"
|
||||||
|
);
|
||||||
|
let statements = crate::db::sql_split::split_statements(&script);
|
||||||
|
|
||||||
|
let mut events: Vec<(usize, usize)> = Vec::new();
|
||||||
|
let mut on_progress = |done: usize, total: usize| events.push((done, total));
|
||||||
|
let never = || false;
|
||||||
|
|
||||||
|
run_mysql_restore_on(
|
||||||
|
&mut conn,
|
||||||
|
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||||
|
&mut on_progress,
|
||||||
|
&never,
|
||||||
|
).await.expect("first restore");
|
||||||
|
|
||||||
|
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||||
|
.fetch_one(&mut conn).await.expect("count");
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
|
||||||
|
|
||||||
|
// Second run with clean: the table must be dropped and recreated, so the
|
||||||
|
// insert cannot collide with the existing primary key.
|
||||||
|
let mut events2: Vec<(usize, usize)> = Vec::new();
|
||||||
|
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
|
||||||
|
run_mysql_restore_on(
|
||||||
|
&mut conn,
|
||||||
|
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||||
|
&mut on_progress2,
|
||||||
|
&never,
|
||||||
|
).await.expect("clean restore must not hit a duplicate key");
|
||||||
|
|
||||||
|
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||||
|
.fetch_one(&mut conn).await.expect("count2");
|
||||||
|
assert_eq!(count2, 1, "clean restore must replace, not append");
|
||||||
|
|
||||||
|
// A statement that cannot succeed must fail and name its position.
|
||||||
|
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
|
||||||
|
let mut noop = |_: usize, _: usize| {};
|
||||||
|
let err = run_mysql_restore_on(
|
||||||
|
&mut conn,
|
||||||
|
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
|
||||||
|
&mut noop,
|
||||||
|
&never,
|
||||||
|
).await.expect_err("must fail");
|
||||||
|
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
|
||||||
|
|
||||||
|
let _ = conn.close().await;
|
||||||
|
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||||
|
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
|
||||||
|
.connect().await.expect("reconnect");
|
||||||
|
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||||
|
.execute(&mut cleanup).await.expect("cleanup");
|
||||||
|
let _ = cleanup.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── restore file-size cap ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_size_cap_accepts_a_file_at_the_limit() {
|
||||||
|
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
|
||||||
|
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
|
||||||
|
.expect_err("must reject");
|
||||||
|
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
|
||||||
|
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use crate::models::{Connection, ConnectionInput};
|
|||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
|
|
||||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||||
@@ -360,3 +360,7 @@ mod tests {
|
|||||||
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "connections.test.rs"]
|
||||||
|
mod connections_tests;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mariadb_input() -> ConnectionInput {
|
||||||
|
ConnectionInput {
|
||||||
|
name: "Maria local".into(),
|
||||||
|
db_type: "mariadb".into(),
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: Some(3306),
|
||||||
|
username: Some("root".into()),
|
||||||
|
folder_id: None,
|
||||||
|
tag_ids: vec![],
|
||||||
|
password: None,
|
||||||
|
database: Some("test".into()),
|
||||||
|
environment: None,
|
||||||
|
ssh_host: None,
|
||||||
|
ssh_port: None,
|
||||||
|
ssh_user: None,
|
||||||
|
ssh_auth_method: None,
|
||||||
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
|
ssh_passphrase: None,
|
||||||
|
ssl_mode: None,
|
||||||
|
ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None,
|
||||||
|
ssl_key_path: None,
|
||||||
|
use_keychain: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_mariadb_with_port() {
|
||||||
|
assert!(validate(&mariadb_input()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_mariadb_without_port() {
|
||||||
|
let mut input = mariadb_input();
|
||||||
|
input.port = None;
|
||||||
|
assert!(validate(&input).is_err(), "mariadb requires a port like mysql");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_unknown_db_type() {
|
||||||
|
let mut input = mariadb_input();
|
||||||
|
input.db_type = "oracle".into();
|
||||||
|
assert!(validate(&input).is_err());
|
||||||
|
}
|
||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
use crate::models::db_viewer::{
|
use crate::models::db_viewer::{
|
||||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
|
||||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||||
};
|
};
|
||||||
|
use bytes::BytesMut;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tokio_postgres::types::ToSql;
|
use tokio_postgres::types::{Format, IsNull, ToSql, Type};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helper functions
|
// Helper functions
|
||||||
@@ -564,6 +566,14 @@ pub fn build_pg_insert_sql(
|
|||||||
table: &str,
|
table: &str,
|
||||||
columns: &[(String, serde_json::Value)],
|
columns: &[(String, serde_json::Value)],
|
||||||
) -> (String, Vec<serde_json::Value>) {
|
) -> (String, Vec<serde_json::Value>) {
|
||||||
|
// A table with no editable columns (e.g. only a serial/identity PK) inserts
|
||||||
|
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
|
||||||
|
if columns.is_empty() {
|
||||||
|
return (
|
||||||
|
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
}
|
||||||
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
||||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||||
let placeholders: Vec<String> = columns
|
let placeholders: Vec<String> = columns
|
||||||
@@ -585,26 +595,75 @@ pub fn build_pg_insert_sql(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A value bound to a PostgreSQL parameter in TEXT wire format, accepting any
|
||||||
|
/// parameter type.
|
||||||
|
///
|
||||||
|
/// The frontend ships edited values as plain strings (and PK locators as
|
||||||
|
/// numbers/booleans), but binding a native `String` fails for every non-text
|
||||||
|
/// column type — `String::accepts` only matches `varchar`/`text`/`bpchar`/
|
||||||
|
/// `name` — with `error serializing parameter N: cannot convert between the
|
||||||
|
/// Rust type ... and the Postgres type ...`. Binding a native `i64` is also
|
||||||
|
/// wrong because `i64::accepts` only matches `int8`.
|
||||||
|
///
|
||||||
|
/// `PgText` accepts *any* parameter type and writes the value as UTF-8 text in
|
||||||
|
/// text wire format, so PostgreSQL itself parses the literal into the target
|
||||||
|
/// column type. This works uniformly for `integer`, `bigint`, `numeric`,
|
||||||
|
/// `boolean`, `uuid`, `json`/`jsonb`, timestamps, enums, etc. — exactly what
|
||||||
|
/// PostgreSQL does for an `'<literal>'` in a query.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PgText(String);
|
||||||
|
|
||||||
|
impl From<&str> for PgText {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
PgText(s.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql for PgText {
|
||||||
|
fn to_sql(
|
||||||
|
&self,
|
||||||
|
_ty: &Type,
|
||||||
|
out: &mut BytesMut,
|
||||||
|
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
|
||||||
|
out.extend_from_slice(self.0.as_bytes());
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepts(_ty: &Type) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_format(&self, _ty: &Type) -> Format {
|
||||||
|
Format::Text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_sql_checked(
|
||||||
|
&self,
|
||||||
|
ty: &Type,
|
||||||
|
out: &mut BytesMut,
|
||||||
|
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
|
||||||
|
self.to_sql(ty, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a JSON value into a boxed PostgreSQL-bindable value.
|
/// Convert a JSON value into a boxed PostgreSQL-bindable value.
|
||||||
///
|
///
|
||||||
/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors.
|
/// Every non-null value is bound as a `PgText` (text wire format) so the server
|
||||||
/// Unknown/complex types are stringified as a fallback.
|
/// parses it into the target column type; SQL `NULL` is bound as `None`. This
|
||||||
|
/// is deliberately type-agnostic — the caller already knows the column type
|
||||||
|
/// through the SQL context (e.g. `SET "col" = $1` lets PostgreSQL infer `$1`
|
||||||
|
/// as `col`'s type).
|
||||||
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
|
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
|
||||||
match v {
|
if v.is_null() {
|
||||||
serde_json::Value::Null => Box::new(Option::<String>::None),
|
return Box::new(Option::<PgText>::None);
|
||||||
serde_json::Value::Bool(b) => Box::new(*b),
|
|
||||||
serde_json::Value::Number(n) => {
|
|
||||||
if let Some(i) = n.as_i64() {
|
|
||||||
Box::new(i)
|
|
||||||
} else if let Some(f) = n.as_f64() {
|
|
||||||
Box::new(f)
|
|
||||||
} else {
|
|
||||||
Box::new(n.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::Value::String(s) => Box::new(s.clone()),
|
|
||||||
other => Box::new(other.to_string()),
|
|
||||||
}
|
}
|
||||||
|
let text = match v {
|
||||||
|
serde_json::Value::String(s) => s.clone(),
|
||||||
|
serde_json::Value::Bool(b) => b.to_string(),
|
||||||
|
serde_json::Value::Number(n) => n.to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
Box::new(PgText::from(text.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
|
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
|
||||||
@@ -882,6 +941,102 @@ where
|
|||||||
Ok((client, handle))
|
Ok((client, handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply Gridline's MySQL TLS policy to connect options.
|
||||||
|
///
|
||||||
|
/// Extracted from `run_mysql_connect` so the native restore path connects
|
||||||
|
/// *exactly* as browsing does. That equivalence is the whole point of the fix:
|
||||||
|
/// the sqlx driver reaches MySQL 8 with `caching_sha2_password` and handles
|
||||||
|
/// TLS in-process, where the bundled MariaDB client cannot.
|
||||||
|
pub(crate) fn apply_mysql_tls(
|
||||||
|
mut opts: sqlx::mysql::MySqlConnectOptions,
|
||||||
|
original_host: &str,
|
||||||
|
ssl_mode: Option<&str>,
|
||||||
|
ssl_ca_path: Option<&str>,
|
||||||
|
via_tunnel: bool,
|
||||||
|
) -> sqlx::mysql::MySqlConnectOptions {
|
||||||
|
use sqlx::mysql::MySqlSslMode;
|
||||||
|
|
||||||
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
|
crate::db::tls::tls_decision_for(original_host, ssl_mode),
|
||||||
|
via_tunnel,
|
||||||
|
);
|
||||||
|
match decision {
|
||||||
|
crate::db::tls::TlsDecision::Disable => {
|
||||||
|
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||||
|
}
|
||||||
|
crate::db::tls::TlsDecision::Require => {
|
||||||
|
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||||
|
}
|
||||||
|
crate::db::tls::TlsDecision::Verify => {
|
||||||
|
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||||
|
// verify-full -> VerifyIdentity (chain + hostname).
|
||||||
|
match ssl_mode {
|
||||||
|
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||||
|
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||||
|
}
|
||||||
|
if let Some(ca) = ssl_ca_path {
|
||||||
|
opts = opts.ssl_ca(ca);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the final MySQL connect options (base params + TLS policy) for a
|
||||||
|
/// headless, non-pooled connection.
|
||||||
|
pub(crate) fn mysql_connect_options(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
database: &str,
|
||||||
|
original_host: &str,
|
||||||
|
ssl_mode: Option<&str>,
|
||||||
|
ssl_ca_path: Option<&str>,
|
||||||
|
via_tunnel: bool,
|
||||||
|
) -> sqlx::mysql::MySqlConnectOptions {
|
||||||
|
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(host)
|
||||||
|
.port(port)
|
||||||
|
.username(username)
|
||||||
|
.password(password)
|
||||||
|
.database(database);
|
||||||
|
apply_mysql_tls(opts, original_host, ssl_mode, ssl_ca_path, via_tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a single standalone MySQL connection for a non-browsing operation
|
||||||
|
/// (currently: native restore). Deliberately not pooled — the restore owns the
|
||||||
|
/// session so `USE` and `FOREIGN_KEY_CHECKS` stay coherent for its whole run.
|
||||||
|
pub(crate) async fn open_mysql_connection(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
database: &str,
|
||||||
|
original_host: &str,
|
||||||
|
ssl_mode: Option<&str>,
|
||||||
|
ssl_ca_path: Option<&str>,
|
||||||
|
via_tunnel: bool,
|
||||||
|
) -> Result<sqlx::mysql::MySqlConnection, String> {
|
||||||
|
use sqlx::ConnectOptions;
|
||||||
|
let opts = mysql_connect_options(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
original_host,
|
||||||
|
ssl_mode,
|
||||||
|
ssl_ca_path,
|
||||||
|
via_tunnel,
|
||||||
|
);
|
||||||
|
opts.connect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize_error(&format!("MySQL connection failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
||||||
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
||||||
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
||||||
@@ -891,8 +1046,9 @@ 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};
|
||||||
if config.host.trim().is_empty() {
|
if config.host.trim().is_empty() {
|
||||||
return Err("host is required".to_string());
|
return Err("host is required".to_string());
|
||||||
}
|
}
|
||||||
@@ -935,38 +1091,26 @@ pub(crate) async fn run_mysql_connect(
|
|||||||
via_tunnel = false;
|
via_tunnel = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut opts = MySqlConnectOptions::new()
|
let opts = MySqlConnectOptions::new()
|
||||||
.host(&target_host)
|
.host(&target_host)
|
||||||
.port(target_port)
|
.port(target_port)
|
||||||
.username(config.username.as_deref().unwrap_or("root"))
|
.username(config.username.as_deref().unwrap_or("root"))
|
||||||
.password(config.password.as_deref().unwrap_or(""))
|
.password(config.password.as_deref().unwrap_or(""))
|
||||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||||
|
|
||||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
// TLS: shared with the native restore path so browsing and restore make
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// identical TLS decisions.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
let opts = apply_mysql_tls(
|
||||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
opts,
|
||||||
|
&config.host,
|
||||||
|
config.ssl_mode.as_deref(),
|
||||||
|
config.ssl_ca_path.as_deref(),
|
||||||
via_tunnel,
|
via_tunnel,
|
||||||
);
|
);
|
||||||
match decision {
|
|
||||||
crate::db::tls::TlsDecision::Disable => {
|
// Clone the (already final) options before `connect_with` consumes them so
|
||||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
|
||||||
}
|
let cancel_opts = opts.clone();
|
||||||
crate::db::tls::TlsDecision::Require => {
|
|
||||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
|
||||||
}
|
|
||||||
crate::db::tls::TlsDecision::Verify => {
|
|
||||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
|
||||||
// verify-full -> VerifyIdentity (chain + hostname).
|
|
||||||
match config.ssl_mode.as_deref() {
|
|
||||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
|
||||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
|
||||||
}
|
|
||||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
|
||||||
opts = opts.ssl_ca(ca);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match MySqlPoolOptions::new()
|
match MySqlPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
@@ -979,6 +1123,13 @@ pub(crate) async fn run_mysql_connect(
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
|
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
|
||||||
|
cancel_registry.set_mysql(
|
||||||
|
connection_id,
|
||||||
|
crate::cancel::MySqlCancel {
|
||||||
|
conn_id: None,
|
||||||
|
connect_options: cancel_opts,
|
||||||
|
},
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1013,7 +1164,7 @@ pub async fn db_connect(
|
|||||||
// connections honor the user's mode. Building this before opening the
|
// connections honor the user's mode. Building this before opening the
|
||||||
// tunnel means a config error can't leak the tunnel.
|
// tunnel means a config error can't leak the tunnel.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||||
will_tunnel,
|
will_tunnel,
|
||||||
);
|
);
|
||||||
let tls = crate::db::tls::build_tls_config(
|
let tls = crate::db::tls::build_tls_config(
|
||||||
@@ -1023,6 +1174,8 @@ pub async fn db_connect(
|
|||||||
config.ssl_key_path.as_deref(),
|
config.ssl_key_path.as_deref(),
|
||||||
)
|
)
|
||||||
.map_err(|e| sanitize_error(&e))?;
|
.map_err(|e| sanitize_error(&e))?;
|
||||||
|
// Snapshot before `match tls` consumes the Option below.
|
||||||
|
let cancel_cfg = tls.clone();
|
||||||
|
|
||||||
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
||||||
// and connect through it. The blocking ssh2 handshake runs in
|
// and connect through it. The blocking ssh2 handshake runs in
|
||||||
@@ -1080,6 +1233,18 @@ pub async fn db_connect(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((client, handle)) => {
|
Ok((client, handle)) => {
|
||||||
|
// Capture a cancel token + the exact TLS decision/config used to
|
||||||
|
// build the connection so `cancel_query` can open an identical
|
||||||
|
// (short-lived) cancel connection later.
|
||||||
|
let pgtoken = client.cancel_token();
|
||||||
|
state.cancel_registry.set_pg(
|
||||||
|
&connection_id,
|
||||||
|
crate::cancel::PgCancel {
|
||||||
|
cancel_token: pgtoken,
|
||||||
|
tls_decision: decision,
|
||||||
|
tls_config: cancel_cfg,
|
||||||
|
},
|
||||||
|
);
|
||||||
let mut pm = state.pool_manager.lock().await;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.register(
|
pm.register(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
@@ -1101,18 +1266,25 @@ pub async fn db_connect(
|
|||||||
} else if config.db_type == "sqlite" {
|
} else if config.db_type == "sqlite" {
|
||||||
match rusqlite::Connection::open(&config.host) {
|
match rusqlite::Connection::open(&config.host) {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
|
// Capture the per-connection interrupt handle so `cancel_query`
|
||||||
|
// can abort a running SQLite query from another thread.
|
||||||
|
let interrupt = conn.get_interrupt_handle();
|
||||||
let mut pm = state.pool_manager.lock().await;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
|
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
|
||||||
|
state
|
||||||
|
.cancel_registry
|
||||||
|
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||||
}
|
}
|
||||||
} else if config.db_type == "mysql" {
|
} else if config.db_type == "mysql" || config.db_type == "mariadb" {
|
||||||
run_mysql_connect(
|
run_mysql_connect(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
&config,
|
&config,
|
||||||
&state.ssh_manager,
|
&state.ssh_manager,
|
||||||
&state.pool_manager,
|
&state.pool_manager,
|
||||||
|
&state.cancel_registry,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -1130,6 +1302,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2323,11 +2496,15 @@ pub(crate) async fn execute_change_inner(
|
|||||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Change::Ddl { .. } => {
|
Change::Ddl { sql, .. } => {
|
||||||
return Err("Object management is PostgreSQL-only".to_string());
|
// Object DDL can be multi-statement; run as a batch.
|
||||||
|
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
Change::RebuildTable { .. } => {
|
Change::RebuildTable { sql, .. } => {
|
||||||
return Err("Object management is PostgreSQL-only".to_string());
|
// 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,
|
||||||
@@ -2543,6 +2720,58 @@ pub async fn get_functions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_hypertables(
|
||||||
|
connection_id: String,
|
||||||
|
schema: Option<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<HypertableListResponse, String> {
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let detected: bool = client
|
||||||
|
.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||||
|
.await
|
||||||
|
.map(|r| r.get::<_, bool>(0))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !detected {
|
||||||
|
return Ok(HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("TimescaleDB extension is not installed".into()),
|
||||||
|
items: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||||
|
let query = crate::db::introspection::pg_hypertables_query(&schema);
|
||||||
|
let rows = client
|
||||||
|
.query(&query, &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("TimescaleDB introspection failed: {e}"))?;
|
||||||
|
Ok(HypertableListResponse {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
items: rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| HypertableInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: r.get(1),
|
||||||
|
num_dimensions: r.get::<_, Option<i64>>(2).unwrap_or(0),
|
||||||
|
compression_enabled: r.get::<_, Option<bool>>(3).unwrap_or(false),
|
||||||
|
num_chunks: r.get::<_, Option<i64>>(4).unwrap_or(0),
|
||||||
|
total_size_bytes: r.get::<_, Option<i64>>(5),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("Hypertables are a TimescaleDB (PostgreSQL) feature".into()),
|
||||||
|
items: vec![],
|
||||||
|
}),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_indexes(
|
pub async fn get_indexes(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -3013,6 +3242,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A table with no editable columns (only a serial/identity PK) must insert
|
||||||
|
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
|
||||||
|
#[test]
|
||||||
|
fn build_pg_insert_sql_empty_columns_uses_default_values() {
|
||||||
|
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
|
||||||
|
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
|
||||||
|
assert!(params.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||||
/// from `sqlite_master`.
|
/// from `sqlite_master`.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3282,6 +3520,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// PgText: universal text-format parameter binding (serialization fix)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_text_accepts_any_postgres_type() {
|
||||||
|
// String::accepts only matches text-ish types, so a plain String can't
|
||||||
|
// bind to an integer/boolean/uuid/jsonb column. PgText must accept all
|
||||||
|
// of these (the server parses the text into the column's own type).
|
||||||
|
for ty in [
|
||||||
|
Type::TEXT,
|
||||||
|
Type::VARCHAR,
|
||||||
|
Type::INT4,
|
||||||
|
Type::INT8,
|
||||||
|
Type::FLOAT8,
|
||||||
|
Type::NUMERIC,
|
||||||
|
Type::BOOL,
|
||||||
|
Type::UUID,
|
||||||
|
Type::JSON,
|
||||||
|
Type::JSONB,
|
||||||
|
Type::TIMESTAMP,
|
||||||
|
Type::TIMESTAMPTZ,
|
||||||
|
Type::DATE,
|
||||||
|
Type::TIME,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
PgText::accepts(&ty),
|
||||||
|
"PgText must accept {:?}",
|
||||||
|
ty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sanity: a native String must NOT accept a non-text type (proving the
|
||||||
|
// root cause this fix addresses).
|
||||||
|
assert!(!<&str as ToSql>::accepts(&Type::INT4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_text_serializes_as_utf8_text() {
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use tokio_postgres::types::IsNull;
|
||||||
|
|
||||||
|
let val = pg_box_value(&serde_json::json!("42"));
|
||||||
|
let mut out = BytesMut::new();
|
||||||
|
let isnull = val.to_sql_checked(&Type::INT4, &mut out).unwrap();
|
||||||
|
assert!(matches!(isnull, IsNull::No));
|
||||||
|
assert_eq!(&out[..], b"42");
|
||||||
|
// Text wire format: the bytes are raw UTF-8, not binary-encoded int.
|
||||||
|
assert!(matches!(val.encode_format(&Type::INT4), Format::Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_text_boxes_null_to_sql_null() {
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use tokio_postgres::types::IsNull;
|
||||||
|
|
||||||
|
let val = pg_box_value(&serde_json::Value::Null);
|
||||||
|
let mut out = BytesMut::new();
|
||||||
|
assert!(matches!(
|
||||||
|
val.to_sql_checked(&Type::TEXT, &mut out).unwrap(),
|
||||||
|
IsNull::Yes
|
||||||
|
));
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_text_stringifies_booleans_and_numbers() {
|
||||||
|
let b = pg_box_value(&serde_json::json!(true));
|
||||||
|
assert!(matches!(b.encode_format(&Type::BOOL), Format::Text));
|
||||||
|
let n = pg_box_value(&serde_json::json!(5));
|
||||||
|
assert!(matches!(n.encode_format(&Type::INT4), Format::Text));
|
||||||
|
}
|
||||||
|
|
||||||
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
|
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
|
||||||
tokio::sync::Mutex::new(ConnectionPoolManager::new())
|
tokio::sync::Mutex::new(ConnectionPoolManager::new())
|
||||||
}
|
}
|
||||||
@@ -3309,8 +3619,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");
|
||||||
@@ -3369,8 +3680,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"
|
||||||
@@ -3419,8 +3731,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];
|
||||||
@@ -3456,3 +3769,7 @@ mod tests {
|
|||||||
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "db_viewer.test.rs"]
|
||||||
|
mod db_viewer_extra_tests;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hypertables_query_targets_timescale_information_schema() {
|
||||||
|
let q = crate::db::introspection::pg_hypertables_query("public");
|
||||||
|
assert!(q.contains("timescaledb_information.hypertables"));
|
||||||
|
assert!(q.contains("hypertable_schema = $1"));
|
||||||
|
assert!(q.contains("timescaledb_information.chunks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timescale_detect_query_checks_pg_extension() {
|
||||||
|
let q = crate::db::introspection::pg_timescale_detect_query();
|
||||||
|
assert!(q.contains("pg_extension"));
|
||||||
|
assert!(q.contains("'timescaledb'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hypertable_response_degrades_gracefully() {
|
||||||
|
let resp = crate::models::db_viewer::HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("TimescaleDB extension is not installed".into()),
|
||||||
|
items: vec![],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&resp).expect("serializes");
|
||||||
|
assert!(json.contains("\"available\":false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live test. On a plain PostgreSQL connection (no TimescaleDB) the detection
|
||||||
|
/// helper must report `available: false`, never an error. Follows the exact
|
||||||
|
/// env-var + client-construction convention used by the live PG tests in
|
||||||
|
/// `commands/objects.test.rs`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn hypertables_unavailable_on_plain_pg() {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
let id = "test-pg".to_string();
|
||||||
|
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||||
|
let pm = tokio::sync::Mutex::new(pm);
|
||||||
|
|
||||||
|
// Exercise the detection helper directly (the command needs `State`, which
|
||||||
|
// is not constructible in tests).
|
||||||
|
let detected = {
|
||||||
|
let mut g = pm.lock().await;
|
||||||
|
match g.get(&id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => {
|
||||||
|
c.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => panic!("expected a postgresql handle"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(detected.is_ok(), "detection query must run on plain PG");
|
||||||
|
let available: bool = detected.unwrap().get(0);
|
||||||
|
assert!(!available, "plain PG must report available=false");
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use crate::store::Store;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct ImportRecord {
|
pub(crate) struct ImportRecord {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::keychain_acl;
|
||||||
use tauri_plugin_keyring_store::KeyringExt;
|
use tauri_plugin_keyring_store::KeyringExt;
|
||||||
|
|
||||||
/// Store a connection password in the OS keychain.
|
/// Store a connection password in the OS keychain.
|
||||||
@@ -11,7 +12,10 @@ pub fn save_connection_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&connection_id, &password)
|
.set_password(&connection_id, &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
|
||||||
|
keychain_acl::after_store(&app, &connection_id);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a connection password from the OS keychain.
|
/// Retrieve a connection password from the OS keychain.
|
||||||
@@ -21,6 +25,9 @@ pub fn get_connection_password(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
// Repair a stale item ACL first: after a rebuild/path change macOS would
|
||||||
|
// otherwise ask for the keychain password on every access (once per secret).
|
||||||
|
keychain_acl::before_read(&app, &connection_id);
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&connection_id)
|
.get_password(&connection_id)
|
||||||
@@ -33,6 +40,7 @@ pub fn get_connection_password_internal(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(app, connection_id);
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(connection_id)
|
.get_password(connection_id)
|
||||||
@@ -55,7 +63,9 @@ pub fn save_connection_ssh_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("password", &connection_id), &password)
|
.set_password(&ssh_account("password", &connection_id), &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||||
@@ -65,6 +75,7 @@ pub fn get_connection_ssh_password(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("password", &connection_id))
|
.get_password(&ssh_account("password", &connection_id))
|
||||||
@@ -93,7 +104,9 @@ pub fn save_connection_ssh_passphrase(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||||
@@ -103,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("passphrase", &connection_id))
|
.get_password(&ssh_account("passphrase", &connection_id))
|
||||||
@@ -127,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(app, &ssh_account("password", connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("password", connection_id))
|
.get_password(&ssh_account("password", connection_id))
|
||||||
@@ -7,7 +7,10 @@ pub mod import_export;
|
|||||||
pub mod keychain;
|
pub mod keychain;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
|
pub mod open_files;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
|
pub mod query_export;
|
||||||
|
pub mod schema_diff;
|
||||||
pub mod schema_graph;
|
pub mod schema_graph;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
@@ -147,13 +147,120 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
|
|||||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
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
|
/// 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
|
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||||
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
/// `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> {
|
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;
|
let mut pm = pm.lock().await;
|
||||||
match pm.get(connection_id) {
|
match pm.get(connection_id) {
|
||||||
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
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()),
|
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||||
None => Err("Connection not found".into()),
|
None => Err("Connection not found".into()),
|
||||||
}
|
}
|
||||||
@@ -201,125 +308,136 @@ pub(crate) async fn build_rebuild_script_inner(
|
|||||||
new_columns: serde_json::Value,
|
new_columns: serde_json::Value,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut pm = pm.lock().await;
|
let mut pm = pm.lock().await;
|
||||||
let client = match pm.get(connection_id) {
|
match pm.get(connection_id) {
|
||||||
Some(DbHandle::Postgresql(c, _)) => c,
|
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()),
|
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||||
None => return Err("Connection not found".into()),
|
None => return Err("Connection not found".into()),
|
||||||
};
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -417,20 +535,28 @@ pub async fn get_role_privileges(connection_id: String, role: String, state: Sta
|
|||||||
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
|
/// 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> {
|
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;
|
let mut pm = pm.lock().await;
|
||||||
let client = match pm.get(connection_id) {
|
match pm.get(connection_id) {
|
||||||
Some(DbHandle::Postgresql(c, _)) => c,
|
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()),
|
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||||
None => return Err("Connection not found".into()),
|
None => return Err("Connection not found".into()),
|
||||||
};
|
}
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -58,19 +58,62 @@ async fn object_ddl_for_sequence_enum_function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn build_object_ddl_inner_guards_postgresql_only() {
|
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
|
||||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
// Missing connection -> Connection not found
|
// Missing connection -> Connection not found
|
||||||
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
})).await.unwrap_err();
|
})).await.unwrap_err();
|
||||||
assert!(err.contains("Connection not found"), "{err}");
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
// SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
|
||||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
})).await.unwrap_err();
|
})).await.unwrap_err();
|
||||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
assert!(err.contains("unknown op"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
|
||||||
|
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
pm
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
|
||||||
|
let pm = fresh_pool_with_sqlite();
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"schema": "main", "name": "users",
|
||||||
|
"action": { "op": "create", "columns": [
|
||||||
|
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
|
||||||
|
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
|
||||||
|
] }
|
||||||
|
});
|
||||||
|
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
|
||||||
|
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
|
||||||
|
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_change_sqlite_ddl_runs_create() {
|
||||||
|
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
|
||||||
|
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
|
||||||
|
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
|
||||||
|
assert!(r.is_ok(), "{r:?}");
|
||||||
|
// the table really landed on the live connection
|
||||||
|
{
|
||||||
|
let mut g = pm.lock().await;
|
||||||
|
match g.get("c").unwrap() {
|
||||||
|
crate::db::pool::DbHandle::Sqlite(conn) => {
|
||||||
|
let n: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
_ => panic!("expected a sqlite handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! OS-delivered "open this .sql file with Gridline" requests.
|
||||||
|
//!
|
||||||
|
//! Every platform funnels into [`accept_path`], which validates strictly and
|
||||||
|
//! queues the path. The frontend only ever calls [`take_pending_sql_files`],
|
||||||
|
//! which takes **no path argument** and drains what was queued — so the webview
|
||||||
|
//! cannot name a file to read. That is the boundary that keeps this feature
|
||||||
|
//! from becoming an arbitrary-filesystem-read primitive and defeating the
|
||||||
|
//! deliberate `fs` scope (spec §2).
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Editor-load cap. A larger script is routed to Tools → Restore instead of
|
||||||
|
/// being handed to Monaco (spec §2).
|
||||||
|
pub const MAX_SQL_FILE_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// A validated open request, delivered to the frontend with its contents.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PendingSqlFile {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide FIFO of accepted paths. Lives in Rust (not the webview) so a
|
||||||
|
/// window reload cannot lose an open request.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PendingOpenFiles(Mutex<Vec<PathBuf>>);
|
||||||
|
|
||||||
|
/// Strict validation. Fail closed: anything we are not certain about is
|
||||||
|
/// rejected rather than opened (spec §6).
|
||||||
|
pub fn validate_sql_path_with_cap(raw: &str, cap: u64) -> Option<PathBuf> {
|
||||||
|
let path = Path::new(raw);
|
||||||
|
|
||||||
|
// Absolute only — the OS always hands us absolute paths, and accepting a
|
||||||
|
// relative one would resolve against the process CWD. Note the app is
|
||||||
|
// often launched with CWD `/` (see the app-data-dir note in lib.rs).
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = path.extension()?.to_str()?;
|
||||||
|
if !ext.eq_ignore_ascii_case("sql") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// symlink_metadata, not metadata: a symlink must not be followed into an
|
||||||
|
// arbitrary target (and `file_type().is_file()` is false for one).
|
||||||
|
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||||
|
if !meta.file_type().is_file() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if meta.len() > cap {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate against the production cap.
|
||||||
|
pub fn validate_sql_path(raw: &str) -> Option<PathBuf> {
|
||||||
|
validate_sql_path_with_cap(raw, MAX_SQL_FILE_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an OS-supplied path. Returns `true` when it was newly queued, so
|
||||||
|
/// callers can decide whether a nudge event is worth emitting.
|
||||||
|
pub fn accept_path(buffer: &PendingOpenFiles, raw: &str) -> bool {
|
||||||
|
let Some(path) = validate_sql_path(raw) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut queue = buffer.0.lock().unwrap();
|
||||||
|
if queue.iter().any(|p| p == &path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
queue.push(path);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and remove everything queued. Separated from the command so it is
|
||||||
|
/// testable without a Tauri `State`.
|
||||||
|
pub fn take_from_buffer(buffer: &PendingOpenFiles) -> Result<Vec<PendingSqlFile>, String> {
|
||||||
|
let drained: Vec<PathBuf> = {
|
||||||
|
let mut queue = buffer.0.lock().unwrap();
|
||||||
|
std::mem::take(&mut *queue)
|
||||||
|
};
|
||||||
|
|
||||||
|
drained
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||||
|
// Fail closed on undecodable bytes rather than guessing an
|
||||||
|
// encoding for the user's SQL.
|
||||||
|
let bytes =
|
||||||
|
std::fs::read(&path).map_err(|e| format!("Cannot read {}: {e}", name))?;
|
||||||
|
let content =
|
||||||
|
String::from_utf8(bytes).map_err(|_| format!("{name} is not valid UTF-8"))?;
|
||||||
|
Ok(PendingSqlFile {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain every queued open request. The only file-reading command the webview
|
||||||
|
/// can call — deliberately argument-free (see the module docs).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn take_pending_sql_files(
|
||||||
|
state: State<'_, PendingOpenFiles>,
|
||||||
|
) -> Result<Vec<PendingSqlFile>, String> {
|
||||||
|
take_from_buffer(&state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "open_files.test.rs"]
|
||||||
|
mod open_files_tests;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tmp_sql(name: &str, repeats: usize) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("gridline-open-{name}"));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("sample.sql");
|
||||||
|
std::fs::write(&file, "SELECT 1;".repeat(repeats.max(1))).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_relative_path() {
|
||||||
|
assert!(validate_sql_path_with_cap("student_db.sql", 1024).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_non_sql_extension() {
|
||||||
|
let f = tmp_sql("ext", 1);
|
||||||
|
let renamed = f.with_extension("txt");
|
||||||
|
std::fs::rename(&f, &renamed).unwrap();
|
||||||
|
assert!(validate_sql_path_with_cap(&renamed.to_string_lossy(), 1024).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_directory() {
|
||||||
|
let dir = std::env::temp_dir().join("gridline-open-dir");
|
||||||
|
std::fs::create_dir_all(dir.join("not-a-file.sql")).unwrap();
|
||||||
|
assert!(
|
||||||
|
validate_sql_path_with_cap(&dir.join("not-a-file.sql").to_string_lossy(), 1024).is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_missing_file() {
|
||||||
|
let p = std::env::temp_dir().join("gridline-definitely-missing-xyz.sql");
|
||||||
|
assert!(validate_sql_path_with_cap(&p.to_string_lossy(), 1024).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_file_over_the_cap() {
|
||||||
|
let f = tmp_sql("big", 200);
|
||||||
|
assert!(validate_sql_path_with_cap(&f.to_string_lossy(), 16).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_symlink() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let f = tmp_sql("sym", 1);
|
||||||
|
let link = f.with_file_name("link.sql");
|
||||||
|
let _ = std::fs::remove_file(&link);
|
||||||
|
std::os::unix::fs::symlink(&f, &link).unwrap();
|
||||||
|
assert!(
|
||||||
|
validate_sql_path_with_cap(&link.to_string_lossy(), 1024).is_none(),
|
||||||
|
"a symlink must not be accepted (spec §6)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_valid_sql_file_with_an_uppercase_extension() {
|
||||||
|
let f = tmp_sql("ok", 1);
|
||||||
|
let upper = f.with_file_name("SAMPLE.SQL");
|
||||||
|
std::fs::rename(&f, &upper).unwrap();
|
||||||
|
assert!(validate_sql_path_with_cap(&upper.to_string_lossy(), 1024).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accept_path_dedupes_the_same_path() {
|
||||||
|
let buffer = PendingOpenFiles::default();
|
||||||
|
let f = tmp_sql("dedupe", 1);
|
||||||
|
let s = f.to_string_lossy().to_string();
|
||||||
|
assert!(accept_path(&buffer, &s));
|
||||||
|
assert!(!accept_path(&buffer, &s), "second accept must be deduped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accept_path_ignores_an_invalid_path() {
|
||||||
|
let buffer = PendingOpenFiles::default();
|
||||||
|
assert!(!accept_path(&buffer, "relative.sql"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_returns_contents_then_drains() {
|
||||||
|
let buffer = PendingOpenFiles::default();
|
||||||
|
let f = tmp_sql("drain", 1);
|
||||||
|
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||||
|
|
||||||
|
let first = take_from_buffer(&buffer).expect("first drain");
|
||||||
|
assert_eq!(first.len(), 1);
|
||||||
|
assert_eq!(first[0].name, "sample.sql");
|
||||||
|
assert_eq!(first[0].content, "SELECT 1;");
|
||||||
|
|
||||||
|
let second = take_from_buffer(&buffer).expect("second drain");
|
||||||
|
assert!(second.is_empty(), "buffer must be drained, not reread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_non_utf8_file() {
|
||||||
|
let dir = std::env::temp_dir().join("gridline-open-binary");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let f = dir.join("binary.sql");
|
||||||
|
std::fs::write(&f, [0xff, 0xfe, 0x00]).unwrap();
|
||||||
|
let buffer = PendingOpenFiles::default();
|
||||||
|
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||||
|
assert!(take_from_buffer(&buffer).is_err(), "non-UTF8 must fail closed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── statement_kind / affected_from_tag (pure) ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statement_kind_classifies_by_first_keyword() {
|
||||||
|
assert_eq!(statement_kind("SELECT 1"), "select");
|
||||||
|
assert_eq!(statement_kind(" with c as (select 1) select * from c"), "select");
|
||||||
|
assert_eq!(statement_kind("insert into t values (1)"), "dml");
|
||||||
|
assert_eq!(statement_kind("UPDATE t SET a=1"), "dml");
|
||||||
|
assert_eq!(statement_kind("DELETE FROM t"), "dml");
|
||||||
|
assert_eq!(statement_kind("CREATE TABLE t (a int)"), "ddl");
|
||||||
|
assert_eq!(statement_kind("-- c\nDROP TABLE t"), "ddl");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affected_from_tag_parses_pg_command_tags() {
|
||||||
|
assert_eq!(affected_from_tag("INSERT 0 5"), Some(5));
|
||||||
|
assert_eq!(affected_from_tag("UPDATE 3"), Some(3));
|
||||||
|
assert_eq!(affected_from_tag("CREATE TABLE"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite walker — real in-memory database, no env vars, not ignored ──
|
||||||
|
|
||||||
|
fn mem_conn() -> rusqlite::Connection {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO t VALUES (1, 'x'), (2, 'y'), (3, 'z');",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_yields_notice_for_dml_with_affected() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "INSERT INTO t VALUES (4, 'w')", 1, 50)
|
||||||
|
.expect("statement runs");
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Notice { kind, affected, .. } => {
|
||||||
|
assert_eq!(kind, "dml");
|
||||||
|
assert_eq!(affected, Some(1));
|
||||||
|
}
|
||||||
|
_ => panic!("expected Notice, got Set"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_yields_set_for_select() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "SELECT a FROM t ORDER BY a", 1, 2)
|
||||||
|
.expect("statement runs");
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Set(qr) => {
|
||||||
|
assert_eq!(qr.columns.len(), 1);
|
||||||
|
assert_eq!(qr.rows.len(), 2, "page size caps rows");
|
||||||
|
assert_eq!(qr.total_rows, 3, "total counts all rows");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Set, got Notice"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_notices_ddl() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "CREATE TABLE u (x INT)", 1, 50).unwrap();
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Notice { kind, .. } => assert_eq!(kind, "ddl"),
|
||||||
|
_ => panic!("expected Notice"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_sqlite_statements_stops_at_first_error_with_prior_sets() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let statements = crate::db::sql_split::split_statements(
|
||||||
|
"SELECT a FROM t; SELECT * FROM missing; SELECT 1",
|
||||||
|
);
|
||||||
|
let mut sets = Vec::new();
|
||||||
|
let mut notices = Vec::new();
|
||||||
|
let err = run_sqlite_statements(&conn, &statements, 1, 50, &mut sets, &mut notices);
|
||||||
|
assert_eq!(sets.len(), 1, "first set is returned");
|
||||||
|
assert!(err.is_some(), "the failed statement stops the run");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live PG walker test — mirror the env/client conventions of the live PG
|
||||||
|
/// tests in `commands/objects.test.rs`. Assert: `SELECT 1; SELECT 2` (split
|
||||||
|
/// via sql_split) produces two Sets via `run_pg_statements`, and
|
||||||
|
/// `UPDATE`-on-missing-table produces a first-error stop with the error
|
||||||
|
/// notice attached.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn pg_multi_statement_live() {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two SELECTs → two Sets, no notices.
|
||||||
|
let stmts = crate::db::sql_split::split_statements("SELECT 1; SELECT 2");
|
||||||
|
let mut sets = vec![];
|
||||||
|
let mut notices = vec![];
|
||||||
|
let err = run_pg_statements(&client, &stmts, 1, 50, &mut sets, &mut notices).await;
|
||||||
|
assert!(err.is_none());
|
||||||
|
assert_eq!(sets.len(), 2);
|
||||||
|
assert!(notices.is_empty());
|
||||||
|
|
||||||
|
// UPDATE on a missing table → first-error stop with an error notice.
|
||||||
|
let bad = crate::db::sql_split::split_statements("UPDATE gridline_missing SET a=1");
|
||||||
|
let mut sets2 = vec![];
|
||||||
|
let mut notices2 = vec![];
|
||||||
|
let err2 = run_pg_statements(&client, &bad, 1, 50, &mut sets2, &mut notices2).await;
|
||||||
|
assert!(err2.is_some(), "missing table must stop the run");
|
||||||
|
assert!(
|
||||||
|
notices2.iter().any(|n| n.kind == "error"),
|
||||||
|
"an error notice must be attached"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sqlx type-feature coverage ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Connect to the optional live MySQL test server. Returns `None` when the
|
||||||
|
/// env vars are absent so the whole suite stays green without a server
|
||||||
|
/// (matching the existing `#[ignore]`d integration-test convention).
|
||||||
|
pub(crate) async fn mysql_test_connection() -> Option<sqlx::mysql::MySqlConnection> {
|
||||||
|
use sqlx::ConnectOptions;
|
||||||
|
let host = std::env::var("GRIDLINE_TEST_MYSQL_HOST").ok()?;
|
||||||
|
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||||
|
.ok()?
|
||||||
|
.parse()
|
||||||
|
.ok()?;
|
||||||
|
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||||
|
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||||
|
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").ok()?;
|
||||||
|
sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(&host)
|
||||||
|
.port(port)
|
||||||
|
.username(&user)
|
||||||
|
.password(&pass)
|
||||||
|
.database(&db)
|
||||||
|
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||||
|
.disable_statement_logging()
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn mysql_decimal_and_date_decode_with_features_enabled() {
|
||||||
|
use sqlx::Row;
|
||||||
|
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TEMPORARY TABLE gl_feature_probe (
|
||||||
|
c_decimal DECIMAL(20,4), c_date DATE, c_datetime DATETIME(6) )",
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("create temporary table");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO gl_feature_probe VALUES (
|
||||||
|
12345678901234.5678, '2024-01-15', '2024-01-15 10:30:00.123456' )",
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT * FROM gl_feature_probe")
|
||||||
|
.fetch_one(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("select");
|
||||||
|
|
||||||
|
let dec: sqlx::types::BigDecimal = row.try_get(0).expect("DECIMAL must decode");
|
||||||
|
assert_eq!(dec.to_string(), "12345678901234.5678");
|
||||||
|
|
||||||
|
let date: chrono::NaiveDate = row.try_get(1).expect("DATE must decode");
|
||||||
|
assert_eq!(date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
|
||||||
|
|
||||||
|
let dt: chrono::NaiveDateTime = row.try_get(2).expect("DATETIME(6) must decode");
|
||||||
|
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mysql_cell_kind (pure) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_cell_kind_maps_every_type_name_sqlx_can_report() {
|
||||||
|
use MysqlCellKind::*;
|
||||||
|
// Every string here was observed from a live MySQL 8 server during the
|
||||||
|
// architect phase (ColumnType::name(flags, max_size)).
|
||||||
|
let cases: &[(&str, MysqlCellKind)] = &[
|
||||||
|
("BOOLEAN", Int { bits: 8, signed: true }),
|
||||||
|
("TINYINT", Int { bits: 8, signed: true }),
|
||||||
|
("TINYINT UNSIGNED", Int { bits: 8, signed: false }),
|
||||||
|
("SMALLINT", Int { bits: 16, signed: true }),
|
||||||
|
("SMALLINT UNSIGNED", Int { bits: 16, signed: false }),
|
||||||
|
("MEDIUMINT", Int { bits: 24, signed: true }),
|
||||||
|
("MEDIUMINT UNSIGNED", Int { bits: 24, signed: false }),
|
||||||
|
("INT", Int { bits: 32, signed: true }),
|
||||||
|
("INT UNSIGNED", Int { bits: 32, signed: false }),
|
||||||
|
("BIGINT", Int { bits: 64, signed: true }),
|
||||||
|
("BIGINT UNSIGNED", Int { bits: 64, signed: false }),
|
||||||
|
("YEAR", Int { bits: 16, signed: false }),
|
||||||
|
("BIT", Bit),
|
||||||
|
("FLOAT", Float),
|
||||||
|
("DOUBLE", Double),
|
||||||
|
("DECIMAL", Decimal),
|
||||||
|
("DATE", Date),
|
||||||
|
("TIME", Time),
|
||||||
|
("DATETIME", DateTime),
|
||||||
|
("TIMESTAMP", DateTime),
|
||||||
|
("JSON", Json),
|
||||||
|
("CHAR", Text),
|
||||||
|
("VARCHAR", Text),
|
||||||
|
("TEXT", Text),
|
||||||
|
("TINYTEXT", Text),
|
||||||
|
("MEDIUMTEXT", Text),
|
||||||
|
("LONGTEXT", Text),
|
||||||
|
("ENUM", Text),
|
||||||
|
("SET", Text),
|
||||||
|
("BINARY", Bytes),
|
||||||
|
("VARBINARY", Bytes),
|
||||||
|
("BLOB", Bytes),
|
||||||
|
("TINYBLOB", Bytes),
|
||||||
|
("MEDIUMBLOB", Bytes),
|
||||||
|
("LONGBLOB", Bytes),
|
||||||
|
("NULL", Null),
|
||||||
|
("GEOMETRY", Unknown),
|
||||||
|
("SOMETHING_NEW", Unknown),
|
||||||
|
];
|
||||||
|
for (name, expected) in cases {
|
||||||
|
assert_eq!(&mysql_cell_kind(name), expected, "type name {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_cell_kind_is_case_insensitive_and_trims() {
|
||||||
|
assert_eq!(mysql_cell_kind(" bigint unsigned "), MysqlCellKind::Int { bits: 64, signed: false });
|
||||||
|
assert_eq!(mysql_cell_kind("Datetime"), MysqlCellKind::DateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mysql_cell_to_json: full type coverage (regression for #41) ─────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn mysql_cell_to_json_decodes_every_type() {
|
||||||
|
use sqlx::Row;
|
||||||
|
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TEMPORARY TABLE gl_decode_probe (
|
||||||
|
c_tiny TINYINT, c_tiny_u TINYINT UNSIGNED, c_bool BOOLEAN,
|
||||||
|
c_small SMALLINT, c_small_u SMALLINT UNSIGNED, c_medium MEDIUMINT,
|
||||||
|
c_int INT, c_int_u INT UNSIGNED, c_big BIGINT, c_big_u BIGINT UNSIGNED,
|
||||||
|
c_float FLOAT, c_double DOUBLE, c_decimal DECIMAL(10,2), c_bit BIT(8),
|
||||||
|
c_year YEAR, c_date DATE, c_time TIME, c_datetime DATETIME,
|
||||||
|
c_datetime6 DATETIME(6), c_timestamp TIMESTAMP NULL,
|
||||||
|
c_char CHAR(4), c_varchar VARCHAR(20), c_text TEXT,
|
||||||
|
c_binary BINARY(4), c_varbinary VARBINARY(8), c_blob BLOB,
|
||||||
|
c_json JSON, c_enum ENUM('a','b'), c_set SET('x','y'),
|
||||||
|
c_geometry GEOMETRY )",
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("create temporary table");
|
||||||
|
|
||||||
|
// c_int is 70000 so a 16-bit rung cannot hold it; c_big is small so a
|
||||||
|
// value-dependent chain would wrongly emit a number instead of a string.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO gl_decode_probe VALUES (
|
||||||
|
1, 2, 1, 5, 6, 7, 70000, 9, 300, 4294967296,
|
||||||
|
11.5, 12.25, 13.75, b'10101010', 2024,
|
||||||
|
'2024-01-15', '10:30:00', '2024-01-15 10:30:00',
|
||||||
|
'2024-01-15 10:30:00.123456', '2024-01-15 10:30:00',
|
||||||
|
'abcd', 'hello', 'text value', 'WXYZ', 0x0102, 0x01020304,
|
||||||
|
JSON_OBJECT('k','v'), 'a', 'x,y', ST_GeomFromText('POINT(1 2)') )",
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT * FROM gl_decode_probe")
|
||||||
|
.fetch_one(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("select");
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
let expected: Vec<(&str, Value)> = vec![
|
||||||
|
("c_tiny", json!(1)),
|
||||||
|
("c_tiny_u", json!(2)),
|
||||||
|
("c_bool", json!(1)),
|
||||||
|
("c_small", json!(5)),
|
||||||
|
("c_small_u", json!(6)),
|
||||||
|
("c_medium", json!(7)),
|
||||||
|
("c_int", json!(70000)),
|
||||||
|
("c_int_u", json!(9)),
|
||||||
|
("c_big", Value::String("300".into())),
|
||||||
|
("c_big_u", Value::String("4294967296".into())),
|
||||||
|
("c_float", json!(11.5)),
|
||||||
|
("c_double", json!(12.25)),
|
||||||
|
("c_decimal", Value::String("13.75".into())),
|
||||||
|
("c_bit", json!(170)),
|
||||||
|
("c_year", json!(2024)),
|
||||||
|
("c_date", Value::String("2024-01-15".into())),
|
||||||
|
("c_time", Value::String("10:30:00".into())),
|
||||||
|
("c_datetime", Value::String("2024-01-15 10:30:00".into())),
|
||||||
|
("c_datetime6", Value::String("2024-01-15 10:30:00.123456".into())),
|
||||||
|
("c_timestamp", Value::String("2024-01-15 10:30:00".into())),
|
||||||
|
("c_char", Value::String("abcd".into())),
|
||||||
|
("c_varchar", Value::String("hello".into())),
|
||||||
|
("c_text", Value::String("text value".into())),
|
||||||
|
("c_binary", Value::String("WXYZ".into())),
|
||||||
|
("c_varbinary", Value::String("\u{1}\u{2}".into())),
|
||||||
|
("c_blob", Value::String("\u{1}\u{2}\u{3}\u{4}".into())),
|
||||||
|
("c_json", json!({"k": "v"})),
|
||||||
|
("c_enum", Value::String("a".into())),
|
||||||
|
("c_set", Value::String("x,y".into())),
|
||||||
|
// GEOMETRY has no sqlx decoder even with chrono/bigdecimal enabled;
|
||||||
|
// staying null is the documented limitation (spec §9).
|
||||||
|
("c_geometry", Value::Null),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(row.len(), expected.len(), "column count drifted from the fixture");
|
||||||
|
for (i, (name, want)) in expected.iter().enumerate() {
|
||||||
|
assert_eq!(row.columns()[i].name(), *name, "column order drifted");
|
||||||
|
assert_eq!(&mysql_cell_to_json(&row, i), want, "column {name} decoded wrong");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── statement-count policy ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statement_budget_allows_a_count_at_the_cap() {
|
||||||
|
assert!(check_statement_budget(MAX_STATEMENTS, MAX_STATEMENTS, false).is_ok());
|
||||||
|
assert!(check_statement_budget(MAX_RESTORE_STATEMENTS, MAX_RESTORE_STATEMENTS, true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interactive_over_budget_error_points_at_restore() {
|
||||||
|
let err = check_statement_budget(MAX_STATEMENTS + 1, MAX_STATEMENTS, false)
|
||||||
|
.expect_err("must reject");
|
||||||
|
assert!(err.contains("Tools"), "error must route to Tools → Restore; got {err:?}");
|
||||||
|
assert!(err.contains(&(MAX_STATEMENTS + 1).to_string()), "error must state the count; got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_over_budget_error_states_the_limit_without_the_tool_hint() {
|
||||||
|
let err = check_statement_budget(MAX_RESTORE_STATEMENTS + 1, MAX_RESTORE_STATEMENTS, true)
|
||||||
|
.expect_err("must reject");
|
||||||
|
assert!(!err.contains("Tools"), "bulk path is already in Tools; got {err:?}");
|
||||||
|
assert!(err.contains(&MAX_RESTORE_STATEMENTS.to_string()), "got {err:?}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
//! Streaming export of a full query result to a local file (spec §4/§5C).
|
||||||
|
//!
|
||||||
|
//! Headless `run_*_export` functions (testable without Tauri State) + one
|
||||||
|
//! thin command that owns the pool lock, the cancel registration, and the
|
||||||
|
//! `export-progress` events. Fail-closed: any error deletes the partial file.
|
||||||
|
|
||||||
|
use crate::db::pool::DbHandle;
|
||||||
|
use crate::models::db_viewer::ColumnInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Column, Row};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const PROGRESS_ROWS: u64 = 5_000;
|
||||||
|
const CURSOR_NAME: &str = "_gridline_export";
|
||||||
|
const CURSOR_FETCH: usize = 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ExportFormat {
|
||||||
|
Csv,
|
||||||
|
Jsonl,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportFormat {
|
||||||
|
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"csv" => Ok(ExportFormat::Csv),
|
||||||
|
"jsonl" => Ok(ExportFormat::Jsonl),
|
||||||
|
"json" => Ok(ExportFormat::Json),
|
||||||
|
other => Err(format!("unsupported export format: {other} (csv | jsonl | json)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ExportSummary {
|
||||||
|
pub rows_written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct ExportProgressEvent {
|
||||||
|
pub job_id: String,
|
||||||
|
/// "running" | "done" | "error"
|
||||||
|
pub status: String,
|
||||||
|
pub rows: u64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only heuristic mirroring the frontend `isDestructiveQuery` set (spec §2:
|
||||||
|
/// never trust the frontend guard alone).
|
||||||
|
pub fn is_read_only_sql(sql: &str) -> bool {
|
||||||
|
let k: String = sql
|
||||||
|
.trim_start()
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_alphabetic())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_uppercase();
|
||||||
|
!matches!(
|
||||||
|
k.as_str(),
|
||||||
|
"INSERT" | "UPDATE" | "DELETE" | "DROP" | "ALTER" | "TRUNCATE" | "CREATE" | "REPLACE"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate export inputs: single, read-only statement + absolute path.
|
||||||
|
fn validate_export_inputs(sql: &str, path: &Path) -> Result<(), String> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Export path must be absolute".to_string());
|
||||||
|
}
|
||||||
|
if !is_read_only_sql(sql) {
|
||||||
|
return Err("Export accepts read-only queries only (INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE are rejected)".to_string());
|
||||||
|
}
|
||||||
|
let statements = crate::db::sql_split::split_statements(sql);
|
||||||
|
if statements.len() != 1 {
|
||||||
|
return Err("Export accepts a single query".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format writers (pure) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Quote one CSV cell with formula-injection protection (spec §2) — mirrors
|
||||||
|
/// the client-side `csvCell` brought to parity in Task 11.
|
||||||
|
pub fn csv_cell(value: &serde_json::Value) -> String {
|
||||||
|
let raw = match value {
|
||||||
|
serde_json::Value::Null => String::new(),
|
||||||
|
serde_json::Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
let guarded = match raw.chars().next() {
|
||||||
|
Some('=') | Some('+') | Some('-') | Some('@') | Some('\t') | Some('\r') => {
|
||||||
|
format!("'{raw}")
|
||||||
|
}
|
||||||
|
_ => raw.clone(),
|
||||||
|
};
|
||||||
|
if guarded.contains(',') || guarded.contains('"') || guarded.contains('\n') || guarded.contains('\r') {
|
||||||
|
format!("\"{}\"", guarded.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
guarded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csv_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||||
|
let mut line = String::new();
|
||||||
|
for (i, _) in columns.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
line.push(',');
|
||||||
|
}
|
||||||
|
line.push_str(&csv_cell(row.get(i).unwrap_or(&serde_json::Value::Null)));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csv_header(columns: &[ColumnInfo]) -> String {
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| csv_cell(&serde_json::Value::String(c.name.clone())))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jsonl_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
for (i, c) in columns.iter().enumerate() {
|
||||||
|
obj.insert(c.name.clone(), row.get(i).cloned().unwrap_or(serde_json::Value::Null));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_row<W: Write>(
|
||||||
|
w: &mut W,
|
||||||
|
format: ExportFormat,
|
||||||
|
columns: &[ColumnInfo],
|
||||||
|
row: &[serde_json::Value],
|
||||||
|
first: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
match format {
|
||||||
|
ExportFormat::Csv => writeln!(w, "{}", csv_line(columns, row)),
|
||||||
|
ExportFormat::Jsonl => writeln!(w, "{}", jsonl_line(columns, row)),
|
||||||
|
ExportFormat::Json => {
|
||||||
|
if !first {
|
||||||
|
writeln!(w, ",")?;
|
||||||
|
}
|
||||||
|
write!(w, "{}", jsonl_line(columns, row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the export, deleting the partial file on any failure (fail-closed).
|
||||||
|
fn with_partial_cleanup<W, F>(path: &PathBuf, w: W, f: F) -> Result<ExportSummary, String>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
F: FnOnce(&mut std::io::BufWriter<W>) -> Result<ExportSummary, String>,
|
||||||
|
{
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, w);
|
||||||
|
match f(&mut writer) {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite (native streaming) ─────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn run_sqlite_export(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
|
||||||
|
with_partial_cleanup(path, file, |writer| {
|
||||||
|
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
|
||||||
|
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
|
||||||
|
.map(|i| ColumnInfo {
|
||||||
|
name: stmt.column_name(i).unwrap_or("?").to_string(),
|
||||||
|
data_type: "TEXT".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if format == ExportFormat::Csv {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let col_count = columns.len();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||||
|
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||||
|
let values: Vec<serde_json::Value> = (0..col_count)
|
||||||
|
.map(|i| sqlite_ref_to_json(row, i))
|
||||||
|
.collect();
|
||||||
|
write_row(writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_ref_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
|
||||||
|
use rusqlite::types::ValueRef;
|
||||||
|
match row.get_ref(i) {
|
||||||
|
Ok(ValueRef::Null) => serde_json::Value::Null,
|
||||||
|
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
|
||||||
|
Ok(ValueRef::Real(v)) => serde_json::json!(v),
|
||||||
|
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
|
||||||
|
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
|
||||||
|
Err(_) => serde_json::Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgreSQL (cursor FETCH loop — constant memory, stable order) ──
|
||||||
|
|
||||||
|
pub async fn run_pg_export(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
let trimmed = sql.trim();
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||||
|
|
||||||
|
// LINEAR FORM: run the cursor loop inline; on any error drop the writer
|
||||||
|
// and remove the partial file.
|
||||||
|
let run = async {
|
||||||
|
client.simple_query("BEGIN").await.map_err(|e| format!("export begin failed: {e}"))?;
|
||||||
|
if let Err(e) = client
|
||||||
|
.simple_query(&format!("DECLARE {CURSOR_NAME} CURSOR FOR ({trimmed})"))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
return Err(format!("export declare failed: {e}"));
|
||||||
|
}
|
||||||
|
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let msgs = client
|
||||||
|
.simple_query(&format!("FETCH FORWARD {CURSOR_FETCH} FROM {CURSOR_NAME}"))
|
||||||
|
.await;
|
||||||
|
let msgs = match msgs {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
return Err(format!("export fetch failed: {e}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut batch_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
|
for msg in msgs {
|
||||||
|
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
|
||||||
|
if columns.is_empty() {
|
||||||
|
columns = row
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: "text".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
// Simple-query protocol returns every value as text; decode
|
||||||
|
// as &str (usize has no FromSql impl) and NULL as None.
|
||||||
|
let values: Vec<serde_json::Value> = (0..row.len())
|
||||||
|
.map(|i| match row.try_get(i) {
|
||||||
|
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
|
||||||
|
_ => serde_json::Value::Null,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
batch_rows.push(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch_rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Csv && rows_written == 0 {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
for row in batch_rows {
|
||||||
|
write_row(&mut writer, format, &columns, &row, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let _ = client.simple_query(&format!("CLOSE {CURSOR_NAME}")).await;
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match run {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(writer);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MySQL (sqlx stream — native streaming) ─────────────────────────
|
||||||
|
|
||||||
|
pub async fn run_mysql_export(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||||
|
|
||||||
|
let run = async {
|
||||||
|
let mut stream = sqlx::query(sql).fetch(&mut *conn);
|
||||||
|
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
while let Some(row) = stream.try_next().await.map_err(|e| {
|
||||||
|
crate::commands::db_viewer::sanitize_error(&format!("{e}"))
|
||||||
|
})? {
|
||||||
|
if columns.is_empty() {
|
||||||
|
columns = row
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: c.type_info().to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if format == ExportFormat::Csv {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let values: Vec<serde_json::Value> =
|
||||||
|
(0..row.len()).map(|i| crate::commands::query::mysql_cell_to_json(&row, i)).collect();
|
||||||
|
write_row(&mut writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match run {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(writer);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatcher + thin command ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Dispatch across engines. The caller (command) owns the pool lock and the
|
||||||
|
/// cancel registration; MySQL callers must hold a dedicated connection.
|
||||||
|
pub(crate) async fn run_export_query(
|
||||||
|
handle: &mut DbHandle,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
match handle {
|
||||||
|
DbHandle::Sqlite(conn) => run_sqlite_export(conn, sql, format, path, progress),
|
||||||
|
DbHandle::Postgresql(client, _) => run_pg_export(client, sql, format, path, progress).await,
|
||||||
|
DbHandle::MySql(pool) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
run_mysql_export(&mut *conn, sql, format, path, progress).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_query_to_file(
|
||||||
|
connection_id: String,
|
||||||
|
sql: String,
|
||||||
|
format: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
let fmt = ExportFormat::from_str(&format)?;
|
||||||
|
let path_buf = PathBuf::from(&path);
|
||||||
|
let job_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let mut last_emit = std::time::Instant::now();
|
||||||
|
let job = job_id.clone();
|
||||||
|
let mut progress_cb = |rows: u64| {
|
||||||
|
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
|
||||||
|
last_emit = std::time::Instant::now();
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job.clone(),
|
||||||
|
status: "running".into(),
|
||||||
|
rows,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
let handle = pm
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or_else(|| "Connection not found".to_string())?;
|
||||||
|
|
||||||
|
// MySQL: the STREAMING connection must be the one registered for
|
||||||
|
// KILL QUERY cancellation — acquire it once here, register its id,
|
||||||
|
// stream through it, then clear the registration.
|
||||||
|
let result = match handle {
|
||||||
|
DbHandle::MySql(pool) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(-1);
|
||||||
|
state.cancel_registry.set_mysql_conn_id(&connection_id, Some(conn_id));
|
||||||
|
let r = run_mysql_export(&mut *conn, &sql, fmt, &path_buf, &mut progress_cb).await;
|
||||||
|
state.cancel_registry.set_mysql_conn_id(&connection_id, None);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
other => run_export_query(other, &sql, fmt, &path_buf, &mut progress_cb).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(summary) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job_id.clone(),
|
||||||
|
status: "done".into(),
|
||||||
|
rows: summary.rows_written,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(serde_json::json!({ "rows_written": summary.rows_written, "path": path }))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job_id.clone(),
|
||||||
|
status: "error".into(),
|
||||||
|
rows: 0,
|
||||||
|
error: Some(e.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "query_export.test.rs"]
|
||||||
|
mod query_export_tests;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
use super::*;
|
||||||
|
use sqlx::Connection;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_heuristic_matches_frontend_guard_set() {
|
||||||
|
for sql in ["SELECT 1", " with x as (select 1) select * from x", "-- note\nSELECT 1"] {
|
||||||
|
assert!(is_read_only_sql(sql), "should allow: {sql}");
|
||||||
|
}
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO t VALUES (1)",
|
||||||
|
"update t set a = 1",
|
||||||
|
"DELETE FROM t",
|
||||||
|
"DROP TABLE t",
|
||||||
|
"ALTER TABLE t ADD c int",
|
||||||
|
"TRUNCATE t",
|
||||||
|
"CREATE TABLE t (a int)",
|
||||||
|
"REPLACE INTO t VALUES (1)",
|
||||||
|
] {
|
||||||
|
assert!(!is_read_only_sql(sql), "should reject: {sql}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_cell_quotes_and_guards_formula_injection() {
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("plain".into())), "plain");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a,b".into())), "\"a,b\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a\"b".into())), "\"a\"\"b\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a\nb".into())), "\"a\nb\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("=SUM(A1)".into())), "'=SUM(A1)");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("+x".into())), "'+x");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("-1".into())), "'-1");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("@cmd".into())), "'@cmd");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::Null), "");
|
||||||
|
assert_eq!(csv_cell(&serde_json::json!(42)), "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_line_bom_is_written_by_header_only() {
|
||||||
|
let cols = vec![
|
||||||
|
crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||||
|
crate::models::db_viewer::ColumnInfo { name: "b".into(), data_type: "text".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||||
|
];
|
||||||
|
let line = csv_line(&cols, &[serde_json::json!("1"), serde_json::Value::Null]);
|
||||||
|
assert_eq!(line, "1,");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonl_line_encodes_row_object() {
|
||||||
|
let cols = vec![crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false }];
|
||||||
|
let line = jsonl_line(&cols, &[serde_json::json!("x\"y")]);
|
||||||
|
assert_eq!(line, "{\"a\":\"x\\\"y\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_streams_all_rows_with_bom_and_guard() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO t VALUES (1, 'plain');
|
||||||
|
INSERT INTO t VALUES (2, '=SUM(A1)');
|
||||||
|
INSERT INTO t VALUES (3, NULL);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-test-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_sqlite_export(&conn, "SELECT a, b FROM t ORDER BY a", ExportFormat::Csv, &path, &mut |n| rows_seen = n)
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_rejects_write_statements_and_multi_statement() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
let path = std::env::temp_dir().join("gridline-export-should-not-exist.csv");
|
||||||
|
let err = run_sqlite_export(&conn, "INSERT INTO t VALUES (1)", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("write SQL must be rejected");
|
||||||
|
assert!(err.contains("read-only"), "got: {err}");
|
||||||
|
let err = run_sqlite_export(&conn, "SELECT 1; SELECT 2", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("multi-statement must be rejected");
|
||||||
|
assert!(err.contains("single"), "got: {err}");
|
||||||
|
assert!(!path.exists(), "no partial file may be left behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_error_deletes_partial_file() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch("CREATE TABLE t (a INTEGER); INSERT INTO t VALUES (1),(2),(3);").unwrap();
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-partial-{}.csv", std::process::id()));
|
||||||
|
let err = run_sqlite_export(&conn, "SELECT a, b FROM t", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("bad column must fail");
|
||||||
|
assert!(!err.is_empty());
|
||||||
|
assert!(!path.exists(), "partial file must be deleted on error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live PG export — mirror the env/client conventions of the live PG tests in
|
||||||
|
/// `commands/objects.test.rs`. Assert a cursor export of a 3-row table
|
||||||
|
/// produces the same 4 CSV lines as the SQLite test (BOM + header + rows).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn pg_export_live() {
|
||||||
|
let h = match std::env::var("GRIDLINE_TEST_PG_HOST") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let _conn_task = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
client
|
||||||
|
.batch_execute(
|
||||||
|
"DROP TABLE IF EXISTS gridline_export_test;
|
||||||
|
CREATE TABLE gridline_export_test (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL);",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("setup");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-pg-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_pg_export(
|
||||||
|
&client,
|
||||||
|
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||||
|
ExportFormat::Csv,
|
||||||
|
&path,
|
||||||
|
&mut |n| rows_seen = n,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = client.batch_execute("DROP TABLE IF EXISTS gridline_export_test").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live MySQL export — mirror the env conventions of the MySQL live tests in
|
||||||
|
/// `commands/db_viewer.rs` (GRIDLINE_TEST_MYSQL_*). Same assertions.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn mysql_export_live() {
|
||||||
|
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(3306);
|
||||||
|
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||||
|
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||||
|
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(&host)
|
||||||
|
.port(port)
|
||||||
|
.username(&user)
|
||||||
|
.password(&pass)
|
||||||
|
.database(&db);
|
||||||
|
let mut conn = sqlx::mysql::MySqlConnection::connect_with(&opts)
|
||||||
|
.await
|
||||||
|
.expect("connect to test MySQL");
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
sqlx::query("CREATE TABLE gridline_export_test (a INT, b TEXT)")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
sqlx::query("INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL)")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-mysql-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_mysql_export(
|
||||||
|
&mut conn,
|
||||||
|
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||||
|
ExportFormat::Csv,
|
||||||
|
&path,
|
||||||
|
&mut |n| rows_seen = n,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Thin `compare_schemas` command (spec §4: two pool lookups → snapshots →
|
||||||
|
//! diff; errors name the failing side; staging never happens here).
|
||||||
|
|
||||||
|
use crate::models::db_viewer::DiffReport;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Engine family for diff pairing: mysql and mariadb are one family.
|
||||||
|
pub fn engine_family(db_type: &str) -> String {
|
||||||
|
match db_type {
|
||||||
|
"mysql" | "mariadb" => "mysql".to_string(),
|
||||||
|
"sqlite" => "sqlite".to_string(),
|
||||||
|
"postgresql" => "postgresql".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_same_family(source_db_type: &str, target_db_type: &str) -> Result<(), String> {
|
||||||
|
let (s, t) = (engine_family(source_db_type), engine_family(target_db_type));
|
||||||
|
if s != t {
|
||||||
|
return Err(format!(
|
||||||
|
"Schema diff requires the same engine family — source is {s}, target is {t}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_db_type(
|
||||||
|
db_store: &std::sync::Mutex<crate::store::Store>,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let store = db_store.lock().map_err(|e| e.to_string())?;
|
||||||
|
let conns = store.get_connections().map_err(|e| e.to_string())?;
|
||||||
|
conns
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == connection_id)
|
||||||
|
.map(|c| c.db_type.clone())
|
||||||
|
.ok_or_else(|| format!("connection {connection_id} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compare_schemas(
|
||||||
|
source_connection_id: String,
|
||||||
|
source_schema: String,
|
||||||
|
target_connection_id: String,
|
||||||
|
target_schema: String,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<DiffReport, String> {
|
||||||
|
let source_db_type = connection_db_type(&state.db_store, &source_connection_id)
|
||||||
|
.map_err(|e| format!("Source connection failed: {e}"))?;
|
||||||
|
let target_db_type = connection_db_type(&state.db_store, &target_connection_id)
|
||||||
|
.map_err(|e| format!("Target connection failed: {e}"))?;
|
||||||
|
validate_same_family(&source_db_type, &target_db_type)?;
|
||||||
|
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
|
||||||
|
let source = {
|
||||||
|
let handle = pm
|
||||||
|
.get(&source_connection_id)
|
||||||
|
.ok_or_else(|| "Source connection failed: not connected (open it in the DB viewer first)".to_string())?;
|
||||||
|
crate::db::schema_diff::capture_snapshot(handle, &source_db_type, &source_schema)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Source connection failed: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = {
|
||||||
|
let handle = pm
|
||||||
|
.get(&target_connection_id)
|
||||||
|
.ok_or_else(|| "Target connection failed: not connected".to_string())?;
|
||||||
|
crate::db::schema_diff::capture_snapshot(handle, &target_db_type, &target_schema)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Target connection failed: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut report = crate::db::schema_diff::diff_snapshots(&source, &target);
|
||||||
|
report.source_label = format!("{} · {}", report.source_label, source_connection_id);
|
||||||
|
report.target_label = format!("{} · {}", report.target_label, target_connection_id);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "schema_diff.test.rs"]
|
||||||
|
mod schema_diff_tests;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_family_pairs_mysql_with_mariadb() {
|
||||||
|
assert_eq!(engine_family("mysql"), engine_family("mariadb"));
|
||||||
|
assert_eq!(engine_family("mysql"), "mysql");
|
||||||
|
assert_eq!(engine_family("postgresql"), "postgresql");
|
||||||
|
assert_eq!(engine_family("sqlite"), "sqlite");
|
||||||
|
assert_ne!(engine_family("postgresql"), engine_family("mysql"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn family_check_rejects_cross_engine_pairs() {
|
||||||
|
let err = validate_same_family("postgresql", "mysql").expect_err("must reject");
|
||||||
|
assert!(err.contains("same engine"), "got: {err}");
|
||||||
|
assert!(validate_same_family("mariadb", "mysql").is_ok());
|
||||||
|
assert!(validate_same_family("mysql", "mariadb").is_ok());
|
||||||
|
assert!(validate_same_family("postgresql", "postgresql").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live end-to-end: register two connections and diff their schemas through
|
||||||
|
/// the real pool path.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs the pool manager wiring from the command; run via the UI or the live harness"]
|
||||||
|
fn compare_command_live_pg() {}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use crate::models::Settings;
|
||||||
|
use crate::store::Store;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.get_settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.update_setting(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
|
||||||
|
get_settings_inner(&state.db_store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_setting(
|
||||||
|
state: tauri::State<crate::AppState>,
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
update_setting_inner(&state.db_store, &key, &value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings export / import (v0.7.8)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
|
||||||
|
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
|
||||||
|
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
|
||||||
|
/// `get_settings` filters back to `None`).
|
||||||
|
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
|
||||||
|
if let Some(f) = &s.default_folder_id {
|
||||||
|
m.insert("default_folder_id".into(), f.clone());
|
||||||
|
} else {
|
||||||
|
m.insert("default_folder_id".into(), "null".into());
|
||||||
|
}
|
||||||
|
m.insert("theme".into(), s.theme.clone());
|
||||||
|
m.insert("font_size".into(), s.font_size.clone());
|
||||||
|
m.insert("accent_color".into(), s.accent_color.clone());
|
||||||
|
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
|
||||||
|
m.insert("table_page_size".into(), s.table_page_size.to_string());
|
||||||
|
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
|
||||||
|
m.insert("editor_font_family".into(), s.editor_font_family.clone());
|
||||||
|
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
|
||||||
|
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
|
||||||
|
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
|
||||||
|
if let Some(o) = &s.tag_order {
|
||||||
|
m.insert("tag_order".into(), o.clone());
|
||||||
|
}
|
||||||
|
m.insert(
|
||||||
|
"default_ports".into(),
|
||||||
|
serde_json::to_string(&s.default_ports).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"shortcuts".into(),
|
||||||
|
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||||
|
let s = get_settings_inner(&state.db_store)?;
|
||||||
|
serde_json::to_string(&crate::models::settings::SettingsExport {
|
||||||
|
schema_version: 1,
|
||||||
|
settings: s,
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_settings(
|
||||||
|
json: String,
|
||||||
|
state: tauri::State<crate::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let env: crate::models::settings::SettingsExport =
|
||||||
|
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
|
||||||
|
let map = settings_to_kv(&env.settings);
|
||||||
|
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.apply_settings(&map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::store::Store;
|
||||||
|
|
||||||
|
fn state() -> std::sync::Mutex<Store> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
std::sync::Mutex::new(Store::from_connection(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_settings_returns_defaults() {
|
||||||
|
let st = state();
|
||||||
|
let s = get_settings_inner(&st).unwrap();
|
||||||
|
assert_eq!(s.theme, "system");
|
||||||
|
assert_eq!(s.font_size, "medium");
|
||||||
|
assert_eq!(s.accent_color, "#2563EB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_setting_persists() {
|
||||||
|
let st = state();
|
||||||
|
update_setting_inner(&st, "theme", "light").unwrap();
|
||||||
|
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_accent_color_persists() {
|
||||||
|
let st = state();
|
||||||
|
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
|
||||||
|
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_export_envelope_camel_case() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
|
||||||
|
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
|
||||||
|
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
|
||||||
|
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
|
||||||
|
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
|
||||||
|
}};
|
||||||
|
let json = serde_json::to_string(&env).unwrap();
|
||||||
|
assert!(json.contains("\"schemaVersion\":1"));
|
||||||
|
assert!(json.contains("\"settings\":"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_apply_settings_writes_all_keys() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
let store = crate::store::Store::from_connection(conn);
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
map.insert("theme".to_string(), "light".to_string());
|
||||||
|
map.insert("accent_color".to_string(), "#EF4444".to_string());
|
||||||
|
store.apply_settings(&map).unwrap();
|
||||||
|
assert_eq!(store.get_settings().unwrap().theme, "light");
|
||||||
|
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
|||||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||||
let db_type = config.db_type.to_lowercase();
|
let db_type = config.db_type.to_lowercase();
|
||||||
|
|
||||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
let valid_types = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
if !valid_types.contains(&db_type.as_str()) {
|
if !valid_types.contains(&db_type.as_str()) {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"unsupported database type: {}. Supported types: {}",
|
"unsupported database type: {}. Supported types: {}",
|
||||||
@@ -220,6 +220,7 @@ pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> Te
|
|||||||
let result = match config.db_type.to_lowercase().as_str() {
|
let result = match config.db_type.to_lowercase().as_str() {
|
||||||
"postgresql" => test_pg_connection(config, ssh).await,
|
"postgresql" => test_pg_connection(config, ssh).await,
|
||||||
"mysql" => test_mysql_connection(config, ssh).await,
|
"mysql" => test_mysql_connection(config, ssh).await,
|
||||||
|
"mariadb" => test_mysql_connection(config, ssh).await,
|
||||||
"sqlite" => test_sqlite_connection(config),
|
"sqlite" => test_sqlite_connection(config),
|
||||||
"redis" => test_redis_connection(config, ssh).await,
|
"redis" => test_redis_connection(config, ssh).await,
|
||||||
other => TestConnectionResult {
|
other => TestConnectionResult {
|
||||||
@@ -264,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
|||||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||||
target.via_tunnel,
|
target.via_tunnel,
|
||||||
);
|
);
|
||||||
let tls = match crate::db::tls::build_tls_config(
|
let tls = match crate::db::tls::build_tls_config(
|
||||||
@@ -367,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||||
target.via_tunnel,
|
target.via_tunnel,
|
||||||
);
|
);
|
||||||
match decision {
|
match decision {
|
||||||
@@ -724,3 +725,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "test_connection.test.rs"]
|
||||||
|
mod test_connection_tests;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||||
|
use crate::db::pool::DbConfig;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn mariadb_config(port: Option<i64>) -> DbConfig {
|
||||||
|
DbConfig {
|
||||||
|
db_type: "mariadb".into(),
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port,
|
||||||
|
username: Some("root".into()),
|
||||||
|
password: None,
|
||||||
|
database: Some("test".into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_test_input_accepts_mariadb_with_port() {
|
||||||
|
assert_eq!(validate_test_input(&mariadb_config(Some(3306))), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_test_input_rejects_mariadb_without_port() {
|
||||||
|
assert!(validate_test_input(&mariadb_config(None)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live test against a real MariaDB server. Run with:
|
||||||
|
/// `GRIDLINE_TEST_MARIADB_HOST=… GRIDLINE_TEST_MARIADB_PORT=3306 \
|
||||||
|
/// GRIDLINE_TEST_MARIADB_USER=… GRIDLINE_TEST_MARIADB_PASSWORD=… \
|
||||||
|
/// GRIDLINE_TEST_MARIADB_DB=… cargo test -- --ignored mariadb_live`
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn mariadb_live_test_connection_reports_mariadb_version() {
|
||||||
|
let config = DbConfig {
|
||||||
|
host: std::env::var("GRIDLINE_TEST_MARIADB_HOST").expect("GRIDLINE_TEST_MARIADB_HOST"),
|
||||||
|
port: std::env::var("GRIDLINE_TEST_MARIADB_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse::<i64>().ok()),
|
||||||
|
username: std::env::var("GRIDLINE_TEST_MARIADB_USER").ok(),
|
||||||
|
password: std::env::var("GRIDLINE_TEST_MARIADB_PASSWORD").ok(),
|
||||||
|
database: std::env::var("GRIDLINE_TEST_MARIADB_DB").ok(),
|
||||||
|
..mariadb_config(None)
|
||||||
|
};
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let ssh = std::sync::Mutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||||
|
let result = rt.block_on(async { test_mysql_connection(&config, &ssh).await });
|
||||||
|
assert!(result.ok, "connection failed: {:?}", result.error);
|
||||||
|
let version = result.server_version.unwrap_or_default().to_lowercase();
|
||||||
|
assert!(
|
||||||
|
version.contains("mariadb"),
|
||||||
|
"expected a MariaDB server, got version: {version:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -887,3 +887,123 @@ mod tests {
|
|||||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TimescaleDB (hypertables)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detect whether the timescaledb extension is installed on this database.
|
||||||
|
pub fn pg_timescale_detect_query() -> String {
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query hypertables in a schema (TimescaleDB 2.x information views).
|
||||||
|
/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes.
|
||||||
|
pub fn pg_hypertables_query(_schema: &str) -> String {
|
||||||
|
"SELECT h.hypertable_name, h.hypertable_schema, \
|
||||||
|
h.num_dimensions, \
|
||||||
|
COALESCE(h.compression_enabled, false), \
|
||||||
|
(SELECT COUNT(*) FROM timescaledb_information.chunks c \
|
||||||
|
WHERE c.hypertable_schema = h.hypertable_schema \
|
||||||
|
AND c.hypertable_name = h.hypertable_name), \
|
||||||
|
pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \
|
||||||
|
FROM timescaledb_information.hypertables h \
|
||||||
|
WHERE h.hypertable_schema = $1 \
|
||||||
|
ORDER BY h.hypertable_name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema-diff snapshot queries (0.8.0) — one set-based query per object class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Table columns with resolved types, nullability, and defaults.
|
||||||
|
/// Columns: table_name, column_name, data_type, is_nullable, default_expr
|
||||||
|
pub fn pg_diff_columns_query() -> String {
|
||||||
|
"SELECT c.relname, a.attname, \
|
||||||
|
pg_catalog.format_type(a.atttypid, a.atttypmod), \
|
||||||
|
NOT a.attnotnull, \
|
||||||
|
pg_catalog.pg_get_expr(d.adbin, d.adrelid) \
|
||||||
|
FROM pg_catalog.pg_class c \
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||||
|
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
|
||||||
|
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum \
|
||||||
|
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') \
|
||||||
|
ORDER BY c.relname, a.attnum"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constraints (PK/UNIQUE/CHECK/FK) with exact column lists and FK metadata.
|
||||||
|
/// Columns: table_name, constraint_name, contype, cols, ref_cols, ref_table, confdeltype, confupdtype, definition
|
||||||
|
pub fn pg_diff_constraints_query() -> String {
|
||||||
|
"SELECT cl.relname, con.conname, con.contype::text, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum), \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum), \
|
||||||
|
(SELECT n2.nspname || '.' || cl2.relname \
|
||||||
|
FROM pg_class cl2 JOIN pg_namespace n2 ON n2.oid = cl2.relnamespace \
|
||||||
|
WHERE cl2.oid = con.confrelid), \
|
||||||
|
con.confdeltype::text, con.confupdtype::text, \
|
||||||
|
pg_get_constraintdef(con.oid, true) \
|
||||||
|
FROM pg_catalog.pg_constraint con \
|
||||||
|
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid \
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace \
|
||||||
|
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'c', 'f') \
|
||||||
|
ORDER BY cl.relname, con.contype, con.conname"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary indexes (constraint-backed indexes excluded), with column lists.
|
||||||
|
/// Columns: table_name, index_name, is_unique, cols
|
||||||
|
pub fn pg_diff_indexes_query() -> String {
|
||||||
|
"SELECT t.relname, i.relname, ix.indisunique, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
|
||||||
|
WHERE k.attnum > 0) \
|
||||||
|
FROM pg_index ix \
|
||||||
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||||
|
WHERE n.nspname = $1 AND NOT ix.indisprimary \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) \
|
||||||
|
ORDER BY t.relname, i.relname"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views + materialized views. Columns: schema, name, definition, materialized
|
||||||
|
pub fn pg_diff_views_query() -> String {
|
||||||
|
"SELECT v.schemaname, v.viewname, v.definition, false \
|
||||||
|
FROM pg_views v WHERE v.schemaname = $1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT v.schemaname, v.matviewname, v.definition, true \
|
||||||
|
FROM pg_matviews v WHERE v.schemaname = $1 \
|
||||||
|
ORDER BY 2"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequences. Columns: schema, name, start, increment, min, max, cycle
|
||||||
|
pub fn pg_diff_sequences_query() -> String {
|
||||||
|
"SELECT sequence_schema, sequence_name, \
|
||||||
|
COALESCE(start_value::text, '1'), COALESCE(increment::text, '1'), \
|
||||||
|
COALESCE(minimum_value::text, '1'), COALESCE(maximum_value::text, '9223372036854775807'), \
|
||||||
|
cycle_option = 'YES' \
|
||||||
|
FROM information_schema.sequences \
|
||||||
|
WHERE sequence_schema = $1 \
|
||||||
|
ORDER BY sequence_name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enums with ordered labels. Columns: schema, name, label (row per label)
|
||||||
|
pub fn pg_diff_enums_query() -> String {
|
||||||
|
"SELECT n.nspname, t.typname, e.enumlabel \
|
||||||
|
FROM pg_type t \
|
||||||
|
JOIN pg_namespace n ON n.oid = t.typnamespace \
|
||||||
|
JOIN pg_enum e ON e.enumtypid = t.oid \
|
||||||
|
WHERE t.typtype = 'e' AND n.nspname = $1 \
|
||||||
|
ORDER BY t.typname, e.enumsortorder"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod mysql_clean;
|
||||||
|
pub mod sql_split;
|
||||||
pub mod object_crud;
|
pub mod object_crud;
|
||||||
pub mod object_ddl;
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
pub mod schema_diff;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
@@ -323,4 +323,64 @@ mod tests {
|
|||||||
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
||||||
assert_eq!(mysql_default_sort(&[]), "");
|
assert_eq!(mysql_default_sort(&[]), "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Schema-diff snapshot queries (0.8.0) ──────────────────────────────
|
||||||
|
|
||||||
|
/// Columns: table_name, column_name, column_type, is_nullable, column_default
|
||||||
|
pub fn mysql_snapshot_columns_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_name, column_name, column_type, is_nullable, \
|
||||||
|
COALESCE(column_default, '') \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE table_schema = '{}' \
|
||||||
|
ORDER BY table_name, ordinal_position",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constraints (one row per constraint-column). Columns: table_name,
|
||||||
|
/// constraint_name, constraint_type, column_name, referenced_table_name,
|
||||||
|
/// delete_rule, update_rule, check_clause
|
||||||
|
pub fn mysql_snapshot_constraints_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT kcu.table_name, kcu.constraint_name, tc.constraint_type, kcu.column_name, \
|
||||||
|
COALESCE(kcu.referenced_table_name, ''), \
|
||||||
|
COALESCE(rc.delete_rule, ''), COALESCE(rc.update_rule, ''), \
|
||||||
|
COALESCE((SELECT cc.check_clause FROM information_schema.check_constraints cc \
|
||||||
|
WHERE cc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND cc.constraint_name = kcu.constraint_name), '') \
|
||||||
|
FROM information_schema.key_column_usage kcu \
|
||||||
|
LEFT JOIN information_schema.table_constraints tc \
|
||||||
|
ON tc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND tc.constraint_name = kcu.constraint_name \
|
||||||
|
AND tc.table_name = kcu.table_name \
|
||||||
|
LEFT JOIN information_schema.referential_constraints rc \
|
||||||
|
ON rc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND rc.constraint_name = kcu.constraint_name \
|
||||||
|
WHERE kcu.constraint_schema = '{}' AND tc.constraint_type IS NOT NULL \
|
||||||
|
ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary indexes (one row per index-column). Columns: table_name,
|
||||||
|
/// index_name, non_unique, column_name
|
||||||
|
pub fn mysql_snapshot_indexes_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_name, index_name, non_unique, column_name \
|
||||||
|
FROM information_schema.statistics \
|
||||||
|
WHERE table_schema = '{}' AND index_name != 'PRIMARY' \
|
||||||
|
ORDER BY table_name, index_name, seq_in_index",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views. Columns: table_schema, table_name, view_definition
|
||||||
|
pub fn mysql_snapshot_views_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_schema, table_name, view_definition \
|
||||||
|
FROM information_schema.views \
|
||||||
|
WHERE table_schema = '{}'",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Planning for a MySQL "clean" restore.
|
||||||
|
//!
|
||||||
|
//! `clean` drops only the objects the dump recreates, matching the UI label
|
||||||
|
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
|
||||||
|
//! the target database that the dump does not mention survive. It is
|
||||||
|
//! deliberately *not* `DROP DATABASE` (spec §4.3).
|
||||||
|
//!
|
||||||
|
//! Pure by design: takes already-split statements, returns the DROP script.
|
||||||
|
|
||||||
|
use crate::db::mysql::mysql_quote_ident;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ObjectKind {
|
||||||
|
Table,
|
||||||
|
View,
|
||||||
|
Procedure,
|
||||||
|
Function,
|
||||||
|
Trigger,
|
||||||
|
Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectKind {
|
||||||
|
fn keyword(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ObjectKind::Table => "TABLE",
|
||||||
|
ObjectKind::View => "VIEW",
|
||||||
|
ObjectKind::Procedure => "PROCEDURE",
|
||||||
|
ObjectKind::Function => "FUNCTION",
|
||||||
|
ObjectKind::Trigger => "TRIGGER",
|
||||||
|
ObjectKind::Event => "EVENT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dependents first, tables last, so the drop phase reads predictably.
|
||||||
|
/// Foreign-key ordering is handled by the caller disabling
|
||||||
|
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
|
||||||
|
fn order(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
ObjectKind::Trigger => 0,
|
||||||
|
ObjectKind::Event => 1,
|
||||||
|
ObjectKind::Procedure => 2,
|
||||||
|
ObjectKind::Function => 3,
|
||||||
|
ObjectKind::View => 4,
|
||||||
|
ObjectKind::Table => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens that may appear between the object keyword and its name.
|
||||||
|
const MODIFIERS: &[&str] = &[
|
||||||
|
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
|
||||||
|
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
|
||||||
|
"=",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
|
||||||
|
/// Returns an empty vector when no object can be identified (fail closed).
|
||||||
|
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
|
||||||
|
let mut found: Vec<(ObjectKind, String)> = Vec::new();
|
||||||
|
for stmt in statements {
|
||||||
|
if let Some((kind, name)) = parse_create_target(stmt) {
|
||||||
|
let qualified = qualify(default_schema, &name);
|
||||||
|
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
|
||||||
|
found.push((kind, qualified));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.sort_by_key(|(k, _)| k.order());
|
||||||
|
found
|
||||||
|
.into_iter()
|
||||||
|
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
|
||||||
|
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
|
||||||
|
let tokens = unwrap_version_comments(stmt);
|
||||||
|
let tokens = tokenize(&tokens);
|
||||||
|
let mut it = tokens.iter().peekable();
|
||||||
|
while let Some(tok) = it.next() {
|
||||||
|
if !tok.eq_ignore_ascii_case("CREATE") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Walk forward until an object keyword is found.
|
||||||
|
while let Some(t) = it.next() {
|
||||||
|
let upper = t.to_ascii_uppercase();
|
||||||
|
let kind = match upper.as_str() {
|
||||||
|
"TABLE" => ObjectKind::Table,
|
||||||
|
"VIEW" => ObjectKind::View,
|
||||||
|
"PROCEDURE" => ObjectKind::Procedure,
|
||||||
|
"FUNCTION" => ObjectKind::Function,
|
||||||
|
"TRIGGER" => ObjectKind::Trigger,
|
||||||
|
"EVENT" => ObjectKind::Event,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
// The first following token that is neither a modifier nor an
|
||||||
|
// `x=y` clause is the object name.
|
||||||
|
for cand in it.by_ref() {
|
||||||
|
let up = cand.to_ascii_uppercase();
|
||||||
|
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A routine's name is followed by its argument list with no
|
||||||
|
// space (`p`()`, hence a single token); strip that trailing
|
||||||
|
// `()` so the DROP names the routine, not its signature. This
|
||||||
|
// must be a *string* suffix — `trim_end_matches('(')` (a char
|
||||||
|
// pattern) strips nothing from `p`()`.
|
||||||
|
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Fail closed: a token that is not a plausible identifier means
|
||||||
|
// we cannot name the object, so plan no DROP rather than emit a
|
||||||
|
// bogus one. `CREATE TABLE (id int)` tokenizes as
|
||||||
|
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
|
||||||
|
// definition fragment, not a table name.
|
||||||
|
if !is_plausible_identifier(&name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some((kind, name));
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
|
||||||
|
/// outside a backtick-quoted region means the token is part of an argument
|
||||||
|
/// list, a column definition, or a statement terminator — not an identifier.
|
||||||
|
fn is_plausible_identifier(cand: &str) -> bool {
|
||||||
|
if cand.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut in_backtick = false;
|
||||||
|
for ch in cand.chars() {
|
||||||
|
match ch {
|
||||||
|
'`' => in_backtick = !in_backtick,
|
||||||
|
'(' | ')' | ',' | ';' if !in_backtick => return false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `/*!50003 … */` executable comments with their inner text — the
|
||||||
|
/// shape mysqldump uses to wrap routine and trigger definitions.
|
||||||
|
fn unwrap_version_comments(stmt: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(stmt.len());
|
||||||
|
let chars: Vec<char> = stmt.chars().collect();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
|
||||||
|
// skip `/*!` and any leading digits
|
||||||
|
let mut j = i + 3;
|
||||||
|
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
out.push(' ');
|
||||||
|
while j < chars.len()
|
||||||
|
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
|
||||||
|
{
|
||||||
|
out.push(chars[j]);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
out.push(' ');
|
||||||
|
i = (j + 2).min(chars.len());
|
||||||
|
} else {
|
||||||
|
out.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split on whitespace, treating backtick-quoted regions as part of one token.
|
||||||
|
fn tokenize(s: &str) -> Vec<String> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut in_backtick = false;
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'`' => {
|
||||||
|
in_backtick = !in_backtick;
|
||||||
|
cur.push(ch);
|
||||||
|
}
|
||||||
|
c if c.is_whitespace() && !in_backtick => {
|
||||||
|
if !cur.is_empty() {
|
||||||
|
tokens.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
tokens.push(cur);
|
||||||
|
}
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
|
||||||
|
fn qualify(default_schema: &str, name: &str) -> String {
|
||||||
|
let parts: Vec<String> = name
|
||||||
|
.split('.')
|
||||||
|
.map(|p| mysql_quote_ident(strip_backticks(p)))
|
||||||
|
.collect();
|
||||||
|
if parts.len() == 1 {
|
||||||
|
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
|
||||||
|
} else {
|
||||||
|
parts.join(".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_backticks(s: &str) -> &str {
|
||||||
|
s.trim_matches('`')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "mysql_clean.test.rs"]
|
||||||
|
mod mysql_clean_tests;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn plan(sql: &str) -> Vec<String> {
|
||||||
|
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plans_a_drop_for_each_recreated_table() {
|
||||||
|
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![
|
||||||
|
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
|
||||||
|
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualifies_unqualified_names_with_the_target_schema() {
|
||||||
|
assert_eq!(
|
||||||
|
plan("CREATE TABLE orders (id int);"),
|
||||||
|
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_an_explicit_schema_qualifier() {
|
||||||
|
assert_eq!(
|
||||||
|
plan("CREATE TABLE `other`.`orders` (id int);"),
|
||||||
|
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
|
||||||
|
// This is the shape mysqldump actually emits for routines/triggers.
|
||||||
|
let got = plan(
|
||||||
|
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
|
||||||
|
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![
|
||||||
|
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
|
||||||
|
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_repeated_objects() {
|
||||||
|
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
|
||||||
|
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orders_dependents_before_tables() {
|
||||||
|
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
|
||||||
|
let kinds: Vec<&str> = got
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.split_whitespace().nth(1).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_create_statements() {
|
||||||
|
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
|
||||||
|
assert!(got.is_empty(), "got {got:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_a_create_whose_name_cannot_be_determined() {
|
||||||
|
// Fail closed: if we cannot name the object we must not guess at a DROP.
|
||||||
|
let got = plan("CREATE TABLE (id int);");
|
||||||
|
assert!(got.is_empty(), "got {got:?}");
|
||||||
|
}
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
|
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
|
||||||
//! and synthesized object DDL. No DB I/O — deterministic string builders.
|
//! and synthesized object DDL. No DB I/O — deterministic string builders.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
|
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
|
||||||
|
|
||||||
|
/// Column model for the SQLite table editor. Mirrors the frontend payload.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SqliteColumn {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub nullable: bool,
|
||||||
|
pub default: Option<String>,
|
||||||
|
pub is_pk: bool,
|
||||||
|
pub auto_increment: bool,
|
||||||
|
pub unique: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Double-quote an identifier, doubling embedded quotes.
|
/// Double-quote an identifier, doubling embedded quotes.
|
||||||
pub fn quote_ident(name: &str) -> String {
|
pub fn quote_ident(name: &str) -> String {
|
||||||
format!("\"{}\"", name.replace('"', "\"\""))
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
@@ -125,6 +139,98 @@ pub fn constraint_ddl(c: &ConstraintInfo) -> String {
|
|||||||
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
|
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SQLite table editor ---
|
||||||
|
|
||||||
|
fn validate_type_fragment(t: &str) -> Result<(), String> {
|
||||||
|
let l = t.trim().to_lowercase();
|
||||||
|
if l.is_empty() { return Err("column type is required".into()); }
|
||||||
|
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
|
||||||
|
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
|
||||||
|
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
|
||||||
|
validate_object_name(table)?;
|
||||||
|
let mut defs: Vec<String> = vec![];
|
||||||
|
let mut pk_cols: Vec<String> = vec![];
|
||||||
|
for c in cols {
|
||||||
|
validate_object_name(&c.name)?;
|
||||||
|
validate_type_fragment(&c.type_)?;
|
||||||
|
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
|
||||||
|
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
|
||||||
|
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
|
||||||
|
} else {
|
||||||
|
if !c.nullable { d.push_str(" NOT NULL"); }
|
||||||
|
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
|
||||||
|
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
|
||||||
|
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
|
||||||
|
}
|
||||||
|
defs.push(d);
|
||||||
|
}
|
||||||
|
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
|
||||||
|
// columns (single or composite) — the plan draft's `len > 1` condition would
|
||||||
|
// silently drop a single-column PK constraint.
|
||||||
|
if !pk_cols.is_empty() {
|
||||||
|
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
|
||||||
|
}
|
||||||
|
for (lc, refc) in fks {
|
||||||
|
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
|
||||||
|
}
|
||||||
|
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
|
||||||
|
/// for edits SQLite's ALTER TABLE can't express.
|
||||||
|
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||||
|
// rename detection: same position+type+nullable+default, name changed
|
||||||
|
for (i, n) in new.iter().enumerate() {
|
||||||
|
if let Some(o) = old.get(i) {
|
||||||
|
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
|
||||||
|
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// add column (new tail column, safe only if nullable or defaulted)
|
||||||
|
if new.len() > old.len() {
|
||||||
|
if let Some(c) = new.last() {
|
||||||
|
if c.nullable || c.default.is_some() {
|
||||||
|
validate_object_name(&c.name)?;
|
||||||
|
validate_type_fragment(&c.type_)?;
|
||||||
|
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
|
||||||
|
if !c.nullable {
|
||||||
|
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
|
||||||
|
}
|
||||||
|
return Ok(vec![d]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
|
||||||
|
sqlite_rebuild_script(table, old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||||
|
validate_object_name(table)?;
|
||||||
|
let tmp = format!("_gl_{}_tmp", table);
|
||||||
|
let create = sqlite_create_table_sql(&tmp, new, &[])?;
|
||||||
|
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
|
||||||
|
Ok(vec![
|
||||||
|
create,
|
||||||
|
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
|
||||||
|
format!("DROP TABLE \"main\".{}", quote_ident(table)),
|
||||||
|
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail-closed readiness reason, or None if rebuild is safe.
|
||||||
|
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
|
||||||
|
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
|
||||||
|
if old.iter().any(|c| c.auto_increment) {
|
||||||
|
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -258,4 +364,50 @@ mod tests {
|
|||||||
let ddl = constraint_ddl(&c);
|
let ddl = constraint_ddl(&c);
|
||||||
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
|
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_create_with_autoincrement_pk() {
|
||||||
|
let cols = vec![
|
||||||
|
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
|
||||||
|
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||||
|
];
|
||||||
|
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
|
||||||
|
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
|
||||||
|
assert!(sql.contains("\"name\" TEXT"));
|
||||||
|
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_create_rejects_bad_identifier() {
|
||||||
|
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||||
|
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_edit_add_column_when_safe() {
|
||||||
|
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
|
||||||
|
let new = vec![
|
||||||
|
old[0].clone(),
|
||||||
|
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||||
|
];
|
||||||
|
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
|
||||||
|
assert_eq!(stmts.len(), 1);
|
||||||
|
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_edit_rename_column() {
|
||||||
|
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||||
|
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||||
|
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||||
|
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_edit_typechange_requires_rebuild() {
|
||||||
|
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||||
|
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||||
|
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||||
|
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||