diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml new file mode 100644 index 00000000..c6073e66 --- /dev/null +++ b/.github/workflows/headless-image.yml @@ -0,0 +1,101 @@ +name: Headless image + +# Publishes ghcr.io/queryahub/querya-headless: (and :latest) after a +# Release succeeds, or on demand for an existing release. +# +# `workflow_run`, not `release`: the release is created with GITHUB_TOKEN, and +# events made by that token do not start other workflows. +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Querya Desktop release to build the image for (e.g. 0.4.19) + required: true + latest: + description: Also tag the image as latest + type: boolean + default: true + +permissions: + contents: read + packages: write + +jobs: + image: + name: Build and push querya-headless + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Resolve the version + id: v + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + LATEST="${{ inputs.latest }}" + else + # A tag push has the tag as head_branch. + VERSION="${{ github.event.workflow_run.head_branch }}" + LATEST=true + fi + VERSION="${VERSION#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::'$VERSION' is not a release tag, nothing to build" + echo "build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "build=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + if: steps.v.outputs.build == 'true' + + - uses: docker/login-action@v3 + if: steps.v.outputs.build == 'true' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (loaded locally for the smoke test) + if: steps.v.outputs.build == 'true' + uses: docker/build-push-action@v6 + with: + context: docker/headless + file: docker/headless/Dockerfile + build-args: QUERYA_VERSION=${{ steps.v.outputs.version }} + load: true + tags: querya-headless:test + + - name: Smoke test + if: steps.v.outputs.build == 'true' + shell: bash + run: | + set -euo pipefail + docker run --rm querya-headless:test --help | grep -q "Usage: querya-ext-tester" + mkdir -p empty + set +e + docker run --rm -v "$PWD/empty:/ext:ro" querya-headless:test /ext --no-color + code=$? + set -e + # No manifest.json: the run must fail with exit code 1. + test "$code" -eq 1 + + - name: Push + if: steps.v.outputs.build == 'true' + uses: docker/build-push-action@v6 + with: + context: docker/headless + file: docker/headless/Dockerfile + build-args: QUERYA_VERSION=${{ steps.v.outputs.version }} + push: true + tags: | + ghcr.io/queryahub/querya-headless:${{ steps.v.outputs.version }} + ${{ steps.v.outputs.latest == 'true' && 'ghcr.io/queryahub/querya-headless:latest' || '' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44af14e4..cdf190dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -404,7 +404,9 @@ jobs: mkdir -p build/ext-tester EXE=querya-ext-tester if [ "${{ runner.os }}" = "Windows" ]; then EXE=querya-ext-tester.exe; fi - dart compile exe bin/querya_ext_tester.dart -o "build/ext-tester/$EXE" + # Pure Dart in a throwaway package: `dart compile exe` rejects the + # app's graph (build hooks in sqlite3 and objective_c). + scripts/ci/build_ext_tester.sh "build/ext-tester/$EXE" MCP=querya-mcp if [ "${{ runner.os }}" = "Windows" ]; then MCP=querya-mcp.exe; fi mkdir -p build/mcp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e309798..8c1f2cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and Production safeguards, the ER diagram, charts and plans, the MCP server, a rebuilt SQL workspace toolbar, a UI kit, and a round of session and connection correctness fixes. +### Planned + +- Planned 0.5.0 — live Marketplace download and install +- MCP v2: MongoDB and Redis, extension drivers, Streamable HTTP, writes with confirmation +- Server-side statement timeouts for editor and browse sessions +- High-refresh Linux compositor investigations (#980) + +## [0.4.19] - 2026-10-11 + +The NoSQL workbench, SSH tunnelling and Production safeguards, the ER diagram (layout, groups, notes, saved views, DBML and PDF export), charts and plans, the read-only MCP server, a rebuilt SQL workspace toolbar and run modes, one control scale and button family across the UI kit, a first round of leaving `shadcn_flutter`, security fixes (PostgreSQL TLS, the extension sandbox), and a round of session and connection correctness fixes. ### Added @@ -76,6 +85,7 @@ Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and **Platform, packaging and tooling** - **Portable mode out of the box** through a `.portable` marker (#1056). +- **Standalone Flathub manifest and bundle icon (#1060)** in `packaging/linux/flatpak/`. - **macOS .dmg** in the release pipeline; **AppStream metainfo** for Flatpak; **signed APT and DNF repositories** from the release workflow. - **Team connection profile export / import.** - **Extension test harness and CLI runner** (`querya-ext-tester`), published with releases; CI recipes for extension authors. @@ -94,7 +104,7 @@ Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and - **Flutter is pinned to 3.47.1**, the version the 120 Hz Linux engine is built for. - **Dialog footers** — About, Driver manager, Preferences, the connection forms and the small name dialogs use `QueryaDialogActions`: Cancel / Close is ghost, Test connection sits between Cancel and the main action, spacing is 8 px, one divider (#1441). - **One height per toolbar row** — the SQL, ERD, chart and results toolbars set one control size, and an icon button is the same control as every other button (#1439, #1440). -- **ERD view split into parts (#1362)** — layout, routing and geometry moved to `core/erd` (`ErdLayoutEngine`, unit-tested without widgets); cards, relation painter, notes, groups, export data, menus and status bodies are their own files. +- **ERD view split into parts (#1362)** — layout, routing and geometry moved to `core/erd` (`ErdLayoutEngine`, unit-tested without widgets); cards, relation painter, notes, groups, export data, the export flows (`ErdExporter`: Mermaid, DBML, SVG, PNG, PDF), menus and status bodies are their own files. - **SQL workspace toolbar, connection forms and the editor's highlighter lifecycle** are separate, tested units (`SqlWorkspaceToolbar`, form sections for network, credentials and SSL, a highlight controller managed per session). - **`QueryaApp.build` has no side effects** — the async loads and one-time work moved out of it. @@ -118,11 +128,13 @@ Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and - **SQLite** — columns with the same name stay separate in query results (#1144). - **Updater and Windows** — the Linux bundle replace preserves `QueryaData` and `.portable` (#1055); file associations and registry writes are optional (#1054). - **Packaging** — desktop and icon caches are refreshed after install and removal. +- **Run on an empty editor says so (#1478)** — *Nothing to run* instead of silence; the status bar follows the active tab and no longer shows another tab's rows and time or repeats the row count (#1479). - **Removing a connection** asks first and leaves its workspace; *Remove connection* is a destructive menu item. - **SQL workspace** — the status bar no longer keeps *Running query…* after a failed run; a long query error scrolls instead of overflowing the results pane; a code editor that fills its space starts its text at the top. - **Editor** — no duplicate `onChanged`, no false cursor jumps, default highlighting on (#1430); MongoDB documents in JSON mode are highlighted (#1432). - **Data grid** — the WHERE clause of an edit uses the column's real type and tells the string `'NULL'` from SQL `NULL`; the value panel no longer validates twice (#1431). - **UI** — export and copy menu buttons have one chevron; the tab strip draws its focus ring only for keyboard focus (#1438); dropdown menus are at least as wide as their trigger (#1444); form rules use the theme's border colour, not black, in the light theme (#1443); the new-connection wizard search is the standard search field (#1442). +- **Look and feel** — a disabled primary button is a grey chip with a readable label; the SQL toolbar badge reads *Auto-commit* / *Manual commit* before the first run; below 1100 px the ERD toolbar shows *Note*, *Auto layout* and the group buttons as icons so *Export* does not wrap alone; Preferences MCP buttons are the same size as the rest. - **Startup recovery** — when the local database cannot be opened, the app shows a recovery screen instead of failing silently. - **Updater (Windows)** — an in-place update mirrors the new bundle, so obsolete DLLs and assets are removed. - **SSH** — a failed tunnel closes its sockets and the jump socket, a missing key file is reported, concurrent callers share one dial, and a local forwarder that survived a failed keep-alive is closed; a dropped socket marks the session closed (`isConnected`). @@ -149,16 +161,9 @@ Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and - **E2E suite** (`test/e2e`, run in CI and gating `release`): connection lifecycle and forms of every type, folders, SSH bastion tunnel, credential scrubbing, SQL workspaces and export, command palette, extensions sandbox, Redis CLI and key tree, Mongo viewer, index manager, aggregations, CRUD, theme import, shutdown. - **Test sections** run as parallel jobs; self-hosted runners; a MySQL server and bubblewrap for the live database and sandbox tests. -- **Guards** — UI kit usage, and `core/` and `shared/` free of `features/` and `app/` imports; `lib/shared` must not import `shadcn_flutter` (a ratchet); no raw `Divider` outside the kit. +- **Guards** — UI kit usage, and `core/` and `shared/` free of `features/` and `app/` imports; `lib/shared` must not import `shadcn_flutter` (a ratchet); no raw `Divider`, no shadcn or raw Material buttons, no `*Button` class outside the kit (two allowed) and no literal tooltip delay (`test/guards/`). - **Benchmarks** compare build time over interleaved runs and cover ERD, charts and multi-tab SQL scenarios. -### Planned - -- Planned 0.5.0 — live Marketplace download and install -- MCP v2: MongoDB and Redis, extension drivers, Streamable HTTP, writes with confirmation -- Server-side statement timeouts for editor and browse sessions -- High-refresh Linux compositor investigations (#980) - ## [0.4.18] - 2026-09-28 Shell and motion performance (sidebar layout isolation and offstage rendering), data-loss guard rails, driver session correctness, and high-refresh grid reliability. diff --git a/bin/querya_ext_tester.dart b/bin/querya_ext_tester.dart index 95681fbd..6956ab20 100644 --- a/bin/querya_ext_tester.dart +++ b/bin/querya_ext_tester.dart @@ -27,11 +27,18 @@ Options: --timeout-ms per-request timeout (default 5000) --junit write a JUnit XML report --tap write a TAP report + --trace write every RPC call and a bounded view of the data as JSON --no-color disable ANSI colors -h, --help show this help '''; -Future main(List args) async { +/// The exit code is set through `exitCode`: a Dart `main` cannot return one +/// (the `Future` it used to return was ignored, so a failed run exited 0). +Future main(List args) async { + exitCode = await _run(args); +} + +Future _run(List args) async { String? dir; var target = '0.5.0'; String? connect; @@ -42,6 +49,7 @@ Future main(List args) async { var timeoutMs = 5000; String? junit; String? tap; + String? trace; var color = stdout.hasTerminal; String? value(int i, String flag) { @@ -69,7 +77,8 @@ Future main(List args) async { '--database' || '--timeout-ms' || '--junit' || - '--tap': + '--tap' || + '--trace': final v = value(i, a); if (v == null) return 2; i++; @@ -95,6 +104,8 @@ Future main(List args) async { junit = v; case '--tap': tap = v; + case '--trace': + trace = v; } default: if (a.startsWith('-') || dir != null) { @@ -153,5 +164,9 @@ Future main(List args) async { stdout.write(report.toConsole(color: color)); if (junit != null) File(junit).writeAsStringSync(report.toJUnitXml()); if (tap != null) File(tap).writeAsStringSync(report.toTap()); + if (trace != null) { + File(trace).writeAsStringSync(report.toTraceJson()); + stdout.writeln('RPC trace of ${report.rpc.length} calls written to $trace'); + } return report.isSuccess ? 0 : 1; } diff --git a/docker/headless/Dockerfile b/docker/headless/Dockerfile new file mode 100644 index 00000000..3aa1f618 --- /dev/null +++ b/docker/headless/Dockerfile @@ -0,0 +1,49 @@ +# querya-headless: the extension tester of a Querya Desktop release in a pinned, +# reproducible environment, to run in CI or locally without a display, Flutter +# or the Querya source tree. +# +# docker build -f docker/headless/Dockerfile \ +# --build-arg QUERYA_VERSION=0.4.19 -t querya-headless:0.4.19 docker/headless +# +# docker run --rm -v "$PWD/dist:/ext:ro" querya-headless:0.4.19 /ext \ +# --connect '{"host":"db","port":5432}' --trace /out/trace.json +# +# The tester is downloaded from the GitHub Release and checked against its +# SHA256SUMS.txt; the build fails when the checksum does not match. +# +# glibc (Debian) on purpose: extension entry points are native executables, and +# most of them are not linked against musl. +FROM debian:bookworm-slim + +ARG QUERYA_VERSION +ARG TARGET=linux-x64 + +RUN test -n "${QUERYA_VERSION}" || (echo "set --build-arg QUERYA_VERSION=x.y.z" >&2; exit 1) + +# bubblewrap is the OS sandbox of `--sandbox`; curl and tar fetch the tester. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tar bubblewrap \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + base="https://github.com/QueryaHub/Querya-Desktop/releases/download/${QUERYA_VERSION}"; \ + asset="querya-ext-tester-v${QUERYA_VERSION}-${TARGET}.tar.gz"; \ + cd /tmp; \ + curl -fsSLO "${base}/${asset}"; \ + curl -fsSLO "${base}/SHA256SUMS.txt"; \ + grep " ${asset}\$" SHA256SUMS.txt | sha256sum -c -; \ + tar -xzf "${asset}" -C /usr/local/bin querya-ext-tester; \ + rm -f "${asset}" SHA256SUMS.txt; \ + querya-ext-tester --help > /dev/null + +LABEL org.opencontainers.image.title="querya-headless" \ + org.opencontainers.image.description="Querya Desktop extension tester (headless)" \ + org.opencontainers.image.source="https://github.com/QueryaHub/Querya-Desktop" \ + org.opencontainers.image.version="${QUERYA_VERSION}" + +RUN useradd --create-home --uid 1000 querya +USER querya +WORKDIR /ext + +ENTRYPOINT ["querya-ext-tester"] +CMD ["--help"] diff --git a/docs/extension-testing.md b/docs/extension-testing.md index 91f01481..bdc6b924 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -71,6 +71,7 @@ querya-ext-tester ./my-extension \ | `--sandbox` | run the plugin in the OS sandbox (`bwrap` on Linux, `sandbox-exec` on macOS) when available | | `--timeout-ms ` | per-request timeout (default 5000) | | `--junit ` / `--tap ` | write a JUnit XML / TAP report | +| `--trace ` | write every RPC round trip as JSON: method, time, request and response size and a bounded view of the data (see *Following the data*) | | `--no-color` | plain output | Exit codes: `0` all checks passed (warnings and skips do not fail), `1` a @@ -80,6 +81,102 @@ Credentials only travel inside the `db.connect` payload over the plugin's stdio; they are never put on the command line of the plugin or into its environment. Use a throwaway database in CI. +## Following the data: `--trace` + +A green run says the lifecycle works. `--trace trace.json` shows *what* went +over the wire, so you can see whether the data of your driver arrives as the +host expects: + +```json +{ + "extension": "acme.clickhouse-driver", + "targetVersion": "0.5.0", + "success": true, + "totalMs": 41, + "checks": [{ "name": "db.query", "status": "passed", "ms": 3 }], + "rpc": [ + { "method": "db.connect", "ms": 12, "ok": true, "requestBytes": 76, + "responseBytes": 28, "request": { "host": "db", "password": "***" } }, + { "method": "db.query", "ms": 3, "ok": true, "requestBytes": 47, + "responseBytes": 1760, + "response": { "columns": ["id", "name"], "rows": [[0, "user0"], "… (+7 more)"] } } + ] +} +``` + +- Values of keys that look like secrets (`password`, `secret`, `token`, + `passphrase`, ...) are written as `***`. +- Responses are cut: at most 5 items per list, strings of at most 120 + characters. The file shows the shape and a sample, not a copy of your data; + still, use a throwaway database and do not publish traces of real data. +- A failing call is recorded with its `error`, so a CI artifact is enough to + see which call broke and after how long. + +## Docker image + +`ghcr.io/queryahub/querya-headless:` (and `:latest`) is the tester of +that Querya Desktop release in a pinned environment: Debian (glibc), the +tester checked against the release's `SHA256SUMS.txt`, `bubblewrap` for +`--sandbox`, a non-root user, entry point `querya-ext-tester`. Nothing else is +needed on the machine: no Flutter, no display, no Querya install. + +```bash +docker pull ghcr.io/queryahub/querya-headless:latest + +docker run --rm \ + -v "$PWD/dist:/ext:ro" -v "$PWD/out:/out" \ + ghcr.io/queryahub/querya-headless:0.4.19 /ext \ + --connect '{"host":"db","port":5432}' \ + --junit /out/junit.xml --trace /out/trace.json +``` + +The extension's `main` is a native executable and runs **inside** the image, so +build it for Linux x86-64 against glibc. To reach a database from the image, +put both on one Docker network and use the database's service name as the host. + +### In CI, next to a test database + +```yaml +jobs: + rpc: + runs-on: ubuntu-latest + services: + db: + image: postgres:16-alpine + env: { POSTGRES_PASSWORD: querya, POSTGRES_USER: querya, POSTGRES_DB: querya } + options: >- + --health-cmd "pg_isready -U querya" --health-interval 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + - run: ./build.sh # writes ./dist with manifest.json + - name: RPC and data check + run: | + mkdir -p out && chmod 777 out + docker run --rm --network "${{ job.services.db.network }}" \ + -v "$PWD/dist:/ext:ro" -v "$PWD/out:/out" \ + ghcr.io/queryahub/querya-headless:0.4.19 /ext \ + --target-version 0.5.0 \ + --connect '{"host":"db","port":5432,"username":"querya","password":"querya","database":"querya"}' \ + --query 'SELECT 1' --junit /out/junit.xml --trace /out/trace.json + - uses: actions/upload-artifact@v4 + if: always() + with: { name: rpc-trace, path: out/ } +``` + +Use the same `docker run` locally with `--network querya-dev_default` against +the stack from [dev-containers.md](dev-containers.md) (`host` is the service +name, for example `postgres`). + +### `--sandbox` in a container + +The OS sandbox is `bubblewrap`, which needs unprivileged user namespaces. Many +container runtimes block them by default; the tester then runs the plugin +without the sandbox and says so in the report. To test the sandboxed run, start +the container with `--security-opt seccomp=unconfined` (and, on hosts that +restrict namespaces with AppArmor, `--security-opt apparmor=unconfined`), or +run the tester directly on the CI runner with the action below. + ## GitHub Actions This repository contains a composite action in diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 928fe959..cd7ef0ce 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,10 +1,25 @@ -# Pre-release checklist (release **0.4.18**) +# Pre-release checklist (release **0.4.19**) -Use this before tagging **`0.4.18`** or running the **Release** workflow. +Use this before tagging **`0.4.19`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md). Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (does not block this cut). -## Product smoke (manual) — 0.4.18 +## Product smoke (manual) — 0.4.19 + +- [ ] **Buttons and controls (#1331)** — in the SQL toolbar, results bar, ERD toolbar and Preferences every control of a row has one height; hover, pressed, keyboard focus ring (Tab, not mouse click) and disabled states look the same on primary / secondary / ghost / destructive; icon buttons show a tooltip. +- [ ] **Dialog footers (#1441)** — Cancel / Close is ghost, the main action is the only filled button; Esc cancels, Enter confirms (About, Driver manager, Preferences, a connection form, new folder). +- [ ] **Toast (#1466)** — an export, a copy and an error show a toast that stacks, closes by itself and by its button; Motion Off shows it at once. +- [ ] **Light theme** — rules in connection forms and dialogs are visible (not black); dropdown menus are not narrower than their field. +- [ ] **Run modes (#1148)** — Ctrl+Enter runs the statement at the caret, F5 the script statement by statement; Run on an empty editor says *Nothing to run*; the status bar follows the active tab. +- [ ] **SQL toolbar (#1150)** — one row at 1000 px and at 800 px; the transaction badge and Commit / Rollback only while a transaction is open. +- [ ] **ER diagram** — open from a SQLite and a PostgreSQL connection; group, note, saved view; Export → SVG, PNG, PDF, DBML; the toolbar stays on one row at ~1000 px. +- [ ] **Connections** — removing a connection asks first; *Test connection* of an edited connection uses the saved password; an SSH tunnel failure names the bastion. +- [ ] **PostgreSQL TLS** — with a root CA set, the connection verifies the server (`verify-full`). +- [ ] **Startup recovery** — with an unreadable local database the app opens the recovery screen instead of crashing. +- [ ] **MCP server** — enable it, copy a client config, one `list_tables` call from a client; the activity log shows it. +- [ ] **Packaging** — `.deb` / `.rpm` install, `bubblewrap` is recommended; the Flathub manifest in `packaging/linux/flatpak/` is updated with the release zip's URL and `sha256` after the tag (see below). + +## Product smoke (manual) — 0.4.18 (regression) - [ ] **Sidebar toggle layout isolation (#984)** — Ctrl+B toggles sidebar smoothly with zero LayoutBuilder rebuilds in workspace; build time < 0.5ms. - [ ] **LazyOffstage (#1004)** — inactive workspace layers stay offstage and skip redundant layouts. @@ -70,14 +85,15 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support ## Versioning and release -- [x] `pubspec.yaml` on the 0.4.18 track is **`0.4.18+1`**. -- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.19+…** placeholder. -- [ ] **Tag** `0.4.18` is placed on the **main merge commit that includes `0.4.18+1`** (not the auto-bump commit). +- [x] `pubspec.yaml` on the 0.4.19 track is **`0.4.19+2`** (the same as `main` after the bump that followed 0.4.18, so the merge has no version conflict). +- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.20+…** placeholder. +- [ ] **Tag** `0.4.19` is placed on the **main merge commit that includes `0.4.19+2`** (not the auto-bump commit). - [ ] Run the **Release** workflow via that tag (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. +- [ ] After the release, set the release zip's URL and `sha256` in `packaging/linux/flatpak/com.queryahub.querya_desktop.flathub.yml` (and the `metainfo.xml` release entry is already `0.4.19`); the Flathub submission (flathub/flathub#10606) is separate and has open lint errors (HTTPS on `queryahub.com` or another app id, no `--filesystem=home`, a supported GNOME runtime). ## Docs -- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.18]`** section for the release (CI copies it into the GitHub Release body). +- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.19]`** section for the release (CI copies it into the GitHub Release body); `scripts/extract-changelog-section.sh 0.4.19` finds it. - [x] [security.md](security.md) still matches behavior if storage changed. -- [x] [roadmap.md](roadmap.md) marks 0.4.18 as this cut and 0.5.0 as next. +- [x] [roadmap.md](roadmap.md) marks 0.4.19 as this cut and 0.5.0 as next. diff --git a/docs/roadmap.md b/docs/roadmap.md index cc5568cf..83353eff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,8 @@ Living document for planned work. Not a commitment order; adjust as priorities change. -**GitHub Latest Release:** [0.4.17](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.17) (2026-09-20). -**This cut:** **0.4.18** — shell/motion perf (<0.5ms build), data-loss guards, driver session correctness, and high-refresh grid reliability — [CHANGELOG.md](../CHANGELOG.md) `[0.4.18]`. +**GitHub Latest Release:** [0.4.18](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.18) (2026-09-28). +**This cut:** **0.4.19** — NoSQL workbench, SSH and Production safeguards, ER diagram, MCP server, SQL run modes and toolbar, one control scale and button family (UI kit), security fixes — [CHANGELOG.md](../CHANGELOG.md) `[0.4.19]`. **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -31,7 +31,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.13–0.4.15:** interactive Data Grid, fluid motion, multi-tab SQL, preferences/keymap — [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.16:** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. - **Shipped in 0.4.17:** driver/grid correctness after 0.4.16 — schema vs PK (#772), SQLite `rowid` (#774), Mongo writes/filter/discard (#776/#778/#782/#783), Postgres/MySQL/SQLite/Redis session and type round-trips — [CHANGELOG.md](../CHANGELOG.md) `[0.4.17]`. -- **0.4.18 (this cut):** shell/motion perf (<0.5ms build, #984/#1004), data-loss guard rails (#867–#873, #910, #912, #1007), driver correctness, and grid reliability (#1005–#1011) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.18]`. +- **Shipped in 0.4.18:** shell/motion perf (<0.5ms build, #984/#1004), data-loss guard rails (#867–#873, #910, #912, #1007), driver correctness, and grid reliability (#1005–#1011) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.18]`. +- **0.4.19 (this cut):** NoSQL workbench (Mongo viewer, aggregations, indexes, Redis CLI), SSH tunnelling and Production safeguards, ER diagram (groups, notes, saved views, DBML / PDF export), read-only MCP server, SQL run modes and one-row toolbar, UI kit (control scale, `QueryaButton` family, own toast and text styles), PostgreSQL TLS and sandbox fixes — [CHANGELOG.md](../CHANGELOG.md) `[0.4.19]`. - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/lib/core/extensions/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index a6b0e4ea..ea179cdf 100644 --- a/lib/core/extensions/harness/extension_harness.dart +++ b/lib/core/extensions/harness/extension_harness.dart @@ -194,6 +194,7 @@ class QueryaExtensionHarness { final total = Stopwatch()..start(); final profile = ExtensionProtocolProfile.forTarget(targetVersion); final checks = []; + final rpc = []; var extensionId = p.basename(extensionRoot); var abort = false; @@ -328,15 +329,35 @@ class QueryaExtensionHarness { Future call(String method, [Map? params]) async { final c = client; if (c == null) throw StateError('plugin is not running'); + final started = Stopwatch()..start(); + final requestBytes = params == null ? 0 : utf8.encode(jsonEncode(params)).length; + void record({Object? result, String? error}) { + rpc.add(HarnessRpcCall( + method: method, + duration: started.elapsed, + requestBytes: requestBytes, + ok: error == null, + responseBytes: + error == null ? utf8.encode(jsonEncode(result)).length : null, + error: error, + request: params == null ? null : redactRpcValue(params), + response: error == null ? boundRpcValue(result) : null, + )); + } + try { - return await c.sendRequest(method, params); + final result = await c.sendRequest(method, params); + record(result: result); + return result; } on JsonRpcException catch (e) { + record(error: '${e.message}${e.code == null ? '' : ' (code ${e.code})'}'); if (e.code == -32601) { throw StateError('method "$method" is not implemented (-32601)'); } throw StateError('"$method" returned an error: ${e.message}' '${e.code == null ? '' : ' (code ${e.code})'}'); } on TimeoutException { + record(error: 'no answer within $requestTimeout'); throw StateError('"$method" did not answer within $requestTimeout' '${_crashHint(exitCode, stderrTail)}'); } on StateError catch (e) { @@ -451,6 +472,7 @@ class QueryaExtensionHarness { checks: checks, totalDuration: total.elapsed, peakRssKb: peakRss > 0 ? peakRss : null, + rpc: rpc, ); } } diff --git a/lib/core/extensions/harness/extension_harness_report.dart b/lib/core/extensions/harness/extension_harness_report.dart index 5005b2ee..82613739 100644 --- a/lib/core/extensions/harness/extension_harness_report.dart +++ b/lib/core/extensions/harness/extension_harness_report.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:querya_desktop/core/extensions/harness/extension_protocol_profile.dart'; enum HarnessCheckStatus { passed, warning, failed, skipped } @@ -19,6 +21,103 @@ final class HarnessCheck { final String? message; } +/// One JSON-RPC round trip as the harness saw it: what was sent, how long the +/// plugin took, and a bounded, redacted view of the answer (the trace of +/// `--trace`). +final class HarnessRpcCall { + const HarnessRpcCall({ + required this.method, + required this.duration, + required this.requestBytes, + required this.ok, + this.responseBytes, + this.error, + this.request, + this.response, + }); + + final String method; + final Duration duration; + final int requestBytes; + final bool ok; + final int? responseBytes; + final String? error; + + /// The request params with secrets replaced by `***`. + final Object? request; + + /// The result, cut to a few rows and short strings. + final Object? response; + + Map toJson() => { + 'method': method, + 'ms': duration.inMilliseconds, + 'ok': ok, + 'requestBytes': requestBytes, + if (responseBytes != null) 'responseBytes': responseBytes, + if (error != null) 'error': error, + if (request != null) 'request': request, + if (response != null) 'response': response, + }; +} + +/// Replaces the values of keys that look like secrets with `***`. +Object? redactRpcValue(Object? value) { + if (value is Map) { + return { + for (final e in value.entries) + '${e.key}': _isSecretKey('${e.key}') ? '***' : redactRpcValue(e.value), + }; + } + if (value is List) return [for (final v in value) redactRpcValue(v)]; + return value; +} + +bool _isSecretKey(String key) { + final k = key.toLowerCase(); + return k.contains('password') || + k.contains('passwd') || + k.contains('secret') || + k.contains('token') || + k.contains('passphrase') || + k == 'pwd' || + k == 'apikey' || + k == 'api_key' || + k == 'privatekey' || + k == 'private_key'; +} + +/// Cuts [value] for a trace: at most [maxItems] list items, strings of at most +/// [maxString] characters, nesting of at most 5 levels. +Object? boundRpcValue( + Object? value, { + int maxItems = 5, + int maxString = 120, + int depth = 0, +}) { + if (value is String) { + return value.length <= maxString + ? value + : '${value.substring(0, maxString)}… (${value.length} chars)'; + } + if (depth >= 5) return value is Map || value is List ? '…' : value; + if (value is List) { + return [ + for (final v in value.take(maxItems)) + boundRpcValue(v, maxItems: maxItems, maxString: maxString, depth: depth + 1), + if (value.length > maxItems) '… (+${value.length - maxItems} more)', + ]; + } + if (value is Map) { + return { + for (final e in value.entries) + '${e.key}': boundRpcValue(e.value, + maxItems: maxItems, maxString: maxString, depth: depth + 1), + }; + } + return value; +} + /// Results of one harness run, renderable as console text, JUnit XML or TAP. final class HarnessReport { HarnessReport({ @@ -27,8 +126,12 @@ final class HarnessReport { required this.checks, required this.totalDuration, this.peakRssKb, + this.rpc = const [], }); + /// Every RPC round trip of the run, in order. + final List rpc; + final String extensionId; final HostVersion targetVersion; final List checks; @@ -77,6 +180,28 @@ final class HarnessReport { return b.toString(); } + /// The checks and every RPC round trip as JSON (the `--trace` file): what + /// was asked, how long it took and a bounded view of the data that came + /// back. Secrets in the requests are redacted. + String toTraceJson() => const JsonEncoder.withIndent(' ').convert({ + 'extension': extensionId, + 'targetVersion': '$targetVersion', + 'success': isSuccess, + 'totalMs': totalDuration.inMilliseconds, + if (peakRssKb != null) 'pluginPeakRssKb': peakRssKb, + 'checks': [ + for (final c in checks) + { + 'name': c.name, + 'status': c.status.name, + 'ms': c.duration.inMilliseconds, + if (c.message != null && c.message!.isNotEmpty) + 'message': c.message, + }, + ], + 'rpc': [for (final r in rpc) r.toJson()], + }); + /// JUnit XML for GitHub Actions / GitLab CI test reporters. String toJUnitXml() { final b = StringBuffer() diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 52022006..6a8ada31 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -2,7 +2,7 @@ # AUR package — installs the official Release portable Linux zip under /opt. # pkgver is synced by version-bump.yml; CI publishes real sha256sums on Release. pkgname=querya-desktop -pkgver=0.4.18 +pkgver=0.4.22 pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.metainfo.xml b/packaging/linux/flatpak/com.queryahub.querya_desktop.metainfo.xml index 61bb49ff..d8a6efd4 100644 --- a/packaging/linux/flatpak/com.queryahub.querya_desktop.metainfo.xml +++ b/packaging/linux/flatpak/com.queryahub.querya_desktop.metainfo.xml @@ -30,6 +30,7 @@ querya_desktop + diff --git a/pubspec.yaml b/pubspec.yaml index 14821894..1a80a8f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). publish_to: 'none' # app, never published to pub.dev (allows path dependencies) -version: 0.4.18+1 +version: 0.4.22+5 @@ -60,6 +60,11 @@ dependency_overrides: flutter: uses-material-design: true + config: + # The vendored refresh_rate plugin's Package.swift points its target at + # ../Classes, outside the package root, so Swift Package Manager (on by + # default since Flutter 3.47) cannot resolve it; macOS builds use CocoaPods. + enable-swift-package-manager: false assets: - assets/images/ - assets/themes/ diff --git a/scripts/ci/build_ext_tester.sh b/scripts/ci/build_ext_tester.sh new file mode 100755 index 00000000..9fac212e --- /dev/null +++ b/scripts/ci/build_ext_tester.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Compiles querya-ext-tester to one static executable. +# +# scripts/ci/build_ext_tester.sh build/ext-tester/querya-ext-tester +# +# `dart compile exe` refuses a package graph that has build hooks (sqlite3, +# objective_c), and the app's graph has them. The tester itself is pure Dart +# (lib/core/extensions + `package:path`), so it is built from a throwaway +# package that is named like the app, holds only lib/core/extensions and +# depends on `path`. Run it from the repository root; the output path is +# relative to it. +set -euo pipefail + +OUT="${1:?usage: build_ext_tester.sh }" +WORK="build/ext-tester-src" + +rm -rf "$WORK" +mkdir -p "$WORK/lib/core" "$WORK/bin" "$(dirname "$OUT")" +cp -R lib/core/extensions "$WORK/lib/core/extensions" +cp bin/querya_ext_tester.dart "$WORK/bin/querya_ext_tester.dart" + +cat > "$WORK/pubspec.yaml" <<'YAML' +name: querya_desktop +description: Throwaway package that builds querya-ext-tester (see scripts/ci/build_ext_tester.sh). +publish_to: none +environment: + sdk: '>=3.5.0 <4.0.0' +dependencies: + path: ^1.9.0 +YAML + +# The work directory is two levels below the repository root. +(cd "$WORK" && dart pub get && dart compile exe bin/querya_ext_tester.dart -o "../../$OUT") +rm -rf "$WORK" diff --git a/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index 58bc7f96..fbd3e1c9 100644 --- a/test/core/extensions/harness/extension_harness_test.dart +++ b/test/core/extensions/harness/extension_harness_test.dart @@ -341,4 +341,64 @@ void main() { throwsArgumentError, ); }); + + group('RPC trace', () { + test('records every call with timing, sizes and a bounded view of the data', + () async { + writeExtension(manifest()); + final handlers = _healthy() + ..['db.query'] = (_) => { + 'columns': ['id', 'note'], + 'rows': [ + for (var i = 0; i < 12; i++) [i, 'x' * 300], + ], + }; + + final (report, _) = await run(handlers); + + final methods = [for (final c in report.rpc) c.method]; + expect(methods, contains('system.handshake')); + expect(methods, contains('db.query')); + expect(report.rpc.every((c) => c.ok), isTrue); + + final query = report.rpc.firstWhere((c) => c.method == 'db.query'); + final rows = (query.response! as Map)['rows'] as List; + // 5 rows and a marker for the other 7; long strings are cut. + expect(rows, hasLength(6)); + expect(rows.last, '… (+7 more)'); + expect(((rows.first as List)[1] as String).length, lessThan(200)); + expect(query.responseBytes, greaterThan(3000)); + }); + + test('redacts secrets in the request params', () async { + writeExtension(manifest()); + + final (report, _) = await run( + _healthy(), + connection: const HarnessConnectionOptions( + params: {'host': 'db', 'password': 's3cret', 'ssh': {'passphrase': 'p'}}, + ), + ); + + final connect = report.rpc.firstWhere((c) => c.method == 'db.connect'); + final request = connect.request! as Map; + expect(request['password'], '***'); + expect((request['ssh'] as Map)['passphrase'], '***'); + expect(request['host'], 'db'); + expect(report.toTraceJson(), isNot(contains('s3cret'))); + }); + + test('a failing call is recorded with its error', () async { + writeExtension(manifest()); + final handlers = _healthy() + ..['db.query'] = (_) => throw _RpcError(-32000, 'table missing'); + + final (report, _) = await run(handlers); + + final query = report.rpc.firstWhere((c) => c.method == 'db.query'); + expect(query.ok, isFalse); + expect(query.error, contains('table missing')); + expect(report.toTraceJson(), contains('"success": false')); + }); + }); }