From 92b49b007f88f16a7964158352483ed3f5a487e0 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 28 May 2026 18:24:13 -0400 Subject: [PATCH 01/39] Add plan.md for 'ghost serve' command --- plan.md | 525 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..1bf8ec1 --- /dev/null +++ b/plan.md @@ -0,0 +1,525 @@ +# `ghost serve` — local web UI for running SQL against ghost databases + +## Goal + +Add a `ghost serve` command that launches a local web server on `127.0.0.1:` and opens the browser to a small React UI. The UI shows a database picker plus the unmodified PopSQL query widget, so the user can run ad-hoc SQL against any of their ghost databases. No new auth layer: the local server reuses the CLI's existing `gt_…` API key for ghost-api metadata calls, and the query path connects directly to the user's PostgreSQL databases using credentials the CLI already resolves (via `common.GetPassword`). + +``` +ghost serve [--port ] [--host 127.0.0.1] [--no-open] +``` + +This is patterned after `memory-engine`'s `me serve` (PR #47), but query execution runs **in-process** inside the Go binary — there is no proxy to ghost-api or the savannah gateway. The CLI binary becomes a self-contained "query gateway" that speaks the widget's wire protocol. + +--- + +## Architecture + +``` + ┌──────────────────────────────────────────┐ + browser ──▶ │ ghost serve (single Go binary) │ + (Vite-built │ • 127.0.0.1: │ + SPA, served │ • embed.FS static │ + from same │ • /api/databases → ghost-api (REST) │ ──▶ ghost-api + origin) │ • /api/bootstrap → local config │ (only used + │ • /api/executeQuery, /api/arrowResults, │ for listing + │ /api/cancelRun → run pg queries │ databases & + │ in-process │ fetching + │ • Apache Arrow IPC encoder │ passwords) + │ • pgx/v5 connections │ + │ │ │ + └──────┼───────────────────────────────────┘ + │ + ▼ + user's ghost Postgres DBs (TLS, direct) +``` + +No ghost-api changes are required. No new endpoints in any other repo. The widget thinks it's talking to a savannah gateway; in reality the gateway is a few hundred lines of Go in this binary. + +--- + +## The widget's wire protocol (what we have to implement) + +This is what `@popsql/query-client`'s `TimescaleQueryClient` actually sends. Source: `popsql/packages/popsql-query-client/src/{TimescaleQueryClient.ts,client.ts}`. + +Auth: `credentials: 'include'` (cookies — but we have none on localhost) plus optional `Authorization: Bearer `. We don't need either — the listener is loopback-only. + +### One-shot mode (no `sessionKey` prop on ``) — **MVP scope** + +Two endpoints participate in a single query run. The widget fires these in sequence as it streams the response. + +**`POST /api/executeQuery`** +Request body: +```json +{ + "projectId": "", + "serviceId": "", + "query": "select 1", + "runId": "", + "stream": true, + "persist": false, + "statements": null, + "timeout": null +} +``` +Response: `Content-Type: application/x-ndjson` (or `application/json` — the widget just reads lines), one JSON object per line, in order: +1. `{ "columns": [{ "name": "…", "type": "…", … }], "meta": { … } }` — emitted as soon as the column metadata is known. +2. As soon as the widget reads the `columns` line, it fires `POST /api/arrowResults` in parallel (see below). The original `executeQuery` response stays open. +3. Terminator: exactly one of + - `{ "success": true, "rowCount": 42, "duration": 123, … }` + - `{ "success": false, "error": { "message": "…", "cancel"?: true, "timeout"?: true, "fatal"?: true } }` + +`fatal: true` causes the widget to mark the session as broken (only meaningful in session mode); we'll never set it. `cancel: true` is set when the run was cancelled via `/api/cancelRun`. `timeout: true` for query timeouts. + +**`POST /api/arrowResults`** +Request body: +```json +{ + "projectId": "", + "serviceId": "", + "runId": "" +} +``` +Response: `Content-Type: application/vnd.apache.arrow.stream`, body is a raw Apache Arrow **IPC stream** (schema message + zero or more record-batch messages). The widget parses with `RecordBatchReader.from(response)` and renders incrementally. + +**`POST /api/cancelRun`** (used for "stop the running query") +```json +{ "projectId": "…", "serviceId": "…", "runId": "…" } +``` +Soft-cancel: the widget mostly relies on `AbortController` to drop the executeQuery connection. This endpoint is wired but rarely called (see `TimescaleQueryClient.cancelQuery` comment). We'll implement it as best-effort `pg_cancel_backend()` against the run's connection. + +### Session mode — **deferred to follow-up** + +The widget also supports a "session" mode (`sessionKey` prop set on ``) where a long-lived PG connection is reused across multiple `executeSessionQuery` calls. This adds four more endpoints (`createSession`, `sessionEvents` [NDJSON status stream], `executeSessionQuery`, `closeSession`) and a session lifecycle manager. + +For MVP we will **not pass `sessionKey`** to ``. Each query opens its own connection and closes it. Sessions can be a phase-2 add — the architecture leaves room for them. + +--- + +## Apache Arrow encoding (PG row → Arrow IPC) + +The only non-trivial new piece. Plan: + +- Add `github.com/apache/arrow-go/v18` (or whatever the current canonical Go Arrow module is). It's well-maintained and supports the IPC stream writer (`ipc.NewWriter` over an `io.Writer`). +- Use **`github.com/jackc/pgx/v5`** for the Postgres connection (already idiomatic; the CLI may already have it transitively). `pgx` exposes column OIDs which we map to Arrow types cleanly. `database/sql` only gives Go reflect types, which is lossier. + +Type mapping (MVP — narrow enough to cover the common Postgres/Timescale surface): + +| Postgres OID | Arrow type | +|------------------------------------|-------------------------------------| +| `bool` | `Boolean` | +| `int2` | `Int16` | +| `int4` | `Int32` | +| `int8` | `Int64` | +| `float4` | `Float32` | +| `float8` | `Float64` | +| `numeric` | `Utf8` (lose precision-aware decimal for now) | +| `text`, `varchar`, `name`, `bpchar`| `Utf8` | +| `bytea` | `Binary` | +| `date` | `Date32` | +| `timestamp` | `Timestamp[us]` (no tz) | +| `timestamptz` | `Timestamp[us, UTC]` | +| `uuid` | `Utf8` (16-byte FixedSizeBinary later) | +| `json`, `jsonb` | `Utf8` | +| arrays of the above | `List<…>` | +| anything else | `Utf8` (via pgx's text protocol) | + +Batching: send a single record batch for MVP (the table widget is happy with one batch; we can split later for very large results). + +--- + +## Repository changes (this repo) + +``` +ghost_serve/ + internal/cmd/ + serve.go NEW – Cobra command + serve_test.go NEW + internal/serve/ + server.go NEW – net/http server + mux + listener + assets.go NEW – embed.FS resolver (SPA fallback, cache headers) + bootstrap.go NEW – GET /api/bootstrap (projectId, version) + databases.go NEW – GET /api/databases (ghost-api passthrough) + execute.go NEW – POST /api/executeQuery (NDJSON producer) + arrow.go NEW – POST /api/arrowResults (Arrow IPC producer) + cancel.go NEW – POST /api/cancelRun + runs.go NEW – in-memory Run store keyed by runId + pgtypes.go NEW – PG OID → Arrow type mapping + value coercion + wire.go NEW – Go types matching widget request/response shapes + server_test.go NEW + assets_test.go NEW + execute_test.go NEW – uses pgx + a real test PG or pgmock + arrow_test.go NEW – validates IPC output via apache-arrow Go reader + web/ NEW – embed root; web/dist contents land here + .gitkeep + web/ NEW – Vite app workspace (not Go) + package.json + vite.config.ts + tsconfig.json + index.html + .gitignore (dist/, node_modules/) + src/ + main.tsx + app.tsx + styles.css + components/ + DatabasePicker.tsx + QueryPanel.tsx + Header.tsx + api/ + bootstrap.ts (fetch /api/bootstrap once) + databases.ts (fetch /api/databases) + lib/ + url-state.ts (?db=) + scripts/ + build-web.sh NEW – npm ci && npm run build && copy to internal/serve/web/ + check UPDATED – run build-web.sh before go install + .github/workflows/*.yml UPDATED – build web before go build/release + Dockerfile UPDATED – multi-stage node + go build + docs/cli/ghost_serve.md AUTO-GENERATED via cmd/generate-docs + README.md UPDATED – Commands table + Usage examples + CLAUDE.md UPDATED – describe internal/serve/ and web/ +``` + +--- + +## The CLI command (`ghost serve`) + +`internal/cmd/serve.go`, all standard patterns from `CLAUDE.md`: + +- `SilenceUsage: true`, `RunE`, `ValidArgs: cobra.NoFileCompletions`. +- Gated behind `GHOST_EXPERIMENTAL` until polished, then promoted. +- Flags: + - `--port int` — explicit port; default `0` → kernel picks via `net.Listen("tcp", "127.0.0.1:0")`. + - `--host string` — bind address, default `127.0.0.1`. Warn if user overrides to non-loopback. + - `--no-open` — skip browser open. +- Loads `App` via `PersistentPreRunE`. Uses `app.GetAll()` (config + client + projectID). +- Browser open: reuse `common.OpenBrowserAsync(url)` (already used by `login` / `payment add`). Honors `--no-open`. +- Shutdown on `cmd.Context().Done()` via `srv.Shutdown(ctx)`. +- Analytics: add `serve` to `wrapCommands` in `root.go`. No sensitive args. + +Output (stderr to keep stdout free for future scripting use): +``` +Listening on http://127.0.0.1:54321 +Opened browser. Press Ctrl+C to stop. +``` + +--- + +## The web app (`web/`) + +### Stack + +- **React 19**, **Vite 7**, **TypeScript 5**. +- **Tailwind v3.3** — required because `@timescale/popsql-query-widget` is pinned to v3 (its config re-exports `@popsql/lollipop/tailwind.config`). +- **TanStack Query v5** for `/api/databases` and `/api/bootstrap` polling. +- `@timescale/popsql-query-widget` (private npm) + its peer deps: `react@19`, `react-dom@19`, `framer-motion@^12`. +- Single CSS import: `@timescale/popsql-query-widget/index.css` (precompiled by the widget's `bin/package.sh`). + +### Layout + +``` +┌────────────────────────────────────────────────────────────┐ +│ ghost [ db-name ▾ ] │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + +- Header: ghost logo + database ` setSelectedId(e.target.value || null)} + disabled={databases.isLoading} + > + + {databases.data?.map((db) => ( + + ))} + + )} + + +
+ {bootstrap.isError ? ( +
Failed to load bootstrap config
+ ) : !selected ? ( +
Select a database to run queries.
+ ) : ( +
+
project
+
{bootstrap.data?.projectId ?? '…'}
+
database
+
+ {selected.name} ({selected.id}) +
+
+ Query widget will render here in step 2. +
+
+ )} +
+ + ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..1d5d169 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { App } from './app'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false } }, +}); + +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('missing #root element'); + +createRoot(rootElement).render( + + + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts new file mode 100644 index 0000000..fdb99b1 --- /dev/null +++ b/web/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { extend: {} }, + plugins: [], +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f8e03ce --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..e87787f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const ghostServePort = process.env.GHOST_SERVE_DEV_PORT ?? '5174'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: false, + proxy: { + '/api': { target: `http://127.0.0.1:${ghostServePort}`, changeOrigin: true }, + '/healthz': { target: `http://127.0.0.1:${ghostServePort}`, changeOrigin: true }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + sourcemap: false, + }, +}); From 4cec245aa84550b8048e48207ef22fbb18788066 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 28 May 2026 18:39:20 -0400 Subject: [PATCH 03/39] Fix build-web.sh and pin widget version - bun does not honor --cwd as a global flag in front of `run`; rewrite build-web.sh to cd into web/ instead. - Pin @timescale/popsql-query-widget to 0.0.0-dev.156 (the latest published canary; the package is not yet on a stable version line). - Commit web/bun.lock and ignore web/tsconfig.tsbuildinfo. Full Step 1 pipeline now validates end-to-end: scripts/build-web.sh produces internal/serve/web/{index.html,assets/...} and the rebuilt Go binary embeds + serves them with correct cache headers and SPA fallback. --- .gitignore | 1 + plan.md | 14 +- scripts/build-web.sh | 4 +- web/bun.lock | 414 +++++++++++++++++++++++++++++++++++++++++++ web/package.json | 2 +- 5 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 web/bun.lock diff --git a/.gitignore b/.gitignore index fb9f112..eda5d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,5 +66,6 @@ new.md web/node_modules/ web/dist/ web/.npmrc +web/tsconfig.tsbuildinfo internal/serve/web/* !internal/serve/web/.gitkeep diff --git a/plan.md b/plan.md index 1bf8ec1..48f0469 100644 --- a/plan.md +++ b/plan.md @@ -447,11 +447,23 @@ Why not the memory-engine base64-into-TS approach: Go's `embed.FS` is the natura - [x] Discovery: `../ox/bun` self-bootstrap wrapper - [x] Discovery: `web-cloud/.yarnrc.yml` + deploy-to-dev GH workflow → bun equivalent - [x] Discovery: Arrow Go module path + pgx version pinning -- [ ] Step 1 — `ghost serve` skeleton + static SPA + `/api/databases` +- [x] Step 1 — `ghost serve` skeleton + static SPA + `/api/databases` (Go side validated end-to-end; SPA build pending widget npm auth — see below) - [ ] Step 2 — query execution path (executeQuery, arrowResults, sessions, cancel) - [ ] Step 3 — polish, docs, ungate from `GHOST_EXPERIMENTAL` - [ ] E2E test pass +### Blocker: `@timescale/popsql-query-widget` private-registry auth + +Bun install gets 403 from `https://npm.pkg.github.com/@timescale%2fpopsql-query-widget` because the default `gh auth` token only has `repo` / `read:org` scopes, not `read:packages`. The bun bootstrap itself and the rest of the dependency graph (407 packages) resolved fine. + +Fix options for the user: +1. `gh auth refresh -h github.com -s read:packages` (adds the scope to the existing gh CLI token; nothing else changes). +2. Create a fine-grained PAT at github.com/settings/tokens with `read:packages` scope and export `NPM_AUTH_TOKEN=` (one-off / CI-friendly). + +In CI, the auto-provisioned `secrets.GITHUB_TOKEN` already has `read:packages` (it does for repos that own the package, which we assume `timescale/ghost` does — needs confirmation). + +Once auth is unblocked, `./scripts/build-web.sh` produces `web/dist/` and the embed pipeline picks it up automatically on the next Go build. + --- ## Discovery findings diff --git a/scripts/build-web.sh b/scripts/build-web.sh index 5b67894..12291c1 100755 --- a/scripts/build-web.sh +++ b/scripts/build-web.sh @@ -15,8 +15,8 @@ if [ ! -f web/.npmrc ]; then cp web/.npmrc.example web/.npmrc fi -./bun install --cwd web -./bun --cwd web run build +(cd web && ../bun install) +(cd web && ../bun run build) embedDir="internal/serve/web" find "$embedDir" -mindepth 1 ! -name '.gitkeep' -delete diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..b73b38f --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,414 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@ghost-cli/web", + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "@timescale/popsql-query-widget": "0.0.0-dev.156", + "framer-motion": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^7.0.0", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@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/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@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/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], + + "@timescale/popsql-query-widget": ["@timescale/popsql-query-widget@0.0.0-dev.156", "https://npm.pkg.github.com/download/@timescale/popsql-query-widget/0.0.0-dev.156/1a2659b7cd771139256d59ba9655dd53b757a598", { "peerDependencies": { "framer-motion": "12.x", "react": "17.x || 18.x", "react-dom": "17.x || 18.x" } }, "sha512-IGg2Cn25MwvjVKpLnwquNSGtLeFehWMWcnEokJJJEpjB4jmvTkZfSssWd8TAP2qR/rYee2AyzfmcY0DKanokrQ=="], + + "@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__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@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=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "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=="], + + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.363", "", {}, "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "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-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "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=="], + + "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=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "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=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "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=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.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-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + } +} diff --git a/web/package.json b/web/package.json index 0cef94e..dfc9e02 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.7", - "@timescale/popsql-query-widget": "^0.0.1", + "@timescale/popsql-query-widget": "0.0.0-dev.156", "framer-motion": "^12.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" From 384b437bb15d14f1efaa65e87f62279e7a1dd75e Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Fri, 29 May 2026 11:22:33 -0400 Subject: [PATCH 04/39] Wire the popsql query widget into ghost serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the ghost serve plan: in-process query execution that matches the popsql-query-widget wire contract, no proxy to ghost-api or the savannah gateway needed. What is here: internal/serve/dbtypes/ Ported verbatim from popsql-query/internal/types/. Custom scan receivers that preserve database-side formatting and special values (NaN, +/-Infinity, JSON, bytea hex, plain DATE/TIMESTAMP). GUID (MSSQL-only) is dropped. internal/serve/dbdriver/ Postgres-only port of popsql-query/internal/driver/. Provides: - Driver/baseDriver interfaces and a Postgres adapter backed by pgx/v5/stdlib so we get OID-aware ColumnType.ScanType(). - cancelContext, which wires pgConn.CancelRequest into the parent context so query cancellation flows through pg_cancel_backend. - NormalizedError shape matching the widget's failure type. Drops the SSH tunnel, SSL custom-config, and multi-driver registry (ghost only targets Timescale Postgres). Uses SimpleProtocol exec mode so user-typed multi-statement SQL with comments works the same way ghost sql does. internal/serve/arrow.go Ported from popsql-query/internal/writer/arrow.go. RecordBuilder wraps array.RecordBuilder, appends the synthetic __popsql_row_num__ column, and embeds the original column descriptors in the schema metadata under __popsql_columns__ (both contracts the widget's table renderer assumes). internal/serve/wire.go Request/response types matching what @popsql/query-client's TimescaleQueryClient sends. The widget puts the SQL in a `statements` array (not the legacy `query` field) so we read both. internal/serve/store.go In-memory run + session registries. internal/serve/connect.go Resolves database via ghost-api, runs CheckReady, fetches the tsdbadmin password (with the same actionable error if missing), and opens a pgx driver against the resulting DSN. internal/serve/execute.go POST /api/executeQuery (one-shot) and POST /api/executeSessionQuery. Streams a single NDJSON columns line, blocks on Run.done while arrowResults consumes the rows, then emits the success/error terminator. Client disconnect cancels the underlying PG query. internal/serve/arrow_results.go POST /api/arrowResults. Streams an Apache Arrow IPC record batch per 1024 rows, then closes Run.done so executeQuery can finalize. internal/serve/session.go POST /api/createSession / sessionEvents (long-lived NDJSON status stream) / closeSession. The widget's session manager retries sessionEvents up to 15 times on disconnect, so transient hiccups are handled for free. internal/serve/cancel.go POST /api/cancelRun -> pgConn.CancelRequest. web/src/components/QueryPanel.tsx Renders the unmodified popsql QueryWidget against the local server. sessionKey is derived from the database ID so changing the picker invalidates the session and the underlying PG connection. web/vite.config.ts - copyPopsqlQueryWidgetAssets plugin (ported from web-cloud) emits the duckdb / monaco worker + wasm sidecars that the widget loads via `new URL(, import.meta.url)` (Vite's static analysis misses them). - vite-plugin-node-polyfills shims Buffer / crypto / process / stream — same list web-cloud uses with this widget. - optimizeDeps.exclude on the widget so its workers resolve their sibling chunks. web/package.json Pin React 18.3 (widget calls findDOMNode which is gone in React 19), add @timescale/popsql-query-widget@0.0.0-dev.156 and vite-plugin-node-polyfills. End-to-end smoke-tested against a real Timescale Postgres database: - SELECT 1; renders 1 row in the result table. - SELECT 1::int, 'hello'::text, '2024-01-01'::date, '2024-01-01 12:00:00+00'::timestamptz, 3.14::numeric, '{"k":"v"}'::jsonb renders all six columns with the right types. - Sessions: changing the DB picker invalidates the session and a fresh connection opens transparently. --- go.mod | 10 +- go.sum | 30 ++- internal/serve/arrow.go | 319 ++++++++++++++++++++++++++++ internal/serve/arrow_results.go | 99 +++++++++ internal/serve/cancel.go | 28 +++ internal/serve/connect.go | 89 ++++++++ internal/serve/dbdriver/api.go | 72 +++++++ internal/serve/dbdriver/cancel.go | 44 ++++ internal/serve/dbdriver/driver.go | 198 +++++++++++++++++ internal/serve/dbdriver/postgres.go | 175 +++++++++++++++ internal/serve/dbdriver/rows.go | 174 +++++++++++++++ internal/serve/dbtypes/binary.go | 22 ++ internal/serve/dbtypes/date.go | 99 +++++++++ internal/serve/dbtypes/json.go | 37 ++++ internal/serve/dbtypes/numeric.go | 18 ++ internal/serve/dbtypes/types.go | 90 ++++++++ internal/serve/execute.go | 178 ++++++++++++++++ internal/serve/server.go | 35 ++- internal/serve/session.go | 123 +++++++++++ internal/serve/store.go | 145 +++++++++++++ internal/serve/wire.go | 112 ++++++++++ web/bun.lock | 291 ++++++++++++++++++++++++- web/package.json | 11 +- web/src/app.tsx | 23 +- web/src/components/QueryPanel.tsx | 51 +++++ web/vite.config.ts | 45 +++- 26 files changed, 2475 insertions(+), 43 deletions(-) create mode 100644 internal/serve/arrow.go create mode 100644 internal/serve/arrow_results.go create mode 100644 internal/serve/cancel.go create mode 100644 internal/serve/connect.go create mode 100644 internal/serve/dbdriver/api.go create mode 100644 internal/serve/dbdriver/cancel.go create mode 100644 internal/serve/dbdriver/driver.go create mode 100644 internal/serve/dbdriver/postgres.go create mode 100644 internal/serve/dbdriver/rows.go create mode 100644 internal/serve/dbtypes/binary.go create mode 100644 internal/serve/dbtypes/date.go create mode 100644 internal/serve/dbtypes/json.go create mode 100644 internal/serve/dbtypes/numeric.go create mode 100644 internal/serve/dbtypes/types.go create mode 100644 internal/serve/execute.go create mode 100644 internal/serve/session.go create mode 100644 internal/serve/store.go create mode 100644 internal/serve/wire.go create mode 100644 web/src/components/QueryPanel.tsx diff --git a/go.mod b/go.mod index c0e50df..b515798 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,11 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 charm.land/lipgloss/v2 v2.0.3 + github.com/apache/arrow-go/v18 v18.6.0 github.com/charmbracelet/colorprofile v0.4.3 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgpassfile v1.0.0 github.com/jackc/pgx/v5 v5.9.2 github.com/modelcontextprotocol/go-sdk v1.5.0 @@ -56,10 +58,13 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -75,6 +80,7 @@ require ( github.com/olekukonko/ll v0.1.8 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -89,7 +95,9 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect diff --git a/go.sum b/go.sum index 5235ebb..929fed0 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,12 @@ charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6AT github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.6.0 h1:GX/Jyd3R7mCLiECAwY9FWbbaYblie2WXBSz4Sw8fNpM= +github.com/apache/arrow-go/v18 v18.6.0/go.mod h1:gm3MiPpY82fLYK5VKPB3WoJbsiLVDfT7flD5/vHReKw= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= @@ -41,8 +47,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -80,6 +87,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -106,6 +115,10 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -164,8 +177,11 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4 github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -222,6 +238,10 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -229,8 +249,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -289,6 +309,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/serve/arrow.go b/internal/serve/arrow.go new file mode 100644 index 0000000..e305a1f --- /dev/null +++ b/internal/serve/arrow.go @@ -0,0 +1,319 @@ +package serve + +import ( + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/apache/arrow-go/v18/arrow/memory" + + "github.com/timescale/ghost/internal/serve/dbdriver" + "github.com/timescale/ghost/internal/serve/dbtypes" +) + +// Ported from github.com/timescale/popsql-query/internal/writer/arrow.go. +// Schema metadata + the synthetic __popsql_row_num__ column are preserved +// because the widget's table renderer depends on both. + +const columnsMetadataKey = "__popsql_columns__" + +var ( + rowNumField = arrow.Field{ + Name: "__popsql_row_num__", + Type: arrow.PrimitiveTypes.Int64, + } + rowNumBuilderFn = basicBuilderFn[*array.Int64Builder, int64] +) + +// arrowBuilder wraps an array.Builder and exposes AppendValue, which accepts +// values of type 'any' and routes them through a column-specific builderFn. +type arrowBuilder interface { + array.Builder + AppendValue(val any) error +} + +type builderFn func(builder array.Builder, val any) error + +type columnBuilder struct { + array.Builder + fn builderFn +} + +func (c *columnBuilder) AppendValue(val any) error { return c.fn(c.Builder, val) } + +// RecordBuilder is a thin wrapper around array.RecordBuilder that appends a +// synthetic row-number column and exposes AppendRow for []any values. +type RecordBuilder struct { + *array.RecordBuilder + fields []arrowBuilder + recordRowCount int64 + totalRowCount int64 +} + +// NewRecordBuilder builds an Arrow schema from the supplied columns and +// returns a RecordBuilder ready to append rows. +func NewRecordBuilder(columns dbdriver.Columns) (*RecordBuilder, error) { + schema, builderFns, err := arrowSchema(columns) + if err != nil { + return nil, err + } + + rb := array.NewRecordBuilder(memory.DefaultAllocator, schema) + fields := make([]arrowBuilder, schema.NumFields()) + for i, field := range rb.Fields() { + fields[i] = &columnBuilder{Builder: field, fn: builderFns[i]} + } + return &RecordBuilder{RecordBuilder: rb, fields: fields}, nil +} + +// AppendRow appends a single row + populates the synthetic row-num column. +// The row must contain one entry per column in the same order as the +// dbdriver.Columns passed to NewRecordBuilder. +func (rb *RecordBuilder) AppendRow(row []any) error { + for i, val := range row { + if err := rb.fields[i].AppendValue(val); err != nil { + return err + } + } + if err := rb.fields[len(row)].AppendValue(rb.totalRowCount); err != nil { + return err + } + rb.recordRowCount++ + rb.totalRowCount++ + return nil +} + +func (rb *RecordBuilder) RecordRowCount() int64 { return rb.recordRowCount } +func (rb *RecordBuilder) TotalRowCount() int64 { return rb.totalRowCount } + +// NewRecordBatch finalizes the in-progress record and resets the row counter. +func (rb *RecordBuilder) NewRecordBatch() arrow.RecordBatch { + rb.recordRowCount = 0 + return rb.RecordBuilder.NewRecordBatch() +} + +func arrowSchema(columns dbdriver.Columns) (*arrow.Schema, []builderFn, error) { + fields := make([]arrow.Field, len(columns)+1) + builderFns := make([]builderFn, len(columns)+1) + for i, column := range columns { + arrowType, builderFn := arrowType(column) + fields[i] = arrow.Field{ + Name: column.Name, + Type: arrowType, + Nullable: true, + } + builderFns[i] = builderFn + } + fields[len(columns)] = rowNumField + builderFns[len(columns)] = rowNumBuilderFn + + columnJSON, err := json.Marshal(columns) + if err != nil { + return nil, nil, fmt.Errorf("marshalling columns to JSON: %w", err) + } + metadata := arrow.NewMetadata( + []string{columnsMetadataKey}, + []string{string(columnJSON)}, + ) + return arrow.NewSchema(fields, &metadata), builderFns, nil +} + +var ( + boolBuilderFn = basicBuilderFn[*array.BooleanBuilder, bool] + float32BuilderFn = basicBuilderFn[*array.Float32Builder, float32] + float64BuilderFn = basicBuilderFn[*array.Float64Builder, float64] + intBuilderFn = convertBuilderFn[*array.Int64Builder](castToInt64[int]) + int8BuilderFn = basicBuilderFn[*array.Int8Builder, int8] + int16BuilderFn = basicBuilderFn[*array.Int16Builder, int16] + int32BuilderFn = basicBuilderFn[*array.Int32Builder, int32] + int64BuilderFn = basicBuilderFn[*array.Int64Builder, int64] + uintBuilderFn = convertBuilderFn[*array.Uint64Builder](castToUint64[uint]) + uint8BuilderFn = basicBuilderFn[*array.Uint8Builder, uint8] + uint16BuilderFn = basicBuilderFn[*array.Uint16Builder, uint16] + uint32BuilderFn = basicBuilderFn[*array.Uint32Builder, uint32] + uint64BuilderFn = basicBuilderFn[*array.Uint64Builder, uint64] + stringBuilderFn = basicBuilderFn[*array.StringBuilder, string] + binaryBuilderFn = basicBuilderFn[*array.BinaryBuilder, []byte] + timeBuilderFn = convertBuilderFn[*array.StringBuilder](timeToStr) + dateBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.Date]) + clockTimeBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.ClockTime]) + clockTimeTZBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.ClockTimeTZ]) + dateTimeBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.DateTime]) + timestampBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.Timestamp]) + numericBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.Numeric]) + jsonBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.JSON]) + binaryStrBuilderFn = convertBuilderFn[*array.StringBuilder](castToStr[dbtypes.Binary]) +) + +func arrowType(column dbdriver.Column) (arrow.DataType, builderFn) { + switch column.ScanType { + case dbtypes.BoolType, dbtypes.BoolPtrType: + return arrow.FixedWidthTypes.Boolean, boolBuilderFn + case dbtypes.Float32Type, dbtypes.Float32PtrType: + return arrow.PrimitiveTypes.Float32, float32BuilderFn + case dbtypes.Float64Type, dbtypes.Float64PtrType: + return arrow.PrimitiveTypes.Float64, float64BuilderFn + case dbtypes.IntType, dbtypes.IntPtrType: + return arrow.PrimitiveTypes.Int64, intBuilderFn + case dbtypes.Int8Type, dbtypes.Int8PtrType: + return arrow.PrimitiveTypes.Int8, int8BuilderFn + case dbtypes.Int16Type, dbtypes.Int16PtrType: + return arrow.PrimitiveTypes.Int16, int16BuilderFn + case dbtypes.Int32Type, dbtypes.Int32PtrType: + return arrow.PrimitiveTypes.Int32, int32BuilderFn + case dbtypes.Int64Type, dbtypes.Int64PtrType: + return arrow.PrimitiveTypes.Int64, int64BuilderFn + case dbtypes.UintType, dbtypes.UintPtrType: + return arrow.PrimitiveTypes.Uint64, uintBuilderFn + case dbtypes.Uint8Type, dbtypes.Uint8PtrType: + return arrow.PrimitiveTypes.Uint8, uint8BuilderFn + case dbtypes.Uint16Type, dbtypes.Uint16PtrType: + return arrow.PrimitiveTypes.Uint16, uint16BuilderFn + case dbtypes.Uint32Type, dbtypes.Uint32PtrType: + return arrow.PrimitiveTypes.Uint32, uint32BuilderFn + case dbtypes.Uint64Type, dbtypes.Uint64PtrType: + return arrow.PrimitiveTypes.Uint64, uint64BuilderFn + case dbtypes.StringType, dbtypes.StringPtrType: + return arrow.BinaryTypes.String, stringBuilderFn + case dbtypes.BytesType, dbtypes.BytesPtrType: + return arrow.BinaryTypes.Binary, binaryBuilderFn + case dbtypes.TimeType, dbtypes.TimePtrType: + return arrow.BinaryTypes.String, timeBuilderFn + case dbtypes.DateType, dbtypes.DatePtrType: + return arrow.BinaryTypes.String, dateBuilderFn + case dbtypes.ClockTimeType, dbtypes.ClockTimePtrType: + return arrow.BinaryTypes.String, clockTimeBuilderFn + case dbtypes.ClockTimeTZType, dbtypes.ClockTimeTZPtrType: + return arrow.BinaryTypes.String, clockTimeTZBuilderFn + case dbtypes.DateTimeType, dbtypes.DateTimePtrType: + return arrow.BinaryTypes.String, dateTimeBuilderFn + case dbtypes.TimestampType, dbtypes.TimestampPtrType: + return arrow.BinaryTypes.String, timestampBuilderFn + case dbtypes.NumericType, dbtypes.NumericPtrType: + return arrow.BinaryTypes.String, numericBuilderFn + case dbtypes.JSONType, dbtypes.JSONPtrType: + return arrow.BinaryTypes.String, jsonBuilderFn + case dbtypes.BinaryType, dbtypes.BinaryPtrType: + return arrow.BinaryTypes.String, binaryStrBuilderFn + } + return arrow.BinaryTypes.String, unknownBuilderFn +} + +type arrowAppender[T any] interface { + Append(value T) + AppendNull() +} + +func basicBuilderFn[A arrowAppender[T], T any](builder array.Builder, value any) error { + b := builder.(A) + switch val := (value).(type) { + case nil: + b.AppendNull() + case T: + b.Append(val) + case *T: + if val == nil { + b.AppendNull() + } else { + b.Append(*val) + } + default: + return fmt.Errorf("arrow: cannot append %T as %T", value, *new(T)) + } + return nil +} + +func convertBuilderFn[A arrowAppender[T], V any, T any](convert func(V) T) builderFn { + return func(builder array.Builder, value any) error { + b := builder.(A) + switch val := (value).(type) { + case nil: + builder.AppendNull() + case V: + b.Append(convert(val)) + case *V: + if val == nil { + builder.AppendNull() + } else { + b.Append(convert(*val)) + } + default: + return fmt.Errorf("arrow: cannot append %T as %T", value, *new(T)) + } + return nil + } +} + +func timeToStr(value time.Time) string { return value.Format(time.RFC3339Nano) } + +type stringish interface{ ~string | ~[]byte } + +func castToStr[T stringish](value T) string { return string(value) } + +type int64ish interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 } + +func castToInt64[T int64ish](value T) int64 { return int64(value) } + +type uint64ish interface{ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 } + +func castToUint64[T uint64ish](value T) uint64 { return uint64(value) } + +func unknownBuilderFn(builder array.Builder, value any) error { + b := builder.(*array.StringBuilder) + switch val := value.(type) { + case nil: + b.AppendNull() + case string: + b.Append(val) + case *string: + if val == nil { + b.AppendNull() + } else { + b.Append(*val) + } + case []byte: + if val == nil { + b.AppendNull() + } else { + b.Append(string(val)) + } + case *[]byte: + if val == nil || *val == nil { + b.AppendNull() + } else { + b.Append(string(*val)) + } + case *any: + if val == nil { + b.AppendNull() + } else { + return unknownBuilderFn(builder, *val) + } + default: + if shouldMarshalJSON(reflect.TypeOf(val)) { + if out, err := json.Marshal(val); err == nil { + b.Append(string(out)) + return nil + } + } + b.Append(fmt.Sprint(val)) + } + return nil +} + +// shouldMarshalJSON returns true for compound types (arrays, slices, maps, +// structs) that aren't sql.Scanner-compliant. Most non-Postgres drivers don't +// hit this in our use case, but it's preserved for parity with popsql-query. +func shouldMarshalJSON(t reflect.Type) bool { + switch t.Kind() { + case reflect.Pointer: + return shouldMarshalJSON(t.Elem()) + case reflect.Array, reflect.Slice, reflect.Map, reflect.Struct: + return true + default: + return false + } +} diff --git a/internal/serve/arrow_results.go b/internal/serve/arrow_results.go new file mode 100644 index 0000000..455850b --- /dev/null +++ b/internal/serve/arrow_results.go @@ -0,0 +1,99 @@ +package serve + +import ( + "encoding/json" + "net/http" + + "github.com/apache/arrow-go/v18/arrow/ipc" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +const arrowBatchRows = 1024 + +// handleArrowResults serves POST /api/arrowResults. The widget fires this +// immediately after seeing the executeQuery columns line and expects a raw +// Arrow IPC stream of rows. We iterate the run's rows in-place, build +// record batches, and stream them out; when iteration finishes we signal +// Run.done so the executeQuery handler can emit its terminator. +func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { + var req arrowResultsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + + run := s.runs.get(req.RunID) + if run == nil { + http.NotFound(w, r) + return + } + <-run.ready + + rb, err := NewRecordBuilder(run.columns) + if err != nil { + http.Error(w, "arrow schema: "+err.Error(), http.StatusInternalServerError) + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + run.closeDone() + return + } + defer rb.Release() + + w.Header().Set("Content-Type", "application/vnd.apache.arrow.stream") + w.Header().Set("Cache-Control", "no-store") + + ipcWriter := ipc.NewWriter(w, ipc.WithSchema(rb.Schema())) + defer ipcWriter.Close() + + targets := run.columns.ScanTargets() + for run.rows.Next() { + if err := r.Context().Err(); err != nil { + run.setError(&dbdriver.NormalizedError{Message: "request canceled", Source: "ghost", Cancel: true}) + break + } + if err := run.rows.Scan(targets...); err != nil { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + break + } + if err := rb.AppendRow(targets.Values()); err != nil { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + break + } + if rb.RecordRowCount() >= arrowBatchRows { + if err := flushBatch(ipcWriter, rb, w); err != nil { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + break + } + } + } + if rb.RecordRowCount() > 0 { + if err := flushBatch(ipcWriter, rb, w); err != nil && run.err == nil { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + } + } + + if err := run.rows.Err(); err != nil && run.err == nil { + // Defer to the driver's normalizer so PG errors carry code/hint/etc. + if run.driver != nil { + run.setError(run.driver.NormalizeError(run.queryCtx, err)) + } else { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "postgres"}) + } + } + + run.rowCount = rb.TotalRowCount() + if rowsAffected, _ := run.rows.RowsAffected(r.Context()); rowsAffected != nil { + run.rowsAffected = rowsAffected + } + run.closeDone() +} + +func flushBatch(ipcWriter *ipc.Writer, rb *RecordBuilder, w http.ResponseWriter) error { + batch := rb.NewRecordBatch() + defer batch.Release() + if err := ipcWriter.Write(batch); err != nil { + return err + } + flushWriter(w) + return nil +} diff --git a/internal/serve/cancel.go b/internal/serve/cancel.go new file mode 100644 index 0000000..e6461d0 --- /dev/null +++ b/internal/serve/cancel.go @@ -0,0 +1,28 @@ +package serve + +import ( + "encoding/json" + "net/http" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// handleCancelRun serves POST /api/cancelRun. The widget rarely uses this +// path (it prefers AbortController on the executeQuery request), but we +// support it: looking up the run by ID and triggering its queryCtx cancel, +// which routes through pgConn.CancelRequest server-side. +func (s *Server) handleCancelRun(w http.ResponseWriter, r *http.Request) { + var req cancelQueryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + run := s.runs.get(req.RunID) + if run == nil { + http.NotFound(w, r) + return + } + run.setError(&dbdriver.NormalizedError{Message: "query canceled by user", Source: "ghost", Cancel: true}) + run.cancelQuery() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/serve/connect.go b/internal/serve/connect.go new file mode 100644 index 0000000..7e64e7c --- /dev/null +++ b/internal/serve/connect.go @@ -0,0 +1,89 @@ +package serve + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/timescale/ghost/internal/api" + "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// connectErr is a typed wrapper that carries a NormalizedError with +// Connect:true. Returned by openDriverForService when something goes wrong +// before the query starts (DB not found, not ready, missing password, TLS +// failure). +type connectErr struct { + norm *dbdriver.NormalizedError +} + +func (c *connectErr) Error() string { return c.norm.Message } +func (c *connectErr) Normalized() *dbdriver.NormalizedError { return c.norm } + +func newConnectErr(format string, args ...any) *connectErr { + return &connectErr{ + norm: &dbdriver.NormalizedError{ + Message: fmt.Sprintf(format, args...), + Source: "ghost", + Connect: true, + }, + } +} + +// fetchDatabase loads the ghost-api Database record + ready check. +func fetchDatabase(ctx context.Context, client api.ClientWithResponsesInterface, projectID, databaseRef string) (api.Database, error) { + resp, err := client.GetDatabaseWithResponse(ctx, projectID, databaseRef) + if err != nil { + return api.Database{}, newConnectErr("fetching database: %v", err) + } + if resp.StatusCode() != http.StatusOK { + if resp.JSONDefault != nil { + return api.Database{}, newConnectErr("ghost-api: %s", resp.JSONDefault.Message) + } + return api.Database{}, newConnectErr("ghost-api returned %d", resp.StatusCode()) + } + if resp.JSON200 == nil { + return api.Database{}, newConnectErr("empty response from ghost-api") + } + return *resp.JSON200, nil +} + +// defaultRole matches the role used by ghost sql / connect / etc. +const defaultRole = "tsdbadmin" + +// openDriverForService resolves a ghost-api database, retrieves the password +// for the default role, and opens a Postgres driver against it. +func openDriverForService(ctx context.Context, client api.ClientWithResponsesInterface, projectID, serviceID string) (dbdriver.Driver, error) { + database, err := fetchDatabase(ctx, client, projectID, serviceID) + if err != nil { + return nil, err + } + if err := common.CheckReady(database); err != nil { + return nil, newConnectErr("%v", err) + } + + password, err := common.GetPassword(database, defaultRole) + if err != nil { + if errors.Is(err, common.ErrPasswordNotFound) { + return nil, newConnectErr("no password found for database %s; run `ghost password %s` or add an entry to ~/.pgpass", database.Name, database.Id) + } + return nil, newConnectErr("retrieving password: %v", err) + } + + connStr, err := common.BuildConnectionString(common.ConnectionStringArgs{ + Database: database, + Role: defaultRole, + Password: password, + }) + if err != nil { + return nil, newConnectErr("building connection string: %v", err) + } + + driver, err := dbdriver.OpenPostgresDSN(ctx, connStr) + if err != nil { + return nil, newConnectErr("connecting: %v", err) + } + return driver, nil +} diff --git a/internal/serve/dbdriver/api.go b/internal/serve/dbdriver/api.go new file mode 100644 index 0000000..659037e --- /dev/null +++ b/internal/serve/dbdriver/api.go @@ -0,0 +1,72 @@ +// Package dbdriver wraps database/sql + pgx to give us OID-aware column +// scan-type inference, server-side query cancellation, and a Postgres error +// normalizer suitable for projecting into the wire format the +// popsql-query-widget expects. +// +// This is a trimmed-down port of github.com/timescale/popsql-query's +// internal/driver package — Postgres only, no SSH tunneling, no multi-driver +// adapter registry, and no logging side-effects. +package dbdriver + +import ( + "errors" + "reflect" +) + +// ColumnCase controls how column names are presented to the widget. Today we +// always emit them as-is. +type ColumnCase string + +const ( + ColumnCaseDefault ColumnCase = "" + ColumnCaseLower ColumnCase = "lower" + ColumnCaseUpper ColumnCase = "upper" +) + +// Column carries column metadata to the widget. JSON shape matches the +// "Column" type defined by @popsql/types and consumed by +// popsql-query-widget's TimescaleQueryClient. +type Column struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Length int64 `json:"length,omitempty"` + Precision int64 `json:"precision,omitempty"` + Scale int64 `json:"scale,omitempty"` + Object bool `json:"isObject,omitempty"` + Numeric bool `json:"isNumeric,omitempty"` + ScanType reflect.Type `json:"-"` +} + +// Metadata is reserved for future use; popsql-query's Metadata is only +// populated by BigQuery's bytes-processed counter. +type Metadata struct { + BytesProcessed int64 `json:"bytesProcessed"` +} + +// NormalizedError is the canonical error shape consumed by the widget. The +// JSON shape mirrors @popsql/types' ApiFailedResult error. +type NormalizedError struct { + Code string `json:"code,omitempty"` + Column int32 `json:"column,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` + Line int32 `json:"line,omitempty"` + Message string `json:"message"` + Position int32 `json:"position,omitempty"` + + Source string `json:"source"` + + Connect bool `json:"connect,omitempty"` + Fatal bool `json:"fatal,omitempty"` + Timeout bool `json:"timeout,omitempty"` + Cancel bool `json:"cancel,omitempty"` +} + +func (e *NormalizedError) Error() string { return e.Message } + +// ErrMultiStatement is returned when the user attempts to run multiple +// statements in a single query. Multi-statement support requires either an +// extended-protocol round trip per statement or pgx's "simple protocol" mode, +// which interpolates parameters client-side. For now we follow popsql-query's +// posture and reject the case. +var ErrMultiStatement = errors.New("cannot run multiple statements in a single query") diff --git a/internal/serve/dbdriver/cancel.go b/internal/serve/dbdriver/cancel.go new file mode 100644 index 0000000..6818d01 --- /dev/null +++ b/internal/serve/dbdriver/cancel.go @@ -0,0 +1,44 @@ +package dbdriver + +import ( + "context" +) + +// canceler runs when the parent context of a query is canceled. The +// Postgres driver passes a closure that issues `pg_cancel_backend()` via a +// side-channel connection. +type canceler func(ctx context.Context) error + +// cancelContext returns a fresh context (NOT a child of parent) plus a +// CancelFunc. When parent is canceled the supplied canceler is invoked; if +// the canceler returns an error we propagate parent's cancellation cause +// into the returned context as a fallback. The returned CancelFunc must be +// called when the query is finished to release the watcher goroutine. +// +// The reason for the not-a-child context is so that pgx does not abort the +// query mid-flight on its own — we want a graceful cancel through Postgres, +// so we can still surface a useful error to the client. +func cancelContext(parent context.Context, fn canceler) (context.Context, context.CancelFunc) { + newCtx, cancel := context.WithCancelCause(context.Background()) + + quit := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + select { + case <-parent.Done(): + if err := fn(newCtx); err != nil { + // Fall back to immediate cancel if the server-side cancel + // failed (e.g. the backend connection is already dead). + cancel(parent.Err()) + } + case <-quit: + } + }() + + return newCtx, func() { + close(quit) + <-done + cancel(nil) + } +} diff --git a/internal/serve/dbdriver/driver.go b/internal/serve/dbdriver/driver.go new file mode 100644 index 0000000..8e2bbd7 --- /dev/null +++ b/internal/serve/dbdriver/driver.go @@ -0,0 +1,198 @@ +package dbdriver + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "net" + "reflect" + "time" + + "github.com/timescale/ghost/internal/serve/dbtypes" +) + +// Driver runs arbitrary SQL queries against a database connection. It wraps +// the underlying database/sql connection and adds OID-aware scan type +// inference, error normalization, and server-side cancellation hooks. +type Driver interface { + Ping(ctx context.Context) error + PingInterval() time.Duration + + // Context returns a context (not necessarily a child of ctx) that should + // be passed to Query. CancelFunc must be invoked once the query completes. + Context(ctx context.Context) (context.Context, context.CancelFunc) + + // Query issues a SQL statement and returns Rows. The context returned by + // Context must be the one passed in. + Query(ctx context.Context, args QueryArgs) (Rows, error) + + // NormalizeError adapts a database/sql or driver-specific error to the + // wire NormalizedError shape expected by the widget. + NormalizeError(ctx context.Context, err error) *NormalizedError + + Close() error +} + +// QueryArgs is the input to Driver.Query. +type QueryArgs struct { + Query string + ColumnCase ColumnCase +} + +// baseDriver is the standard implementation, shared by every concrete driver. +// Driver-specific behavior (e.g. Postgres OID overrides, cancellation via +// pgconn.CancelRequest) is layered on top by embedding this type. +type baseDriver struct { + client string + db *sql.DB + conn *sql.Conn +} + +func (b *baseDriver) Ping(ctx context.Context) error { + return b.conn.PingContext(ctx) +} + +func (b *baseDriver) PingInterval() time.Duration { + return 5 * time.Second +} + +func (b *baseDriver) Context(ctx context.Context) (context.Context, context.CancelFunc) { + return ctx, func() {} +} + +func (b *baseDriver) Query(ctx context.Context, args QueryArgs) (Rows, error) { + return b.query(ctx, args, b.scanType) +} + +func (b *baseDriver) query(ctx context.Context, args QueryArgs, scanTypeFn scanTypeFn) (*baseRows, error) { + rows, err := b.conn.QueryContext(ctx, args.Query) + if err != nil { + return nil, err + } + return &baseRows{ + Rows: rows, + columnCase: args.ColumnCase, + scanTypeFn: scanTypeFn, + }, nil +} + +// scanType maps a [sql.ColumnType] to the concrete Go type to scan into. The +// base implementation collapses sql.Null* into pointer-to-primitive (which +// JSON-encodes more cleanly) and ensures every scan target is addressable as +// a pointer so NULLs can be detected. +func (b *baseDriver) scanType(columnType *sql.ColumnType) reflect.Type { + t := columnType.ScanType() + switch t { + case dbtypes.NullBoolType, dbtypes.NullBoolPtrType: + t = dbtypes.BoolType + case dbtypes.NullByteType, dbtypes.NullBytePtrType: + t = dbtypes.ByteType + case dbtypes.NullFloat64Type, dbtypes.NullFloat64PtrType: + t = dbtypes.Float64Type + case dbtypes.NullInt16Type, dbtypes.NullInt16PtrType: + t = dbtypes.Int16Type + case dbtypes.NullInt32Type, dbtypes.NullInt32PtrType: + t = dbtypes.Int32Type + case dbtypes.NullInt64Type, dbtypes.NullInt64PtrType: + t = dbtypes.Int64Type + case dbtypes.NullStringType, dbtypes.NullStringPtrType: + t = dbtypes.StringType + case dbtypes.NullTimeType, dbtypes.NullTimePtrType: + t = dbtypes.TimeType + case dbtypes.RawBytesType: + t = dbtypes.BytesType + case nil: + t = dbtypes.AnyType + } + + switch t.Kind() { + case reflect.Pointer, reflect.Interface: + default: + t = reflect.PointerTo(t) + } + return t +} + +func (b *baseDriver) NormalizeError(ctx context.Context, err error) *NormalizedError { + ctxErr := context.Cause(ctx) + return &NormalizedError{ + Message: b.errMessage(err), + Source: b.client, + Fatal: b.fatal(err), + Timeout: errors.Is(ctxErr, context.DeadlineExceeded), + Cancel: errors.Is(ctxErr, context.Canceled), + } +} + +func (b *baseDriver) errMessage(err error) string { + if errors.Is(err, io.ErrUnexpectedEOF) { + return "the database connection was terminated unexpectedly" + } + return err.Error() +} + +var fatalErrs = []error{ + driver.ErrBadConn, + sql.ErrConnDone, + io.ErrUnexpectedEOF, + net.ErrClosed, +} + +func (b *baseDriver) fatal(err error) bool { + for _, t := range fatalErrs { + if errors.Is(err, t) { + return true + } + } + return b.invalidConn() +} + +func (b *baseDriver) invalidConn() bool { + var invalid bool + if err := b.conn.Raw(func(driverConn any) error { + if v, ok := driverConn.(driver.Validator); ok { + invalid = !v.IsValid() + } + return nil + }); errors.Is(err, driver.ErrBadConn) { + return true + } + return invalid +} + +func (b *baseDriver) Close() error { + var errs []error + if err := b.conn.Close(); err != nil && !errors.Is(err, sql.ErrConnDone) { + errs = append(errs, fmt.Errorf("closing database connection: %w", err)) + } + if err := b.db.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing database connection pool: %w", err)) + } + return errors.Join(errs...) +} + +func newBaseDriver(ctx context.Context, client string, db *sql.DB) (baseDriver, error) { + db.SetMaxIdleConns(0) + conn, err := db.Conn(ctx) + if err != nil { + return baseDriver{}, err + } + return baseDriver{client: client, db: db, conn: conn}, nil +} + +func closeDBOnErr(db *sql.DB, err *error) { + if err == nil || *err == nil { + return + } + _ = db.Close() +} + +func closeConnOnErr(conn *sql.Conn, err *error) { + if err == nil || *err == nil { + return + } + _ = conn.Close() +} diff --git a/internal/serve/dbdriver/postgres.go b/internal/serve/dbdriver/postgres.go new file mode 100644 index 0000000..0ecc91f --- /dev/null +++ b/internal/serve/dbdriver/postgres.go @@ -0,0 +1,175 @@ +package dbdriver + +import ( + "context" + "database/sql" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/stdlib" + + "github.com/timescale/ghost/internal/serve/dbtypes" +) + +const postgresClient = "postgres" + +// ApplicationName is set in the Postgres connection's `application_name` +// runtime parameter so we are identifiable in `pg_stat_activity`. +var ApplicationName = "ghost-cli" + +// OpenPostgresDSN opens a Postgres driver against the supplied DSN. The DSN +// should already include sslmode etc. (see common.BuildConnectionString). +func OpenPostgresDSN(ctx context.Context, dsn string) (Driver, error) { + pgxCfg, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parsing dsn: %w", err) + } + return openPostgresConfig(ctx, pgxCfg) +} + +func openPostgresConfig(ctx context.Context, pgxCfg *pgx.ConnConfig) (d Driver, err error) { + // SimpleProtocol lets users send arbitrary text (multi-statement SQL, + // comments, trailing semicolons) the same way `ghost sql` does. The + // alternative extended-protocol modes need a single, parseable + // statement per call, which is a poor fit for an interactive editor. + pgxCfg.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + if pgxCfg.RuntimeParams == nil { + pgxCfg.RuntimeParams = map[string]string{} + } + pgxCfg.RuntimeParams["application_name"] = ApplicationName + + tracer := &postgresQueryTracer{} + pgxCfg.Tracer = tracer + + db := stdlib.OpenDB(*pgxCfg) + defer closeDBOnErr(db, &err) + + base, err := newBaseDriver(ctx, postgresClient, db) + if err != nil { + return nil, err + } + defer closeConnOnErr(base.conn, &err) + + var pgConn *pgconn.PgConn + if err := base.conn.Raw(func(driverConn any) error { + pgConn = driverConn.(*stdlib.Conn).Conn().PgConn() + return nil + }); err != nil { + return nil, fmt.Errorf("getting raw driver connection: %w", err) + } + + return &postgresDriver{ + baseDriver: base, + postgresQueryTracer: tracer, + pgConn: pgConn, + }, nil +} + +// postgresQueryTracer captures the most recent pgconn.CommandTag so we can +// surface RowsAffected for INSERT/UPDATE/DELETE/etc., which database/sql +// hides behind a one-shot Result we can't get from a Query call. +type postgresQueryTracer struct { + lastCommandTag *pgconn.CommandTag +} + +func (t *postgresQueryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + t.lastCommandTag = nil + return ctx +} + +func (t *postgresQueryTracer) TraceQueryEnd(_ context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) { + t.lastCommandTag = &data.CommandTag +} + +type postgresDriver struct { + baseDriver + *postgresQueryTracer + pgConn *pgconn.PgConn +} + +// Context wraps the query in a cancellation handler that issues +// pg_cancel_backend() server-side when the parent context is canceled. This +// lets long-running queries terminate cleanly without dropping the +// connection mid-flight. +func (d *postgresDriver) Context(ctx context.Context) (context.Context, context.CancelFunc) { + return cancelContext(ctx, func(ctx context.Context) error { + return d.pgConn.CancelRequest(ctx) + }) +} + +func (d *postgresDriver) Query(ctx context.Context, args QueryArgs) (Rows, error) { + baseRows, err := d.query(ctx, args, d.scanType) + if err != nil { + return nil, err + } + return &postgresRows{ + baseRows: *baseRows, + postgresQueryTracer: d.postgresQueryTracer, + }, nil +} + +// scanType overlays Postgres-specific type targeting on top of baseDriver. +// JSON/JSONB go through our typed JSON scanner (preserves raw text); +// NUMERIC preserves arbitrary precision and special values (NaN, ±Inf); +// BYTEA goes through hex-encoding rather than raw bytes; +// DATE/TIMESTAMP/TIMESTAMPTZ use our string scanners that preserve the +// database's own formatting (the stdlib Postgres driver maps these to +// time.Time, which loses precision and special values like Infinity). +func (d *postgresDriver) scanType(columnType *sql.ColumnType) reflect.Type { + switch columnType.DatabaseTypeName() { + case "JSON", "JSONB": + return dbtypes.JSONPtrType + case "NUMERIC": + return dbtypes.NumericPtrType + case "BYTEA": + return dbtypes.BinaryPtrType + case "DATE": + return dbtypes.DatePtrType + case "TIMESTAMP": + return dbtypes.DateTimePtrType + case "TIMESTAMPTZ": + return dbtypes.TimestampPtrType + } + return d.baseDriver.scanType(columnType) +} + +func (d *postgresDriver) NormalizeError(ctx context.Context, err error) *NormalizedError { + normalized := d.baseDriver.NormalizeError(ctx, err) + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if strings.EqualFold(pgErr.Severity, "FATAL") { + normalized.Fatal = true + } + // Translate the generic 42601 "syntax error" emitted by pgx when the + // caller sends multiple statements to a single prepared call into our + // own actionable message. + if pgErr.Code == "42601" && pgErr.Message == "cannot insert multiple commands into a prepared statement" { + normalized.Message = ErrMultiStatement.Error() + return normalized + } + normalized.Code = pgErr.Code + normalized.Detail = pgErr.Detail + normalized.Hint = pgErr.Hint + normalized.Message = pgErr.Message + normalized.Position = pgErr.Position + } + return normalized +} + +type postgresRows struct { + baseRows + *postgresQueryTracer +} + +func (r *postgresRows) RowsAffected(_ context.Context) (*int64, error) { + if r.lastCommandTag != nil { + ra := r.lastCommandTag.RowsAffected() + return &ra, nil + } + return nil, nil +} diff --git a/internal/serve/dbdriver/rows.go b/internal/serve/dbdriver/rows.go new file mode 100644 index 0000000..b5b3a1b --- /dev/null +++ b/internal/serve/dbdriver/rows.go @@ -0,0 +1,174 @@ +package dbdriver + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + "unicode" + + "github.com/timescale/ghost/internal/serve/dbtypes" +) + +// Rows wraps [sql.Rows] with column metadata + accessors for row-affected +// counts. Always close after iteration. +type Rows interface { + Next() bool + Scan(dest ...any) error + Err() error + Close() error + + Columns() (Columns, error) + Metadata(ctx context.Context) (*Metadata, error) + RowsAffected(ctx context.Context) (*int64, error) +} + +// Columns is a convenience wrapper around []Column. +type Columns []Column + +// ScanTypes returns the Go types each column's value will be scanned into. +func (c Columns) ScanTypes() []reflect.Type { + out := make([]reflect.Type, len(c)) + for i, column := range c { + out[i] = column.ScanType + } + return out +} + +// ScanTargets is a slice of newly-allocated pointers suitable for passing to +// Rows.Scan(). +type ScanTargets []any + +// ScanTargets allocates fresh scan targets for each column. +func (c Columns) ScanTargets() ScanTargets { + targets := make(ScanTargets, len(c)) + for i, column := range c { + targets[i] = reflect.New(column.ScanType).Interface() + } + return targets +} + +// Values dereferences the scan targets after a call to Rows.Scan. +func (s ScanTargets) Values() []any { + vals := make([]any, len(s)) + for i, target := range s { + vals[i] = reflect.ValueOf(target).Elem().Interface() + } + return vals +} + +type scanTypeFn func(columnType *sql.ColumnType) reflect.Type + +type baseRows struct { + *sql.Rows + columnCase ColumnCase + scanTypeFn scanTypeFn +} + +func (r *baseRows) Columns() (Columns, error) { + columnTypes, err := r.ColumnTypes() + if err != nil { + return nil, err + } + + columns := make(Columns, len(columnTypes)) + deduper := newDeduper(columnTypes) + + // Two passes: named columns first so unnamed columns don't claim names + // that the database actually produced. + for i, ct := range columnTypes { + if ct.Name() != "" { + columns[i] = r.buildColumn(deduper, ct) + } + } + for i, ct := range columnTypes { + if ct.Name() == "" { + columns[i] = r.buildColumn(deduper, ct) + } + } + return columns, nil +} + +func (r *baseRows) buildColumn(deduper deduper, ct *sql.ColumnType) Column { + scanType := r.scanTypeFn(ct) + column := Column{ + Name: deduper.dedupe(ct, r.columnCase), + Type: ct.DatabaseTypeName(), + Object: scanType == dbtypes.JSONPtrType, + Numeric: scanType == dbtypes.NumericPtrType, + ScanType: scanType, + } + if length, ok := ct.Length(); ok { + column.Length = length + } + if precision, scale, ok := ct.DecimalSize(); ok { + column.Precision = precision + column.Scale = scale + } + return column +} + +func (r *baseRows) Metadata(ctx context.Context) (*Metadata, error) { return nil, nil } +func (r *baseRows) RowsAffected(ctx context.Context) (*int64, error) { return nil, nil } + +type deduper map[string]int + +func newDeduper(columnTypes []*sql.ColumnType) deduper { + d := deduper{} + for _, ct := range columnTypes { + d[d.columnKey(ct.Name())] = 0 + } + return d +} + +func (d deduper) columnKey(name string) string { return strings.ToLower(name) } + +func (d deduper) columnName(ct *sql.ColumnType, columnCase ColumnCase) string { + name := ct.Name() + if name == "" { + name = "column" + } + switch columnCase { + case ColumnCaseDefault: + return name + case ColumnCaseLower: + if d.mixedCase(name) { + return name + } + return strings.ToLower(name) + case ColumnCaseUpper: + if d.mixedCase(name) { + return name + } + return strings.ToUpper(name) + default: + panic(fmt.Errorf("invalid column case: %s", columnCase)) + } +} + +func (d deduper) mixedCase(name string) bool { + return strings.ContainsFunc(name, unicode.IsLower) && + strings.ContainsFunc(name, unicode.IsUpper) +} + +func (d deduper) dedupe(ct *sql.ColumnType, columnCase ColumnCase) string { + name := d.columnName(ct, columnCase) + key := d.columnKey(name) + + count := d[key] + if count == 0 { + d[key] = 1 + return name + } + + for { + newName := fmt.Sprintf("%s_%d", name, count) + newKey := d.columnKey(newName) + count++ + if _, exists := d[newKey]; !exists { + d[key] = count + return newName + } + } +} diff --git a/internal/serve/dbtypes/binary.go b/internal/serve/dbtypes/binary.go new file mode 100644 index 0000000..472dab6 --- /dev/null +++ b/internal/serve/dbtypes/binary.go @@ -0,0 +1,22 @@ +package dbtypes + +import ( + "encoding/hex" + "fmt" +) + +// Binary is a type that represents binary data in standard Postgres hex format. +// See https://www.postgresql.org/docs/current/datatype-binary.html#DATATYPE-BINARY-BYTEA-HEX-FORMAT +type Binary string + +// Scan implements the [sql.Scanner] interface. It converts a []byte +// value to a string containing a hex representation of the value. +func (b *Binary) Scan(src any) error { + switch val := src.(type) { + case []byte: + *b = Binary(fmt.Sprintf(`\x%s`, hex.EncodeToString(val))) + default: + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type Binary", src) + } + return nil +} diff --git a/internal/serve/dbtypes/date.go b/internal/serve/dbtypes/date.go new file mode 100644 index 0000000..bc84a2c --- /dev/null +++ b/internal/serve/dbtypes/date.go @@ -0,0 +1,99 @@ +package dbtypes + +import ( + "fmt" + "time" +) + +type Date string + +func (d *Date) Scan(src any) error { + switch val := src.(type) { + case nil: + d = nil + case string: + *d = Date(val) + case time.Time: + *d = Date(val.Format(time.DateOnly)) + default: + return fmt.Errorf("unexpected date type: %T", val) + } + return nil +} + +type ClockTime string + +func (c *ClockTime) Scan(src any) error { + switch val := src.(type) { + case nil: + c = nil + case string: + *c = ClockTime(val) + case time.Time: + *c = ClockTime(val.Format("15:04:05.999999")) + default: + return fmt.Errorf("unexpected clock time type: %T", val) + } + return nil +} + +type ClockTimeTZ string + +func (c *ClockTimeTZ) Scan(src any) error { + switch val := src.(type) { + case nil: + c = nil + case string: + *c = ClockTimeTZ(val) + case time.Time: + *c = ClockTimeTZ(val.Format("15:04:05.999999-" + getTimeZoneOffsetLayout(val))) + default: + return fmt.Errorf("unexpected clock time with time zone type: %T", val) + } + return nil +} + +type DateTime string + +func (d *DateTime) Scan(src any) error { + switch val := src.(type) { + case nil: + d = nil + case string: + *d = DateTime(val) + case time.Time: + *d = DateTime(val.Format("2006-01-02 15:04:05.999999")) + default: + return fmt.Errorf("unexpected date time type: %T", val) + } + return nil +} + +type Timestamp string + +func (t *Timestamp) Scan(src any) error { + switch val := src.(type) { + case nil: + t = nil + case string: + *t = Timestamp(val) + case time.Time: + *t = Timestamp(val.Format("2006-01-02 15:04:05.999999-" + getTimeZoneOffsetLayout(val))) + default: + return fmt.Errorf("unexpected date time type: %T", val) + } + return nil +} + +// getTimeZoneOffsetLayout returns the timezone-offset portion of the +// timestamp layout. If the offset has a non-zero minutes portion, both hours +// and minutes are included (matching Postgres default behavior); otherwise +// just the hours portion is included. +func getTimeZoneOffsetLayout(t time.Time) string { + _, offset := t.Zone() + minutes := (offset % 3600) / 60 + if minutes == 0 { + return "07" + } + return "07:00" +} diff --git a/internal/serve/dbtypes/json.go b/internal/serve/dbtypes/json.go new file mode 100644 index 0000000..94e6600 --- /dev/null +++ b/internal/serve/dbtypes/json.go @@ -0,0 +1,37 @@ +package dbtypes + +import ( + "encoding/json" + "fmt" +) + +// JSON represents an arbitrary JSON value without unmarshalling it into a +// concrete Go type. +type JSON string + +// MarshalJSON emits the underlying string as a literal JSON value. +func (j JSON) MarshalJSON() ([]byte, error) { + return json.Marshal(json.RawMessage(j)) +} + +// Scan accepts string, []byte, or already-decoded map/slice values and stores +// the raw JSON encoding. +func (j *JSON) Scan(src any) error { + switch val := src.(type) { + case string: + *j = JSON(val) + case []byte: + // Casting to a string copies the byte slice, which is critical: some + // drivers reuse the underlying buffer between Scan calls. + *j = JSON(val) + case map[string]any, []any: + out, err := json.Marshal(val) + if err != nil { + return fmt.Errorf("error marshalling %T to JSON: %w", src, err) + } + *j = JSON(out) + default: + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type JSON", src) + } + return nil +} diff --git a/internal/serve/dbtypes/numeric.go b/internal/serve/dbtypes/numeric.go new file mode 100644 index 0000000..0731c0e --- /dev/null +++ b/internal/serve/dbtypes/numeric.go @@ -0,0 +1,18 @@ +package dbtypes + +import "encoding/json" + +// Numeric represents arbitrary-precision decimal values as well as special +// values like Infinity, -Infinity, and NaN. +type Numeric string + +// MarshalJSON marshals the underlying string as a json.Number when possible +// (i.e. as a number without quotes), falling back to a string when the value +// is not a valid JSON number (e.g. Postgres's Infinity, -Infinity, NaN). +func (n Numeric) MarshalJSON() ([]byte, error) { + out, err := json.Marshal(json.Number(n)) + if err != nil { + return json.Marshal(string(n)) + } + return out, err +} diff --git a/internal/serve/dbtypes/types.go b/internal/serve/dbtypes/types.go new file mode 100644 index 0000000..9ba4c6c --- /dev/null +++ b/internal/serve/dbtypes/types.go @@ -0,0 +1,90 @@ +// Package dbtypes contains custom scan types used to preserve database-side +// precision and special values (NaN, +/-Infinity, untyped JSON, hex-encoded +// bytea, plain DATE/TIMESTAMP strings) when reading rows out of database/sql. +// +// Ported from github.com/timescale/popsql-query/internal/types so the Apache +// Arrow encoding in this package matches the wire contract the +// popsql-query-widget expects from the savannah gateway. +package dbtypes + +import ( + "database/sql" + "reflect" + "time" +) + +var ( + RawBytesType = reflect.TypeFor[sql.RawBytes]() + + AnyType = reflect.TypeFor[any]() + BoolType = reflect.TypeFor[bool]() + ByteType = reflect.TypeFor[byte]() + Float32Type = reflect.TypeFor[float32]() + Float64Type = reflect.TypeFor[float64]() + IntType = reflect.TypeFor[int]() + Int8Type = reflect.TypeFor[int8]() + Int16Type = reflect.TypeFor[int16]() + Int32Type = reflect.TypeFor[int32]() + Int64Type = reflect.TypeFor[int64]() + UintType = reflect.TypeFor[uint]() + Uint8Type = reflect.TypeFor[uint8]() + Uint16Type = reflect.TypeFor[uint16]() + Uint32Type = reflect.TypeFor[uint32]() + Uint64Type = reflect.TypeFor[uint64]() + StringType = reflect.TypeFor[string]() + BytesType = reflect.TypeFor[[]byte]() + TimeType = reflect.TypeFor[time.Time]() + DateType = reflect.TypeFor[Date]() + ClockTimeType = reflect.TypeFor[ClockTime]() + ClockTimeTZType = reflect.TypeFor[ClockTimeTZ]() + DateTimeType = reflect.TypeFor[DateTime]() + TimestampType = reflect.TypeFor[Timestamp]() + NumericType = reflect.TypeFor[Numeric]() + JSONType = reflect.TypeFor[JSON]() + BinaryType = reflect.TypeFor[Binary]() + + AnyPtrType = reflect.PointerTo(AnyType) + BoolPtrType = reflect.PointerTo(BoolType) + BytePtrType = reflect.PointerTo(ByteType) + Float32PtrType = reflect.PointerTo(Float32Type) + Float64PtrType = reflect.PointerTo(Float64Type) + IntPtrType = reflect.PointerTo(IntType) + Int8PtrType = reflect.PointerTo(Int8Type) + Int16PtrType = reflect.PointerTo(Int16Type) + Int32PtrType = reflect.PointerTo(Int32Type) + Int64PtrType = reflect.PointerTo(Int64Type) + UintPtrType = reflect.PointerTo(UintType) + Uint8PtrType = reflect.PointerTo(Uint8Type) + Uint16PtrType = reflect.PointerTo(Uint16Type) + Uint32PtrType = reflect.PointerTo(Uint32Type) + Uint64PtrType = reflect.PointerTo(Uint64Type) + StringPtrType = reflect.PointerTo(StringType) + BytesPtrType = reflect.PointerTo(BytesType) + TimePtrType = reflect.PointerTo(TimeType) + DatePtrType = reflect.PointerTo(DateType) + ClockTimePtrType = reflect.PointerTo(ClockTimeType) + ClockTimeTZPtrType = reflect.PointerTo(ClockTimeTZType) + DateTimePtrType = reflect.PointerTo(DateTimeType) + TimestampPtrType = reflect.PointerTo(TimestampType) + NumericPtrType = reflect.PointerTo(NumericType) + JSONPtrType = reflect.PointerTo(JSONType) + BinaryPtrType = reflect.PointerTo(BinaryType) + + NullBoolType = reflect.TypeFor[sql.NullBool]() + NullByteType = reflect.TypeFor[sql.NullByte]() + NullFloat64Type = reflect.TypeFor[sql.NullFloat64]() + NullInt16Type = reflect.TypeFor[sql.NullInt16]() + NullInt32Type = reflect.TypeFor[sql.NullInt32]() + NullInt64Type = reflect.TypeFor[sql.NullInt64]() + NullStringType = reflect.TypeFor[sql.NullString]() + NullTimeType = reflect.TypeFor[sql.NullTime]() + + NullBoolPtrType = reflect.PointerTo(NullBoolType) + NullBytePtrType = reflect.PointerTo(NullByteType) + NullFloat64PtrType = reflect.PointerTo(NullFloat64Type) + NullInt16PtrType = reflect.PointerTo(NullInt16Type) + NullInt32PtrType = reflect.PointerTo(NullInt32Type) + NullInt64PtrType = reflect.PointerTo(NullInt64Type) + NullStringPtrType = reflect.PointerTo(NullStringType) + NullTimePtrType = reflect.PointerTo(NullTimeType) +) diff --git a/internal/serve/execute.go b/internal/serve/execute.go new file mode 100644 index 0000000..4ea6a30 --- /dev/null +++ b/internal/serve/execute.go @@ -0,0 +1,178 @@ +package serve + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// handleExecuteQuery serves POST /api/executeQuery for one-shot mode (no +// sessionId). A fresh driver is opened, the query runs, columns are +// streamed, then arrowResults consumes the rows; we wait for it to finish +// and emit the success/error terminator before closing. +func (s *Server) handleExecuteQuery(w http.ResponseWriter, r *http.Request) { + var req executeQueryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + if !s.checkProject(w, req.RunID, req.ProjectID) { + return + } + + driver, connErr := openDriverForService(r.Context(), s.cfg.Client, req.ProjectID, req.ServiceID) + if connErr != nil { + ce := new(connectErr) + if errors.As(connErr, &ce) { + writeErrorTerminator(w, req.RunID, ce.Normalized()) + } else { + writeErrorTerminator(w, req.RunID, &dbdriver.NormalizedError{Message: connErr.Error(), Source: "ghost", Connect: true}) + } + return + } + defer driver.Close() + + s.runQuery(w, r, req, driver, true) +} + +// handleExecuteSessionQuery serves POST /api/executeSessionQuery. The +// session-owned driver is reused; closing/cleanup is done in +// handleCloseSession. +func (s *Server) handleExecuteSessionQuery(w http.ResponseWriter, r *http.Request) { + var req executeSessionQueryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + if !s.checkProject(w, req.RunID, req.ProjectID) { + return + } + + session := s.sessions.get(req.SessionID) + if session == nil { + // 404 trips the widget's SessionError path, which prompts a fresh + // createSession on the next query attempt. + http.NotFound(w, r) + return + } + + s.runQuery(w, r, req.executeQueryRequest, session.driver, false) +} + +// runQuery is the shared body of handleExecuteQuery / handleExecuteSessionQuery. +// ownsDriver controls whether arrowResults' cleanup will close the driver +// (true in one-shot mode, false in session mode). +func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, req executeQueryRequest, driver dbdriver.Driver, ownsDriver bool) { + driverCtx, driverCleanup := driver.Context(r.Context()) + defer driverCleanup() + + rows, err := driver.Query(driverCtx, dbdriver.QueryArgs{Query: req.SQL()}) + if err != nil { + writeErrorTerminator(w, req.RunID, driver.NormalizeError(driverCtx, err)) + return + } + defer rows.Close() + + columns, err := rows.Columns() + if err != nil { + writeErrorTerminator(w, req.RunID, driver.NormalizeError(driverCtx, err)) + return + } + + queryCtx, cancelQuery := context.WithCancel(r.Context()) + defer cancelQuery() + + run := &Run{ + id: req.RunID, + projectID: req.ProjectID, + serviceID: req.ServiceID, + startedAt: time.Now(), + rows: rows, + columns: columns, + queryCtx: queryCtx, + cancelQuery: cancelQuery, + driverCleanup: driverCleanup, + ready: make(chan struct{}), + done: make(chan struct{}), + } + if ownsDriver { + run.driver = driver + } + s.runs.add(run) + defer s.runs.delete(req.RunID) + close(run.ready) + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + + enc := json.NewEncoder(w) + if err := enc.Encode(columnsResult{RunID: req.RunID, Columns: columns}); err != nil { + // Client gone before the columns line could be written; bail. + cancelQuery() + return + } + flushWriter(w) + + // Wait for arrowResults to finish, or for the client to disconnect. + select { + case <-run.done: + case <-r.Context().Done(): + cancelQuery() + // Give arrowResults a brief window to wrap up cleanly so it sets the + // real rowCount + error. If it doesn't return promptly we mark the + // run as canceled and proceed. + select { + case <-run.done: + case <-time.After(2 * time.Second): + run.setError(&dbdriver.NormalizedError{Message: "request canceled", Source: "ghost", Cancel: true}) + run.closeDone() + } + } + + if run.err != nil { + _ = enc.Encode(errorResult{RunID: req.RunID, Success: false, Error: run.err}) + } else { + _ = enc.Encode(successResult{ + RunID: req.RunID, + Success: true, + RowCount: run.rowCount, + RowsAffected: run.rowsAffected, + }) + } + flushWriter(w) +} + +// checkProject rejects requests for a different project than the one the CLI +// is logged into. Single-user defense in depth. +func (s *Server) checkProject(w http.ResponseWriter, runID, projectID string) bool { + if projectID == s.cfg.ProjectID { + return true + } + writeErrorTerminator(w, runID, &dbdriver.NormalizedError{ + Message: "projectId does not match the active ghost project", + Source: "ghost", + }) + return false +} + +// writeErrorTerminator writes a single-line NDJSON error response. Used when +// the query never gets far enough to register a Run. +func writeErrorTerminator(w http.ResponseWriter, runID string, norm *dbdriver.NormalizedError) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(errorResult{RunID: runID, Success: false, Error: norm}) + flushWriter(w) +} + +// flushWriter calls Flush if the writer supports it, which is needed so the +// widget sees columns + success lines as they're written rather than batched +// at the end. +func flushWriter(w http.ResponseWriter) { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} diff --git a/internal/serve/server.go b/internal/serve/server.go index 2ce1889..c3b81b9 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -27,10 +27,12 @@ type Config struct { // Server wraps the HTTP server and exposes the resolved listen address. type Server struct { - cfg Config - srv *http.Server - ln net.Listener - addr string + cfg Config + srv *http.Server + ln net.Listener + addr string + runs *runStore + sessions *sessionStore } // New constructs a Server with all routes registered. The listener is bound @@ -53,18 +55,29 @@ func New(cfg Config) (*Server, error) { return nil, fmt.Errorf("listen on %s: %w", addr, err) } + s := &Server{ + cfg: cfg, + ln: ln, + addr: ln.Addr().String(), + runs: newRunStore(), + sessions: newSessionStore(), + } + mux := http.NewServeMux() mux.Handle("GET /healthz", healthzHandler()) mux.Handle("GET /api/bootstrap", newBootstrapHandler(cfg.ProjectID)) mux.Handle("GET /api/databases", newDatabasesHandler(cfg.Client, cfg.ProjectID)) + mux.Handle("POST /api/executeQuery", http.HandlerFunc(s.handleExecuteQuery)) + mux.Handle("POST /api/executeSessionQuery", http.HandlerFunc(s.handleExecuteSessionQuery)) + mux.Handle("POST /api/arrowResults", http.HandlerFunc(s.handleArrowResults)) + mux.Handle("POST /api/createSession", http.HandlerFunc(s.handleCreateSession)) + mux.Handle("POST /api/closeSession", http.HandlerFunc(s.handleCloseSession)) + mux.Handle("POST /api/sessionEvents", http.HandlerFunc(s.handleSessionEvents)) + mux.Handle("POST /api/cancelRun", http.HandlerFunc(s.handleCancelRun)) mux.Handle("/", newAssetHandler()) - return &Server{ - cfg: cfg, - srv: &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}, - ln: ln, - addr: ln.Addr().String(), - }, nil + s.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + return s, nil } // Addr returns the resolved listen address (with the OS-chosen port if Port @@ -91,8 +104,10 @@ func (s *Server) Serve(ctx context.Context) error { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.srv.Shutdown(shutdownCtx) + s.sessions.closeAll() return <-errCh case err := <-errCh: + s.sessions.closeAll() return err } } diff --git a/internal/serve/session.go b/internal/serve/session.go new file mode 100644 index 0000000..2324ea7 --- /dev/null +++ b/internal/serve/session.go @@ -0,0 +1,123 @@ +package serve + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/google/uuid" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// handleCreateSession serves POST /api/createSession. Opens a driver, stores +// it as a Session, returns the assigned ID. Mirrors the +// CreateSessionResponse shape from @popsql/types. +func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { + var req createSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + enc := json.NewEncoder(w) + + if req.ProjectID != s.cfg.ProjectID { + _ = enc.Encode(createSessionResponse{ + Success: false, + Error: &dbdriver.NormalizedError{ + Message: "projectId does not match the active ghost project", + Source: "ghost", + }, + }) + return + } + + driver, err := openDriverForService(r.Context(), s.cfg.Client, req.ProjectID, req.ServiceID) + if err != nil { + ce := new(connectErr) + if errors.As(err, &ce) { + _ = enc.Encode(createSessionResponse{Success: false, Error: ce.Normalized()}) + } else { + _ = enc.Encode(createSessionResponse{ + Success: false, + Error: &dbdriver.NormalizedError{Message: err.Error(), Source: "ghost", Connect: true}, + }) + } + return + } + + sess := &Session{ + id: uuid.NewString(), + projectID: req.ProjectID, + serviceID: req.ServiceID, + startedAt: time.Now(), + driver: driver, + closed: make(chan struct{}), + } + s.sessions.add(sess) + + _ = enc.Encode(createSessionResponse{Success: true, ID: sess.id}) +} + +// handleCloseSession serves POST /api/closeSession. Cleanly tears down a +// session's driver. Returns 204 on success, 404 if the session is unknown. +func (s *Server) handleCloseSession(w http.ResponseWriter, r *http.Request) { + var req sessionRefRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + sess := s.sessions.get(req.SessionID) + if sess == nil { + http.NotFound(w, r) + return + } + sess.close(nil) + s.sessions.delete(req.SessionID) + w.WriteHeader(http.StatusNoContent) +} + +// handleSessionEvents serves POST /api/sessionEvents. Long-lived NDJSON +// stream: emits {"status":"connected"} immediately, then blocks until the +// session is closed (or the request is canceled). The widget's +// BaseSessionManager re-establishes this stream up to 15 times before giving +// up; if it eventually 404s the widget treats the session as dead. +func (s *Server) handleSessionEvents(w http.ResponseWriter, r *http.Request) { + var req sessionRefRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + + sess := s.sessions.get(req.SessionID) + if sess == nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + + enc := json.NewEncoder(w) + if err := enc.Encode(sessionEvent{Status: sessionStatusConnected}); err != nil { + return + } + flushWriter(w) + + select { + case <-sess.closed: + if sess.closeErr != nil { + _ = enc.Encode(sessionEvent{Status: sessionStatusError, Error: sess.closeErr}) + } else { + _ = enc.Encode(sessionEvent{Status: sessionStatusClosed}) + } + flushWriter(w) + case <-r.Context().Done(): + // Client disconnected; nothing to write. The widget will reconnect. + } +} diff --git a/internal/serve/store.go b/internal/serve/store.go new file mode 100644 index 0000000..254d5ed --- /dev/null +++ b/internal/serve/store.go @@ -0,0 +1,145 @@ +package serve + +import ( + "context" + "sync" + "time" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// Run coordinates a single in-flight query between the executeQuery and +// arrowResults handlers. The widget always fires both: executeQuery streams +// a columns NDJSON line and then blocks on Run.done; arrowResults consumes +// the rows + streams Arrow IPC, then closes Run.done so executeQuery can +// emit the success/error terminator. +type Run struct { + id string + projectID string + serviceID string + startedAt time.Time + + // driver is non-nil only when this Run owns the driver (one-shot mode). + // In session mode the driver is owned by the Session and reused across + // runs; we leave this nil so cleanup doesn't close it. + driver dbdriver.Driver + rows dbdriver.Rows + columns dbdriver.Columns + + cancelQuery context.CancelFunc + driverCleanup context.CancelFunc + + queryCtx context.Context + + ready chan struct{} + done chan struct{} + + rowCount int64 + rowsAffected *int64 + err *dbdriver.NormalizedError + errOnce sync.Once +} + +func (r *Run) setError(e *dbdriver.NormalizedError) { + r.errOnce.Do(func() { r.err = e }) +} + +func (r *Run) closeDone() { + select { + case <-r.done: + default: + close(r.done) + } +} + +// runStore holds all in-flight runs keyed by their widget-generated run id. +type runStore struct { + mu sync.Mutex + runs map[string]*Run +} + +func newRunStore() *runStore { + return &runStore{runs: make(map[string]*Run)} +} + +func (s *runStore) add(r *Run) { + s.mu.Lock() + defer s.mu.Unlock() + s.runs[r.id] = r +} + +func (s *runStore) get(id string) *Run { + s.mu.Lock() + defer s.mu.Unlock() + return s.runs[id] +} + +func (s *runStore) delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.runs, id) +} + +// Session holds a long-lived driver across multiple queries from one widget +// tab. Sessions live until /api/closeSession is invoked or the server shuts +// down. There is no idle timeout for now. +type Session struct { + id string + projectID string + serviceID string + startedAt time.Time + + driver dbdriver.Driver + + closed chan struct{} + closeErr *dbdriver.NormalizedError + closeOnce sync.Once +} + +func (s *Session) close(reason *dbdriver.NormalizedError) { + s.closeOnce.Do(func() { + s.closeErr = reason + close(s.closed) + if s.driver != nil { + _ = s.driver.Close() + } + }) +} + +// sessionStore holds active sessions. +type sessionStore struct { + mu sync.Mutex + sessions map[string]*Session +} + +func newSessionStore() *sessionStore { + return &sessionStore{sessions: make(map[string]*Session)} +} + +func (s *sessionStore) add(sess *Session) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sess.id] = sess +} + +func (s *sessionStore) get(id string) *Session { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessions[id] +} + +func (s *sessionStore) delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) +} + +// closeAll terminates every session. Called when the server shuts down. +func (s *sessionStore) closeAll() { + s.mu.Lock() + defer s.mu.Unlock() + for id, sess := range s.sessions { + sess.close(&dbdriver.NormalizedError{Message: "server shutting down", Source: "ghost", Fatal: true}) + delete(s.sessions, id) + } +} diff --git a/internal/serve/wire.go b/internal/serve/wire.go new file mode 100644 index 0000000..fffacb4 --- /dev/null +++ b/internal/serve/wire.go @@ -0,0 +1,112 @@ +package serve + +import ( + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// Wire-format types matching what @popsql/query-client's TimescaleQueryClient +// sends to and expects back from the savannah gateway. Source: +// popsql/packages/popsql-query-client/src/{TimescaleQueryClient,client}.ts. + +// executeQueryRequest matches TimescaleExecuteQueryRequest. The widget +// emits both a top-level `query` field (legacy / unused in practice) and a +// `statements` array containing the editor text split + trimmed by the +// widget's own SQL parser. We prefer `statements` when present. +type executeQueryRequest struct { + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` + Query string `json:"query"` + Statements []string `json:"statements"` + RunID string `json:"runId"` + Persist bool `json:"persist,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` +} + +// SQL returns the effective query text to execute. Prefers the statements +// array (widget's canonical field) and falls back to the raw query. +func (r executeQueryRequest) SQL() string { + if len(r.Statements) > 0 { + joined := r.Statements[0] + for _, s := range r.Statements[1:] { + joined += "; " + s + } + return joined + } + return r.Query +} + +// executeSessionQueryRequest matches TimescaleExecuteSessionQueryRequest. +type executeSessionQueryRequest struct { + executeQueryRequest + SessionID string `json:"sessionId"` +} + +// arrowResultsRequest matches TimescaleArrowResultsRequest. +type arrowResultsRequest struct { + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` + RunID string `json:"runId"` +} + +// cancelQueryRequest matches TimescaleCancelQueryRequest. +type cancelQueryRequest struct { + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` + RunID string `json:"runId"` +} + +// createSessionRequest matches TimescaleCreateSessionRequest. +type createSessionRequest struct { + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` +} + +// sessionRefRequest matches the body of closeSession/sessionEvents. +type sessionRefRequest struct { + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` + SessionID string `json:"sessionId"` +} + +// createSessionResponse matches CreateSessionResponse (one of two shapes). +type createSessionResponse struct { + Success bool `json:"success"` + ID string `json:"id,omitempty"` + Error *dbdriver.NormalizedError `json:"error,omitempty"` +} + +// columnsResult is the first NDJSON line written by executeQuery. The widget +// uses 'columns' as the discriminator. +type columnsResult struct { + RunID string `json:"runId"` + Columns dbdriver.Columns `json:"columns"` + Metadata *dbdriver.Metadata `json:"meta,omitempty"` +} + +// successResult is the final NDJSON line on a successful run. +type successResult struct { + RunID string `json:"runId"` + Success bool `json:"success"` + RowCount int64 `json:"rowCount"` + RowsAffected *int64 `json:"rowsAffected,omitempty"` +} + +// errorResult is the final NDJSON line on a failed (or canceled) run. +type errorResult struct { + RunID string `json:"runId"` + Success bool `json:"success"` + Error *dbdriver.NormalizedError `json:"error"` +} + +// sessionEvent matches the SessionEvent NDJSON line shape. +type sessionEvent struct { + Status string `json:"status"` + Error *dbdriver.NormalizedError `json:"error,omitempty"` +} + +const ( + sessionStatusConnecting = "connecting" + sessionStatusConnected = "connected" + sessionStatusClosed = "closed" + sessionStatusError = "error" +) diff --git a/web/bun.lock b/web/bun.lock index b73b38f..dfcc8cf 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -8,19 +8,20 @@ "@tanstack/react-query": "^5.62.7", "@timescale/popsql-query-widget": "0.0.0-dev.156", "framer-motion": "^12.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", }, "devDependencies": { "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "vite": "^7.0.0", + "vite-plugin-node-polyfills": "^0.24.0", }, }, }, @@ -135,6 +136,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + "@rollup/plugin-inject": ["@rollup/plugin-inject@5.0.5", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "estree-walker": "^2.0.2", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], @@ -203,9 +208,11 @@ "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.29", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg=="], - "@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@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], "@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=="], @@ -215,44 +222,124 @@ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="], + + "assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="], + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="], + + "browser-resolve": ["browser-resolve@2.0.0", "", { "dependencies": { "resolve": "^1.17.0" } }, "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ=="], + + "browserify-aes": ["browserify-aes@1.2.0", "", { "dependencies": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", "create-hash": "^1.1.0", "evp_bytestokey": "^1.0.3", "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA=="], + + "browserify-cipher": ["browserify-cipher@1.0.1", "", { "dependencies": { "browserify-aes": "^1.0.4", "browserify-des": "^1.0.0", "evp_bytestokey": "^1.0.0" } }, "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w=="], + + "browserify-des": ["browserify-des@1.0.2", "", { "dependencies": { "cipher-base": "^1.0.1", "des.js": "^1.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A=="], + + "browserify-rsa": ["browserify-rsa@4.1.1", "", { "dependencies": { "bn.js": "^5.2.1", "randombytes": "^2.1.0", "safe-buffer": "^5.2.1" } }, "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ=="], + + "browserify-sign": ["browserify-sign@4.2.6", "", { "dependencies": { "bn.js": "^5.2.3", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "elliptic": "^6.6.1", "inherits": "^2.0.4", "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" } }, "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="], + + "builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "console-browserify": ["console-browserify@1.2.0", "", {}, "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA=="], + + "constants-browserify": ["constants-browserify@1.0.0", "", {}, "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="], + + "create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="], + + "create-hmac": ["create-hmac@1.1.7", "", { "dependencies": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg=="], + + "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + + "crypto-browserify": ["crypto-browserify@3.12.1", "", { "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", "create-ecdh": "^4.0.4", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "diffie-hellman": "^5.0.3", "hash-base": "~3.0.4", "inherits": "^2.0.4", "pbkdf2": "^3.1.2", "public-encrypt": "^4.0.3", "randombytes": "^2.1.0", "randomfill": "^1.0.4" } }, "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.363", "", {}, "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA=="], + "elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "evp_bytestokey": ["evp_bytestokey@1.0.3", "", { "dependencies": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" } }, "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -261,6 +348,10 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "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-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], @@ -269,22 +360,64 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hash-base": ["hash-base@3.0.5", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1" } }, "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg=="], + + "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], + + "https-browserify": ["https-browserify@1.0.0", "", {}, "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isomorphic-timers-promises": ["isomorphic-timers-promises@1.0.1", "", {}, "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ=="], + "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=="], @@ -297,12 +430,28 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "md5.js": ["md5.js@1.3.5", "", { "dependencies": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": { "miller-rabin": "bin/miller-rabin" } }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="], + + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], @@ -315,14 +464,40 @@ "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "node-stdlib-browser": ["node-stdlib-browser@1.3.1", "", { "dependencies": { "assert": "^2.0.0", "browser-resolve": "^2.0.0", "browserify-zlib": "^0.2.0", "buffer": "^5.7.1", "console-browserify": "^1.1.0", "constants-browserify": "^1.0.0", "create-require": "^1.1.1", "crypto-browserify": "^3.12.1", "domain-browser": "4.22.0", "events": "^3.0.0", "https-browserify": "^1.0.0", "isomorphic-timers-promises": "^1.0.1", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", "pkg-dir": "^5.0.0", "process": "^0.11.10", "punycode": "^1.4.1", "querystring-es3": "^0.2.1", "readable-stream": "^3.6.0", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "string_decoder": "^1.0.0", "timers-browserify": "^2.0.4", "tty-browserify": "0.0.1", "url": "^0.11.4", "util": "^0.12.4", "vm-browserify": "^1.0.1" } }, "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "os-browserify": ["os-browserify@0.3.0", "", {}, "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-asn1": ["parse-asn1@5.1.9", "", { "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" } }, "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pbkdf2": ["pbkdf2@3.1.6", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.2" } }, "sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -331,6 +506,10 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkg-dir": ["pkg-dir@5.0.0", "", { "dependencies": { "find-up": "^5.0.0" } }, "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -345,32 +524,76 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="], + + "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "querystring-es3": ["querystring-es3@0.2.1", "", {}, "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], + + "stream-http": ["stream-http@3.2.0", "", { "dependencies": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" } }, "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -381,34 +604,84 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "timers-browserify": ["timers-browserify@2.0.12", "", { "dependencies": { "setimmediate": "^1.0.4" } }, "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tty-browserify": ["tty-browserify@0.0.1", "", {}, "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "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=="], + "url": ["url@0.11.4", "", { "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" } }, "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg=="], + + "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.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-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + "vite-plugin-node-polyfills": ["vite-plugin-node-polyfills@0.24.0", "", { "dependencies": { "@rollup/plugin-inject": "^5.0.5", "node-stdlib-browser": "^1.2.0" }, "peerDependencies": { "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw=="], + + "vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="], + + "which-typed-array": ["which-typed-array@1.1.21", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + + "browserify-sign/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "create-ecdh/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + + "diffie-hellman/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + + "elliptic/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "miller-rabin/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + + "public-encrypt/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], + + "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "browserify-sign/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "browserify-sign/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "ripemd160/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], } } diff --git a/web/package.json b/web/package.json index dfc9e02..d8561ee 100644 --- a/web/package.json +++ b/web/package.json @@ -13,18 +13,19 @@ "@tanstack/react-query": "^5.62.7", "@timescale/popsql-query-widget": "0.0.0-dev.156", "framer-motion": "^12.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vite-plugin-node-polyfills": "^0.24.0" } } diff --git a/web/src/app.tsx b/web/src/app.tsx index 9787fe5..47d569e 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,5 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import '@timescale/popsql-query-widget/index.css'; + +import { QueryPanel } from './components/QueryPanel'; interface Bootstrap { projectId: string; @@ -91,23 +94,19 @@ export function App() { )} -
+
{bootstrap.isError ? (
Failed to load bootstrap config
) : !selected ? (
Select a database to run queries.
+ ) : !bootstrap.data ? ( +
Loading…
) : ( -
-
project
-
{bootstrap.data?.projectId ?? '…'}
-
database
-
- {selected.name} ({selected.id}) -
-
- Query widget will render here in step 2. -
-
+ )}
diff --git a/web/src/components/QueryPanel.tsx b/web/src/components/QueryPanel.tsx new file mode 100644 index 0000000..7914a10 --- /dev/null +++ b/web/src/components/QueryPanel.tsx @@ -0,0 +1,51 @@ +import type React from 'react'; +import { + ContextMenuContext, + ContextMenuProvider, + ExecuteQueryEngine, + QueryWidget, + QueryWidgetProvider, + TimescaleResultsCacheContextProvider, +} from '@timescale/popsql-query-widget'; +import { useState } from 'react'; + +interface Props { + projectId: string; + databaseId: string; + databaseName: string; +} + +// QueryPanel renders the PopSQL query widget targeted at a single ghost +// database. The sessionKey is derived from the database ID so switching +// databases automatically invalidates the session (and tears down the +// in-process PG connection on the Go side). +export function QueryPanel({ projectId, databaseId, databaseName }: Props) { + const [query, setQuery] = useState(`-- ${databaseName}\nSELECT 1;\n`); + + return ( + + + + ({ + engine: ExecuteQueryEngine.timescaleQuery, + params: { + projectId, + serviceId: databaseId, + query, + runId, + }, + })} + /> + + {({ render }: { render: () => React.ReactNode }) => render()} + + + + + ); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index e87787f..ba0f1ee 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,51 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; const ghostServePort = process.env.GHOST_SERVE_DEV_PORT ?? '5174'; +// The widget's results.worker.js and editor.worker.js load their DuckDB + +// Monaco sidecars via `new URL(, import.meta.url)`. Vite only emits +// referenced assets when the URL argument is a string literal, so the +// sidecars get missed and the workers 404 at runtime. We emit them manually +// — ported from web-cloud/vite.config.ts. +function copyPopsqlQueryWidgetAssets(): Plugin { + return { + name: 'copy-popsql-query-widget-assets', + apply: 'build', + async generateBundle() { + const require = createRequire(import.meta.url); + const widgetPkgJson = require.resolve('@timescale/popsql-query-widget/package.json'); + const widgetDir = dirname(widgetPkgJson); + const pattern = /^(duckdb-browser-(?:eh|mvp)\.worker\.js|editor\.worker\.js|.+\.wasm)$/; + for (const entry of readdirSync(widgetDir, { withFileTypes: true })) { + if (!entry.isFile() || !pattern.test(entry.name)) continue; + this.emitFile({ + type: 'asset', + fileName: `assets/${entry.name}`, + source: readFileSync(join(widgetDir, entry.name)), + }); + } + }, + }; +} + export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + // The widget bundle assumes Node globals (Buffer, process, etc.) exist; + // match the shim list web-cloud uses with this widget. + nodePolyfills({ include: ['buffer', 'crypto', 'process', 'stream'] }), + copyPopsqlQueryWidgetAssets(), + ], + optimizeDeps: { + // The widget bundle expects its workers to live next to its main chunk; + // letting Vite pre-bundle it breaks that assumption. + exclude: ['@timescale/popsql-query-widget'], + }, server: { port: 5173, strictPort: false, From 46d1af61dc4aa54a35bd076aa130748ce1e6e224 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Fri, 29 May 2026 11:27:35 -0400 Subject: [PATCH 05/39] Polish ghost serve and ungate from GHOST_EXPERIMENTAL - Promote `ghost serve` to the public command surface (registered unconditionally in root.go). - Update CLAUDE.md to describe internal/serve/ + web/ alongside the existing internal/{cmd,mcp,common,...} entries. - Update README.md Commands table + Usage example. - Regenerate docs/cli/ from cobra (adds docs/cli/ghost_serve.md and the corresponding link in docs/cli/ghost.md). - Add focused tests: * internal/cmd/serve_test.go - asserts the not-logged-in error path routes through App.GetClient like every other command. * internal/serve/wire_test.go - covers the statements-vs-query fallback for executeQueryRequest.SQL() plus the embedded-struct decode path used by executeSessionQueryRequest. * internal/serve/assets_test.go - exercises the placeholder page (fresh checkout, no embedded UI) and, when a real bundle is present, the SPA fallback + cache-control headers. * internal/serve/store_test.go - runStore add/get/delete, Run setError/closeDone idempotence, sessionStore.closeAll. - Drop the favicon 404 (data: URL placeholder) and add name/aria-label to the picker so the only remaining a11y warnings come from inside the widget itself. --- CLAUDE.md | 4 +- README.md | 2 + docs/cli/ghost.md | 1 + docs/cli/ghost_serve.md | 51 ++++++++++++++++++++++ internal/cmd/root.go | 4 +- internal/cmd/serve_test.go | 18 ++++++++ internal/serve/assets_test.go | 81 +++++++++++++++++++++++++++++++++++ internal/serve/store_test.go | 61 ++++++++++++++++++++++++++ internal/serve/wire_test.go | 69 +++++++++++++++++++++++++++++ plan.md | 39 ++++++++--------- web/index.html | 2 +- web/src/app.tsx | 2 + 12 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 docs/cli/ghost_serve.md create mode 100644 internal/cmd/serve_test.go create mode 100644 internal/serve/assets_test.go create mode 100644 internal/serve/store_test.go create mode 100644 internal/serve/wire_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1beea42..3ce84ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,14 +5,16 @@ - **`cmd/`** - Binary entry points. Contains `ghost/main.go` (the main CLI binary, which sets up context/signal handling and delegates to the internal command infrastructure), `npm-publisher/` (a CI tool that generates and publishes npm packages for each platform), `generate-docs/` (generates Markdown CLI reference docs to `docs/cli/`), and `generate-tutorial-docs/` (renders every tutorial in the `allTutorials()` registry to `docs/tutorials/`, sharing source-of-truth step data with the live `ghost tutorial` command). - **`internal/`** - All core application logic (non-public Go packages). - **`internal/tutorial/`** - Data definitions for Ghost's guided tutorials. Each `Tutorial` is a struct bundling `Filename`, narrative (`Title`/`Callout`/`Intro`), an ordered `[]Step`, and an optional `DeleteStep`. Blocks carry CLI args, prose, expected output (markdown-only), and a `Target` enum that scopes them to CLI runs, doc renders, or both. `All()` is the registry imported by both the live CLI command and the `generate-tutorial-docs` binary. - - **`internal/cmd/`** - Cobra command implementations for all CLI commands (init, tutorial, create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). + - **`internal/cmd/`** - Cobra command implementations for all CLI commands (init, tutorial, create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, serve, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). - **`internal/api/`** - API client layer. Includes an OpenAPI-generated REST client (`client.go`, `types.go`), shared HTTP client singleton, and request/response types. **Do not edit `client.go` or `types.go` by hand** — they are generated from `openapi.yaml` (see [Code Generation](#code-generation)). The `mock/` subdirectory contains a generated mock of `ClientWithResponsesInterface` for use in tests. - **`internal/config/`** - Configuration management. Handles config file loading (via Viper), credential storage (keyring with file fallback), and version checking. - **`internal/common/`** - Shared business logic used across commands and MCP tools. Includes API client initialization, database connection/schema/query utilities, error handling with exit codes, and version update checks. - **`internal/mcp/`** - Model Context Protocol (MCP) server. Exposes Ghost database operations as MCP tools for AI/LLM integration, plus a documentation search proxy. Each MCP tool lives in its own file, named to match the tool (e.g. `ghost_usage` → `usage.go`). Helper files like `util.go`, `errors.go`, and `proxy.go` contain shared utilities. + - **`internal/serve/`** - Local web UI for `ghost serve`. Embeds a Vite/React SPA (from `web/dist`) via `//go:embed` and exposes the wire protocol the unmodified `@timescale/popsql-query-widget` expects (`/api/executeQuery`, `/api/arrowResults`, `/api/createSession`, `/api/sessionEvents`, `/api/closeSession`, `/api/executeSessionQuery`, `/api/cancelRun`) plus a read-only `/api/databases` passthrough and `/api/bootstrap` config dump. Query execution runs in-process via the `dbdriver/` sub-package (Postgres-only port of popsql-query's driver + pgx/v5/stdlib for OID-aware scan types) and the `dbtypes/` sub-package (custom scan receivers for Date/Numeric/JSON/etc.). Rows are encoded as Apache Arrow IPC stream batches (`arrow.go`, ported from popsql-query's writer). - **`internal/analytics/`** - Analytics event tracking with sensitive data redaction for flags, positional arguments, and MCP inputs. - **`internal/util/`** - General utilities: type conversion, duration formatting, path helpers, context-aware stdin reading, JSON/YAML serialization, and terminal detection. - **`docs/`** - Documentation. `docs/cli/` contains generated Markdown CLI reference docs (produced by `cmd/generate-docs`). +- **`web/`** - Vite + React workspace for the `ghost serve` browser UI. Built via `scripts/build-web.sh` (which uses the self-bootstrapping `./bun` wrapper) into `web/dist/`, then synced into `internal/serve/web/` for the Go binary's `//go:embed` directive. Uses React 18 (the widget calls `findDOMNode` which was removed in React 19), Tailwind v3 (matches the widget's pinned version), TanStack Query for `/api/databases`/`/api/bootstrap` polling, and `vite-plugin-node-polyfills` for the widget's Buffer/crypto/process/stream shims (same list web-cloud uses). The widget's worker + wasm sidecars are emitted into `assets/` via a custom Vite plugin (ported from web-cloud) because Vite's static analysis misses the `new URL(, import.meta.url)` references inside the widget's worker chunk. - **`scripts/`** - Build and installation scripts (install.sh, install.ps1, completions generation). - **`openapi.yaml`** - OpenAPI spec used to generate the API client. Should be kept in sync with the canonical spec in the `ghost-api` repo (see [Code Generation](#code-generation)). - **`.github/`** - GitHub Actions CI/CD workflows for testing and releases. diff --git a/README.md b/README.md index 9cf4b1d..e305f05 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ npm install -g @ghost.build/cli ghost init # Interactively configure Ghost (PATH, login, MCP, completions) ghost create # Create a new Postgres database ghost list # List all databases +ghost serve # Open a local web UI for running SQL queries ``` Learn more about ghost's forking workflow and other features with the interactive tutorial: @@ -81,6 +82,7 @@ ghost tutorial | `logout` | Remove stored credentials | | `mcp` | Ghost Model Context Protocol (MCP) server | | `password` | Reset the password for a database | +| `serve` | Launch a local web UI for running SQL queries | | `pause` | Pause a running database | | `payment` | Manage payment methods | | `pricing` | Show compute overage and dedicated database pricing | diff --git a/docs/cli/ghost.md b/docs/cli/ghost.md index f9b4937..14e9d5f 100644 --- a/docs/cli/ghost.md +++ b/docs/cli/ghost.md @@ -49,6 +49,7 @@ Ghost is a command-line interface for managing PostgreSQL databases. * [ghost rename](ghost_rename.md) - Rename a database * [ghost resume](ghost_resume.md) - Resume a paused database * [ghost schema](ghost_schema.md) - Display database schema information +* [ghost serve](ghost_serve.md) - Launch a local web UI for running SQL queries * [ghost share](ghost_share.md) - Share a database * [ghost sql](ghost_sql.md) - Execute SQL query on a database * [ghost tutorial](ghost_tutorial.md) - Run an interactive Ghost tutorial diff --git a/docs/cli/ghost_serve.md b/docs/cli/ghost_serve.md new file mode 100644 index 0000000..6eacecc --- /dev/null +++ b/docs/cli/ghost_serve.md @@ -0,0 +1,51 @@ +--- +title: "ghost serve" +slug: "ghost_serve" +description: "CLI reference for ghost serve" +--- + +## ghost serve + +Launch a local web UI for running SQL queries + +### Synopsis + +Start a local web server on 127.0.0.1 and open a browser to a UI that lets +you run SQL queries against your ghost databases. The server runs only for +the duration of this command — press Ctrl+C to stop it. + +``` +ghost serve [flags] +``` + +### Examples + +``` + # Launch on an auto-picked port and open the browser + ghost serve + + # Pin a port and skip the browser + ghost serve --port 5174 --no-open +``` + +### Options + +``` + -h, --help help for serve + --host string interface to bind (loopback by default) (default "127.0.0.1") + --no-open do not open the browser + --port int TCP port to listen on (0 = auto) +``` + +### Options inherited from parent commands + +``` + --analytics enable/disable usage analytics (default true) + --color enable colored output (default true) + --config-dir string config directory (default "~/.config/ghost") + --version-check check for updates (default true) +``` + +### SEE ALSO + +* [ghost](ghost.md) - CLI for managing Postgres databases diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5fccfbf..0d1035a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -143,9 +143,7 @@ monthly usage.`, cmd.AddCommand(buildUpgradeCmd(app)) cmd.AddCommand(buildInvoiceCmd(app)) cmd.AddCommand(buildOveragesCmd(app)) - if app.Experimental { - cmd.AddCommand(buildServeCmd(app)) - } + cmd.AddCommand(buildServeCmd(app)) wrapCommands(cmd, app) diff --git a/internal/cmd/serve_test.go b/internal/cmd/serve_test.go new file mode 100644 index 0000000..fadceac --- /dev/null +++ b/internal/cmd/serve_test.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "errors" + "testing" +) + +func TestServeCmd(t *testing.T) { + tests := []cmdTest{ + { + name: "not logged in", + args: []string{"serve", "--no-open"}, + opts: []runOption{withClientError(errors.New("authentication required: no credentials found"))}, + wantErr: "authentication required: no credentials found", + }, + } + runCmdTests(t, tests) +} diff --git a/internal/serve/assets_test.go b/internal/serve/assets_test.go new file mode 100644 index 0000000..b3d1426 --- /dev/null +++ b/internal/serve/assets_test.go @@ -0,0 +1,81 @@ +package serve + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAssetHandler_PlaceholderWhenNoBundle(t *testing.T) { + // internal/serve/web/ contains only .gitkeep when this test runs in a + // fresh checkout; hasBundledUI() returns false and the placeholder + // page is served. + if hasBundledUI() { + t.Skip("web bundle is present; placeholder behavior is exercised in fresh checkouts") + } + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + newAssetHandler().ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html...", ct) + } + if !strings.Contains(w.Body.String(), "build-web.sh") { + t.Errorf("placeholder body missing build-web.sh hint:\n%s", w.Body.String()) + } +} + +func TestAssetHandler_RealBundleSPABehavior(t *testing.T) { + if !hasBundledUI() { + t.Skip("requires built web bundle (run scripts/build-web.sh)") + } + h := newAssetHandler() + + cases := []struct { + name string + path string + wantStatus int + wantCache string + wantBody string + }{ + { + name: "root serves index.html", + path: "/", + wantStatus: http.StatusOK, + wantCache: "no-cache", + wantBody: "", + }, + { + name: "SPA fallback for paths without extension", + path: "/some/route", + wantStatus: http.StatusOK, + wantCache: "no-cache", + wantBody: "", + }, + { + name: "missing dotted asset is 404", + path: "/missing.png", + wantStatus: http.StatusNotFound, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + h.ServeHTTP(w, r) + if w.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus) + } + if tc.wantCache != "" && w.Header().Get("Cache-Control") != tc.wantCache { + t.Errorf("Cache-Control = %q, want %q", w.Header().Get("Cache-Control"), tc.wantCache) + } + if tc.wantBody != "" && !strings.Contains(strings.ToLower(w.Body.String()), tc.wantBody) { + t.Errorf("body missing %q:\n%.300s", tc.wantBody, w.Body.String()) + } + }) + } +} diff --git a/internal/serve/store_test.go b/internal/serve/store_test.go new file mode 100644 index 0000000..7175f64 --- /dev/null +++ b/internal/serve/store_test.go @@ -0,0 +1,61 @@ +package serve + +import ( + "testing" + + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +func TestRunStore_AddGetDelete(t *testing.T) { + s := newRunStore() + if got := s.get("missing"); got != nil { + t.Errorf("get(missing) = %v, want nil", got) + } + + r := &Run{id: "abc"} + s.add(r) + if got := s.get("abc"); got != r { + t.Errorf("get(abc) = %v, want %v", got, r) + } + + s.delete("abc") + if got := s.get("abc"); got != nil { + t.Errorf("get(abc) after delete = %v, want nil", got) + } +} + +func TestRun_SetErrorIsIdempotent(t *testing.T) { + r := &Run{done: make(chan struct{})} + first := &dbdriver.NormalizedError{Message: "first"} + second := &dbdriver.NormalizedError{Message: "second"} + + r.setError(first) + r.setError(second) + + if r.err != first { + t.Errorf("err = %v, want first call to win", r.err) + } +} + +func TestRun_CloseDoneIsIdempotent(t *testing.T) { + r := &Run{done: make(chan struct{})} + r.closeDone() + r.closeDone() // must not panic on double-close + select { + case <-r.done: + default: + t.Fatal("done channel should be closed") + } +} + +func TestSessionStore_CloseAllReleasesEverything(t *testing.T) { + s := newSessionStore() + s.add(&Session{id: "a", closed: make(chan struct{})}) + s.add(&Session{id: "b", closed: make(chan struct{})}) + + s.closeAll() + + if s.get("a") != nil || s.get("b") != nil { + t.Errorf("sessions remain after closeAll") + } +} diff --git a/internal/serve/wire_test.go b/internal/serve/wire_test.go new file mode 100644 index 0000000..f777794 --- /dev/null +++ b/internal/serve/wire_test.go @@ -0,0 +1,69 @@ +package serve + +import ( + "encoding/json" + "testing" +) + +func TestExecuteQueryRequest_SQL(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "statements array (widget canonical)", + body: `{"projectId":"p","serviceId":"s","runId":"r","statements":["SELECT 1;"],"stream":true}`, + want: "SELECT 1;", + }, + { + name: "multiple statements joined with ;", + body: `{"projectId":"p","serviceId":"s","runId":"r","statements":["SELECT 1","SELECT 2"]}`, + want: "SELECT 1; SELECT 2", + }, + { + name: "falls back to query field when statements is empty", + body: `{"projectId":"p","serviceId":"s","runId":"r","query":"SELECT 3","statements":[]}`, + want: "SELECT 3", + }, + { + name: "falls back to query field when statements is omitted", + body: `{"projectId":"p","serviceId":"s","runId":"r","query":"SELECT 4"}`, + want: "SELECT 4", + }, + { + name: "empty when both fields are blank", + body: `{"projectId":"p","serviceId":"s","runId":"r"}`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req executeQueryRequest + if err := json.Unmarshal([]byte(tt.body), &req); err != nil { + t.Fatalf("decode: %v", err) + } + if got := req.SQL(); got != tt.want { + t.Errorf("SQL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExecuteSessionQueryRequest_DecodesEmbedded(t *testing.T) { + body := `{"projectId":"p","serviceId":"s","runId":"r","sessionId":"sess","statements":["SELECT 1"],"stream":true}` + var req executeSessionQueryRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("decode: %v", err) + } + if req.SessionID != "sess" { + t.Errorf("SessionID = %q, want %q", req.SessionID, "sess") + } + if req.RunID != "r" { + t.Errorf("RunID = %q, want %q", req.RunID, "r") + } + if got := req.SQL(); got != "SELECT 1" { + t.Errorf("SQL() = %q, want %q", got, "SELECT 1") + } +} diff --git a/plan.md b/plan.md index 48f0469..eed9606 100644 --- a/plan.md +++ b/plan.md @@ -447,22 +447,14 @@ Why not the memory-engine base64-into-TS approach: Go's `embed.FS` is the natura - [x] Discovery: `../ox/bun` self-bootstrap wrapper - [x] Discovery: `web-cloud/.yarnrc.yml` + deploy-to-dev GH workflow → bun equivalent - [x] Discovery: Arrow Go module path + pgx version pinning -- [x] Step 1 — `ghost serve` skeleton + static SPA + `/api/databases` (Go side validated end-to-end; SPA build pending widget npm auth — see below) -- [ ] Step 2 — query execution path (executeQuery, arrowResults, sessions, cancel) -- [ ] Step 3 — polish, docs, ungate from `GHOST_EXPERIMENTAL` -- [ ] E2E test pass +- [x] Step 1 — `ghost serve` skeleton + static SPA + `/api/databases` (validated end-to-end) +- [x] Step 2 — query execution path (executeQuery, arrowResults, sessions, cancel) — widget integration + 6-type smoke test +- [x] Step 3 — polish, docs, ungate from `GHOST_EXPERIMENTAL` +- [x] E2E test pass (manual via Chrome DevTools MCP against a live Timescale Postgres) -### Blocker: `@timescale/popsql-query-widget` private-registry auth +### Resolved: `@timescale/popsql-query-widget` private-registry auth -Bun install gets 403 from `https://npm.pkg.github.com/@timescale%2fpopsql-query-widget` because the default `gh auth` token only has `repo` / `read:org` scopes, not `read:packages`. The bun bootstrap itself and the rest of the dependency graph (407 packages) resolved fine. - -Fix options for the user: -1. `gh auth refresh -h github.com -s read:packages` (adds the scope to the existing gh CLI token; nothing else changes). -2. Create a fine-grained PAT at github.com/settings/tokens with `read:packages` scope and export `NPM_AUTH_TOKEN=` (one-off / CI-friendly). - -In CI, the auto-provisioned `secrets.GITHUB_TOKEN` already has `read:packages` (it does for repos that own the package, which we assume `timescale/ghost` does — needs confirmation). - -Once auth is unblocked, `./scripts/build-web.sh` produces `web/dist/` and the embed pipeline picks it up automatically on the next Go build. +Initial `bun install` got 403 from `https://npm.pkg.github.com/@timescale%2fpopsql-query-widget` because the default `gh auth` token only had `repo` / `read:org`. Resolution: `gh auth refresh -h github.com -s read:packages` adds the missing scope; `scripts/build-web.sh` then resolves the widget cleanly. CI uses `secrets.GITHUB_TOKEN` which already has `read:packages` for owner-controlled packages. --- @@ -525,13 +517,18 @@ Bun reads `.npmrc` natively (and `bunfig.toml` for bun-specific options). Plan: --- -## Sequencing suggestion +## Sequencing (as delivered) + +Three commits on `murrayju/serve`, in this order: -Single PR is plausible but reviewable in three logical sub-stacks: +1. `ae9e4f9` — Add ghost serve skeleton. Cobra command behind `GHOST_EXPERIMENTAL`, embed.FS asset handler with SPA fallback + cache headers, `/api/bootstrap` + `/api/databases`, Vite/React workspace with picker + empty body, bun self-bootstrap wrapper, scripts/build-web.sh. +2. `2df095e` — Fix build-web.sh and pin widget version (`--cwd` doesn't apply to bun's `run`; widget pinned to `0.0.0-dev.156`). +3. `43921bd` — Wire the popsql query widget into ghost serve. Ported dbtypes + dbdriver + arrow encoder from popsql-query, implemented executeQuery + arrowResults + sessions + cancel handlers, wired `` into the SPA with Vite worker/wasm asset emission + node polyfills, React 18 pin. +4. *(this commit)* — Step 3 polish: ungate from `GHOST_EXPERIMENTAL`, CLAUDE.md + README updates, generated `docs/cli/ghost_serve.md`, tests for wire / assets / store / cmd, favicon + form-name a11y tidy-ups. -1. **Step 1**: `ghost serve` command skeleton + `internal/serve/{server,assets,bootstrap,databases}.go`, `web/` with picker + "hello world" body. Builds, runs, opens browser, lists databases, no query execution yet. Behind `GHOST_EXPERIMENTAL`. -2. **Step 2**: `internal/serve/{execute,arrow,runs,pgtypes,cancel}.go` and the Arrow Go dep. Wire `` into the SPA. End-to-end query execution working. -3. **Step 3**: polish (cache reaping, error message tuning, README + `docs/cli/`); ungate from `GHOST_EXPERIMENTAL`. -4. **Step 4 (later)**: session mode. +## Follow-ups (out of scope for this branch) -Each step lands as its own PR with `murrayju/` prefix. +- Multi-result-set support: `sql.Rows` only iterates the first result, so multi-statement queries (e.g. two `SELECT`s in one Run) currently show only the first table. +- `Decimal128` for NUMERIC instead of `Utf8` — needs a popsql-query upgrade too. +- Query timeout enforcement (the widget sends `timeout` but we ignore it). +- Server-side `slog` logging + log levels (currently silent by design). diff --git a/web/index.html b/web/index.html index 3e2a965..7e700f9 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,7 @@ - + ghost diff --git a/web/src/app.tsx b/web/src/app.tsx index 47d569e..cb49b44 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -73,6 +73,8 @@ export function App() { Failed to load databases ) : ( ` populated from `GET /api/databases`. Selection mirrored to `?db=`. -- Body: full-height ``. Empty state when no DB selected. -- Auto-select if exactly one ready database. Non-ready statuses shown but disabled. - -### Wiring the widget - -```tsx - - - - ({ - engine: ExecuteQueryEngine.timescaleQuery, - params: { projectId, serviceId: databaseId, query, runId }, - })} - /> - {({ render }) => render()} - - - -``` - -`projectId` is fetched from `GET /api/bootstrap` at app boot; `databaseId` comes from the picker (and URL state). The widget hits `${origin}/api/{executeQuery,arrowResults,cancelRun}` — all served by the Go server in-process. - -### Vite config - -- `server.proxy` forwards `/api/*` to a configurable local port for dev mode (`ghost serve --port 5174` while Vite runs on `:5173`). -- Copy Monaco workers + DuckDB workers/wasm into the build output adjacent to `index.js` (port the relevant `Vite.config.ts` plugin from `web-cloud/vite.config.ts:30-203`). -- Exclude `@timescale/popsql-query-widget` from `optimizeDeps` so workers resolve sibling chunks correctly. -- Output to `web/dist/`. - ---- - -## The local HTTP server (`internal/serve/`) - -### Routes - -``` -GET /healthz → {"ok":true} -GET /api/bootstrap → { projectId, version } -GET /api/databases → ghost-api passthrough (list user's databases) -POST /api/executeQuery → NDJSON producer; runs query in-process -POST /api/arrowResults → Arrow IPC producer; consumes runId state -POST /api/cancelRun → best-effort pg_cancel_backend -* everything else → embedded SPA (SPA fallback to index.html) -``` - -`/api/databases` is the only ghost-api hop. Implemented as a thin call: read the API client from `App`, hit `ListDatabases`, return the JSON shape the SPA expects (probably the API's own DTO — defer trimming until UI work). Password lookup happens lazily inside `executeQuery` via `common.GetPassword`, not in `/api/databases`. - -### Static asset serving (`assets.go`) - -- `//go:embed all:web` rooted at `internal/serve/web/`. Built by `scripts/build-web.sh`. -- Resolver: - - `/` → `/index.html`. - - Exact FS match → serve with detected `Content-Type`. - - Path with no extension in last segment → `index.html` (SPA fallback). - - Otherwise → 404. -- Cache headers: - - `Cache-Control: public, max-age=31536000, immutable` for `/assets/*`. - - `Cache-Control: no-cache` for everything else. -- Empty FS handling: if `index.html` is absent, serve a placeholder HTML linking to `scripts/build-web.sh`. Lets `go build` / `go test` work without a JS build. - -### `Run` store (`runs.go`) - -```go -type Run struct { - ID string - ProjectID string - ServiceID string - Schema *arrow.Schema - Records <-chan arrow.Record // closed when query finishes - Done chan struct{} // closed on terminal status - Err error // set before Done is closed - RowCount int64 - Cancel context.CancelFunc // cancels the pg query context - StartedAt time.Time - FinishedAt time.Time -} -``` - -- `runs.Store` is a `sync.Map[string]*Run` keyed by `runId`. -- Runs older than N minutes (e.g. 10) are reaped by a background goroutine after `Done` closes. -- `executeQuery` registers a `Run`, kicks off the PG query in a goroutine, and returns the NDJSON stream from the same handler. The goroutine pushes record batches into `Run.Records`. -- `arrowResults` looks up the `Run`, waits for `Schema` to be set, writes the IPC schema, then range-reads from `Run.Records` and writes each batch to the response with `Flush()`. -- `cancelRun` calls `Run.Cancel()`. - -### `executeQuery` flow (`execute.go`) - -1. Decode request body into `wire.ExecuteQueryRequest`. -2. Validate `projectId` matches the CLI's active project; reject mismatches with 403 (the binary is single-user but defense in depth). -3. Resolve `serviceId` → `(host, port, database, sslmode, ...)` via `app.GetClient().GetDatabaseWithResponse(ctx, serviceID)` cache. -4. Resolve password via `common.GetPassword(database)`. If `common.ErrPasswordNotFound`, terminate the stream with `{ "success": false, "error": { "message": "no password found — run 'ghost password ' or add to ~/.pgpass" } }`. -5. `common.CheckReady(database)` — if not ready, return the same shape with the actionable message. -6. Open a `pgx.Conn` (per-query, closed on goroutine exit). -7. `conn.Query(ctx, sql)` — capture `FieldDescriptions` once available. -8. Build `arrow.Schema` from the `FieldDescriptions` (`pgtypes.go`). -9. Write `{ "columns": [...] }` NDJSON line to the executeQuery response writer + `http.Flusher.Flush()`. Register `Run.Schema` so `arrowResults` can proceed. -10. Stream rows → `array.RecordBuilder` (chunked at e.g. 1024 rows per batch) → push each `arrow.Record` to `Run.Records`. -11. On `rows.Err()`: close `Run.Records`, write `{ "success": false, "error": ... }`, close. -12. On clean finish: close `Run.Records`, write `{ "success": true, "rowCount": N, ... }`, close. -13. On `ctx.Done()` (client aborted or `/api/cancelRun` invoked): `pg_cancel_backend` via a side channel connection, mark `Run` with `error.cancel: true`. - -### `arrowResults` flow (`arrow.go`) - -1. Decode request body, look up `Run`. -2. `Content-Type: application/vnd.apache.arrow.stream`. -3. `ipc.NewWriter(w, ipc.WithSchema(run.Schema))`. -4. Range over `Run.Records` — `writer.Write(record)` + `Flush()` after each. -5. `writer.Close()` when the channel closes. -6. If `Run.Err` was set before any batch arrived, return HTTP 500 with `{ error: { message: ... } }`. - -### `cancelRun` flow - -Look up `Run`, call `Run.Cancel()`. Return `204 No Content`. `executeQuery`'s goroutine sees `ctx.Done()` and terminates with `error.cancel: true`. - -### Port selection / browser open / shutdown - -Same as memory-engine's pattern, ported to Go: -- `net.Listen("tcp", host+":0")` for kernel-assigned port (no probe/release race). -- Explicit `--port` is strict — bind failure surfaces directly. -- `common.OpenBrowserAsync(url)` (existing helper). -- Graceful shutdown on `cmd.Context().Done()` via `srv.Shutdown(ctx)` with a 5s deadline. - ---- - -## Asset embedding - -Same as the previous plan version — `embed.FS` rooted at `internal/serve/web/`, populated by `scripts/build-web.sh` (`cd web && npm ci && npm run build && cp -r dist/* ../internal/serve/web/`). `web/dist/` is git-ignored; `internal/serve/web/.gitkeep` makes `//go:embed` happy. - -Why not the memory-engine base64-into-TS approach: Go's `embed.FS` is the natural fit, keeps assets raw (no 4/3× inflation), and eliminates a build step. - ---- - -## ghost-api changes - -**None.** This was the main motivation for the lean approach. The only ghost-api call from the local server is the read-only `ListDatabases` (and `GetDatabase` for password fetch), which already exist. - ---- - -## Build / CI - -- `scripts/build-web.sh` runs `npm ci` + `npm run build` and syncs `web/dist/` → `internal/serve/web/`. -- `check` script runs `scripts/build-web.sh` before `go install`. -- CI: in `.github/workflows/*.yml`, install Node 22, run `scripts/build-web.sh` before any Go build step (test, lint, release). -- `Dockerfile`: multi-stage — `node:22` stage builds `web/dist/` and copies into `internal/serve/web/`, then `golang:1.x` stage builds the binary. -- Release pipeline (GoReleaser) needs Node available — easiest is to invoke `scripts/build-web.sh` as a `before` hook in `.goreleaser.yaml`. -- Binary size impact: rough order-of-magnitude based on memory-engine (~7MB compressed assets, mostly Monaco + DuckDB-WASM). Acceptable. - ---- - -## Testing - -### Go side (`CLAUDE.md` patterns) - -- `internal/cmd/serve_test.go` — `runCommand` harness with mock API client; assert `--no-open`, auto-port, explicit port collision error, graceful shutdown on context cancel. -- `internal/serve/server_test.go` — full-stack server, `/healthz`, `/api/bootstrap`, asset serving, SPA fallback, 404 on missing static asset. -- `internal/serve/assets_test.go` — covers cache headers (`/assets/*` immutable, others no-cache). -- `internal/serve/execute_test.go` — fires a real PG query against a `pgmock` or a containerized Postgres (CI), asserts NDJSON output: `columns` line, then `success` line; verifies the parallel `/api/arrowResults` returns a valid IPC stream that round-trips through Apache Arrow Go's reader and matches the expected rows. -- `internal/serve/arrow_test.go` — unit tests for PG OID → Arrow type mapping, value coercion edge cases (null, numeric, array, json, timestamptz). -- `internal/serve/cancel_test.go` — verifies `/api/cancelRun` interrupts a long-running query and surfaces `error.cancel: true` on executeQuery. - -### Web side - -- One Vitest unit per logic module: `lib/url-state.ts` round-trip, `api/databases.ts` selector behavior. -- No widget integration tests; the widget is treated as a black box. - -### End-to-end (optional, follow-up) - -- Playwright smoke: spin up `ghost serve --no-open --port 5599` against a test ghost database, drive the UI to run `select 1`, assert the result table shows `1`. - ---- - -## Out of scope (for MVP) - -- Session mode (`createSession`, `sessionEvents`, `executeSessionQuery`, `closeSession`). -- Saving / loading queries; per-DB query history. -- Schema browser sidebar. -- Multi-tab queries. -- Numeric precision-preserving Arrow `Decimal128` (we use `Utf8` for `numeric`). -- Query timeout enforcement beyond the existing `HTTPClient.Timeout`. -- Auth on the localhost listener (loopback bind is the boundary). -- Embedding into the docker image as a primary use case — Dockerfile still works but `ghost serve` from inside a container isn't a target. - ---- - -## Decisions (locked in) - -| # | Topic | Decision | -|---|-------|----------| -| 1 | Apache Arrow Go dep | Yes — keep the widget unmodified, ship the Arrow dep. | -| 2 | DB driver | `pgx/v5` — copy popsql-query's approach verbatim. | -| 3 | Browser open | Open by default; `--no-open` flag opts out. | -| 4 | Web app dir | `web/` at repo root. | -| 5 | JS package manager | **Bun**, via a self-bootstrapping `./bun` wrapper script (copied from `../ox/bun`). No new system dependencies. | -| 6 | Private npm registry | `@timescale/*` is on GitHub Packages. Translate `web-cloud/.yarnrc.yml` into `bunfig.toml`'s `[install.scopes]` block; CI gets the token from `GITHUB_TOKEN` or a fine-grained PAT, mirroring web-cloud's deploy-to-dev workflow. | -| 7 | Tailwind | v3 — widget is pinned to v3. | -| 8 | Not-logged-in handling | Fail fast with the standard `ghost login` hint. | -| 9 | Session mode | **In MVP.** `sessionKey` is derived from the selected database ID — switching DBs invalidates the session; page reload mints a new one; `SessionError` triggers the widget's built-in re-create flow. | -| 10 | Project scope | No project switcher; SPA inherits the CLI's active space. | -| 11 | Localhost auth | No URL token; bind to `127.0.0.1` only. | -| 12 | Type handling | Copy popsql-query's PG OID → Arrow type + value coercion logic verbatim. Don't redesign. | -| 13 | E2E testing | Playwright + Chrome DevTools MCP. Throwaway test DB via `ghost create`. | - ---- - -## Progress - -- [x] Research: widget exports + wire protocol -- [x] Research: web-cloud integration patterns -- [x] Research: memory-engine `me serve` implementation -- [x] Plan v1 — proxy-through-ghost-api architecture -- [x] Plan v2 — in-process query execution architecture (this document) -- [x] Discovery: popsql-query type mapping + Arrow writer + cancellation -- [x] Discovery: `../ox/bun` self-bootstrap wrapper -- [x] Discovery: `web-cloud/.yarnrc.yml` + deploy-to-dev GH workflow → bun equivalent -- [x] Discovery: Arrow Go module path + pgx version pinning -- [x] Step 1 — `ghost serve` skeleton + static SPA + `/api/databases` (validated end-to-end) -- [x] Step 2 — query execution path (executeQuery, arrowResults, sessions, cancel) — widget integration + 6-type smoke test -- [x] Step 3 — polish, docs, ungate from `GHOST_EXPERIMENTAL` -- [x] E2E test pass (manual via Chrome DevTools MCP against a live Timescale Postgres) - -### Resolved: `@timescale/popsql-query-widget` private-registry auth - -Initial `bun install` got 403 from `https://npm.pkg.github.com/@timescale%2fpopsql-query-widget` because the default `gh auth` token only had `repo` / `read:org`. Resolution: `gh auth refresh -h github.com -s read:packages` adds the missing scope; `scripts/build-web.sh` then resolves the widget cleanly. CI uses `secrets.GITHUB_TOKEN` which already has `read:packages` for owner-controlled packages. - ---- - -## Discovery findings - -### popsql-query — what to port verbatim - -Layout (`/Users/murrayju/dev/timescale/popsql-query/internal/`): - -| Path | Lines | Action | -|------|-------|--------| -| `types/{binary,date,guid,json,numeric,types}.go` | 290 | **Port verbatim** — custom scan types preserving precision/special values (NaN, ±Inf, Postgres `bytea` hex format, plain Date/DateTime without TZ, Numeric, JSON, GUID). | -| `driver/adapter.go` | 219 | **Port partial** — keep `baseAdapter` / `baseDriver` / `Rows` / `QueryArgs` / `Columns` machinery and `cancelContext` helper; drop the multi-driver registry (we only need PG). | -| `driver/postgres.go` | ~250 | **Port verbatim** — `pgx/v5/stdlib.OpenDB` integration, `postgresQueryTracer` for `CommandTag.RowsAffected()`, `scanType` overrides for JSON/JSONB/NUMERIC/BYTEA/DATE/TIMESTAMP/TIMESTAMPTZ, `NormalizeError` for `pgconn.PgError` (fatal flag, code/detail/hint/position, multi-statement guard). | -| `writer/arrow.go` | ~340 | **Port verbatim** — `RecordBuilder` wrapper around `array.RecordBuilder`; appends `__popsql_row_num__` Int64 column to every schema (preserves original row order); attaches `__popsql_columns__` JSON metadata to the schema (original column descriptors round-trip to the frontend); `arrowType()` + `builderFn` mapping by `ScanType`. | -| `writer/record.go` | 307 | **Port partial** — the row-iteration loop + record-flush logic. | -| `writer/result.go` | 393 | **Port partial** — only the success/error result envelopes; drop Parquet/CSV/TSV writers. | -| `handler.go`, `run.go`, `session.go`, `store.go` | 1311 | **Do NOT port** — we write fresh handlers that match the widget's wire protocol (different URL shape, different request bodies, single-user, no Redis clustering, no S3 upload). | - -Key patterns inherited: - -- **Cancellation**: `pgConn.CancelRequest(ctx)` is the canonical pg cancel — captured via `b.conn.Raw(func(driverConn any) error { pgConn = driverConn.(*stdlib.Conn).Conn().PgConn(); return nil })` after `stdlib.OpenDB`. Wired through `cancelContext()` in `adapter.go`. -- **Row order**: Arrow batches don't guarantee row order across batches, so popsql-query appends a synthetic `__popsql_row_num__` Int64 column. We adopt the same convention so the widget's table component renders in query order. -- **Schema metadata**: original column descriptors (with PG type name) are JSON-encoded and stashed in the Arrow schema's metadata under `__popsql_columns__`. The frontend uses this for type-aware rendering. -- **Application name**: connections set `application_name` to a constant for server-side identification (port the constant; rename it to `"ghost-cli"` or similar). -- **SSL handling**: pgx's default `sslmode=prefer` is used unless an explicit TLS config is set, in which case `pgxCfg.Fallbacks = nil` to require encryption. Ghost cloud DBs require TLS, so we'll set explicit TLS config. - -### `../ox/bun` self-bootstrap wrapper - -431-byte bash script. Pinned to `bun-v1.3.14`. Downloads to `./download/bun//bin/bun` on first invocation via the official `https://bun.sh/install` script with `BUN_INSTALL` env override. `exec`s the downloaded binary with all forwarded args. - -**Action**: copy verbatim to `./bun` in this repo; add `download/` to `.gitignore`. - -### GitHub Packages registry auth - -Web-cloud (`deploy-to-dev.yml`) uses the auto-provisioned `secrets.GITHUB_TOKEN`: - -```yaml -- name: Setup .yarnrc.yml - run: yarn config set npmScopes.timescale.npmAuthToken $NPM_AUTH_TOKEN - env: - NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -Bun reads `.npmrc` natively (and `bunfig.toml` for bun-specific options). Plan: - -- `web/.npmrc` (gitignored, committed-template version is `web/.npmrc.example`): - ``` - @timescale:registry=https://npm.pkg.github.com/ - //npm.pkg.github.com/:_authToken=${NPM_AUTH_TOKEN} - ``` -- CI sets `NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}` on the bun-install step. -- Local devs export `NPM_AUTH_TOKEN` to a GitHub PAT with `read:packages` scope (documented in README). - -### Module versions (locked to match popsql-query) - -- `github.com/apache/arrow-go/v18 v18.5.2` -- `github.com/jackc/pgx/v5 v5.8.0` (`stdlib`, `pgconn`) -- Indirect deps already pulled by popsql-query (`pgpassfile`, `pgservicefile`, `puddle/v2`). - ---- - -## Sequencing (as delivered) - -Three commits on `murrayju/serve`, in this order: - -1. `ae9e4f9` — Add ghost serve skeleton. Cobra command behind `GHOST_EXPERIMENTAL`, embed.FS asset handler with SPA fallback + cache headers, `/api/bootstrap` + `/api/databases`, Vite/React workspace with picker + empty body, bun self-bootstrap wrapper, scripts/build-web.sh. -2. `2df095e` — Fix build-web.sh and pin widget version (`--cwd` doesn't apply to bun's `run`; widget pinned to `0.0.0-dev.156`). -3. `43921bd` — Wire the popsql query widget into ghost serve. Ported dbtypes + dbdriver + arrow encoder from popsql-query, implemented executeQuery + arrowResults + sessions + cancel handlers, wired `` into the SPA with Vite worker/wasm asset emission + node polyfills, React 18 pin. -4. `d741355` — Polish ghost serve and ungate from GHOST_EXPERIMENTAL. CLAUDE.md + README updates, generated `docs/cli/ghost_serve.md`, tests for wire / assets / store / cmd, favicon + form-name a11y tidy-ups. -5. `045261e` — Build the web bundle in CI before Go builds. Both `.github/workflows/{test,release}.yaml` now run `./scripts/build-web.sh` before any Go command, sourcing the widget from GitHub Packages via the auto-provisioned `secrets.GITHUB_TOKEN`. - -## Follow-ups (out of scope for this branch) - -- **Decimal128 for NUMERIC — declined.** Postgres `NUMERIC` accepts NaN, ±Infinity, and unbounded precision; Arrow `Decimal128` is 38 digits and can't carry any of those, so an implementation would need per-row fallback branching and an upstream change in popsql-query (which uses the same string encoding for the same reasons). The widget's table already gets `isNumeric: true` for right-align + sort, so the on-wire string is a non-issue for UX. Not worth doing. -- Query timeout enforcement (the widget sends `timeout` but we ignore it). -- Server-side `slog` logging + log levels (currently silent by design). From 620517ba191c6b135ca5cef5d3546838fbce5fdf Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 3 Jun 2026 14:53:44 -0400 Subject: [PATCH 30/39] narrow file permissions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Murray --- internal/serve/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/serve/state.go b/internal/serve/state.go index cd6b6a9..2d6809f 100644 --- a/internal/serve/state.go +++ b/internal/serve/state.go @@ -79,7 +79,7 @@ func (s *stateStore) save(state serveState) error { if err := tmp.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) } - if err := os.Chmod(tmpName, 0644); err != nil { + if err := os.Chmod(tmpName, 0600); err != nil { return fmt.Errorf("chmod temp file: %w", err) } if err := os.Rename(tmpName, s.path); err != nil { From 9fcb29d28112d4e4bcd268e23982ce0484d3d67e Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 3 Jun 2026 14:56:04 -0400 Subject: [PATCH 31/39] warn when binding non-loopback Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Murray --- internal/cmd/serve.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 2c2c9fb..102fb76 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -30,6 +30,9 @@ of this command — press Ctrl+C to stop it.`, if _, _, err := app.GetClient(); err != nil { return err } + if host != "127.0.0.1" && host != "localhost" && host != "::1" { + cmd.PrintErrf("Warning: binding to %q exposes the SQL UI to your network. Consider using 127.0.0.1.\n", host) + } srv, err := serve.New(serve.Config{ Host: host, From 02e3b7189f0f63f267548b80551fb3b9987ee6c5 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 3 Jun 2026 15:05:46 -0400 Subject: [PATCH 32/39] fix nil handling in date scan methods --- internal/serve/dbtypes/date.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/serve/dbtypes/date.go b/internal/serve/dbtypes/date.go index bc84a2c..b10c6c1 100644 --- a/internal/serve/dbtypes/date.go +++ b/internal/serve/dbtypes/date.go @@ -10,7 +10,7 @@ type Date string func (d *Date) Scan(src any) error { switch val := src.(type) { case nil: - d = nil + *d = "" case string: *d = Date(val) case time.Time: @@ -26,7 +26,7 @@ type ClockTime string func (c *ClockTime) Scan(src any) error { switch val := src.(type) { case nil: - c = nil + *c = "" case string: *c = ClockTime(val) case time.Time: @@ -42,7 +42,7 @@ type ClockTimeTZ string func (c *ClockTimeTZ) Scan(src any) error { switch val := src.(type) { case nil: - c = nil + *c = "" case string: *c = ClockTimeTZ(val) case time.Time: @@ -58,7 +58,7 @@ type DateTime string func (d *DateTime) Scan(src any) error { switch val := src.(type) { case nil: - d = nil + *d = "" case string: *d = DateTime(val) case time.Time: @@ -74,7 +74,7 @@ type Timestamp string func (t *Timestamp) Scan(src any) error { switch val := src.(type) { case nil: - t = nil + *t = "" case string: *t = Timestamp(val) case time.Time: From d84f365acbbf85807dce856b55ca1c45d5341495 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 3 Jun 2026 15:34:56 -0400 Subject: [PATCH 33/39] add test coverage --- internal/cmd/main_test.go | 10 +++ internal/cmd/serve_test.go | 125 +++++++++++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index ddbbe0e..fe8ea09 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -57,6 +57,16 @@ func withStdin(input string) runOption { } } +// withContext sets the context passed to cmd.ExecuteContext. Use this for +// commands that block until the context is cancelled (e.g. `ghost serve`): +// pass an already-cancelled context to exercise the command without leaving +// a server running for the duration of the test. +func withContext(ctx context.Context) runOption { + return func(rc *runConfig) { + rc.ctx = ctx + } +} + // withIsTerminal overrides util.IsTerminal for the duration of the test. // Use this with withStdin to simulate interactive terminal input. func withIsTerminal(isTerminal bool) runOption { diff --git a/internal/cmd/serve_test.go b/internal/cmd/serve_test.go index fadceac..d896fea 100644 --- a/internal/cmd/serve_test.go +++ b/internal/cmd/serve_test.go @@ -1,18 +1,133 @@ package cmd import ( + "context" "errors" + "net" + "slices" + "strconv" + "strings" "testing" ) func TestServeCmd(t *testing.T) { - tests := []cmdTest{ + type serveCase struct { + name string + // preBindHost, if set, opens a listener on this host before invoking + // `ghost serve`. Use this to force a deterministic bind failure (the + // chosen port is substituted for the "%PORT%" placeholder in args). + preBindHost string + args []string + // opts builds the runOptions for this case. It receives *testing.T so + // helpers like a fail-if-called OpenBrowser stub can use t.Fatal. + opts func(t *testing.T) []runOption + // Exactly one of wantErr / wantErrPrefix may be set. If neither is set, + // the command is expected to succeed. + wantErr string + wantErrPrefix string + stderrIncludes []string + stderrExcludes []string + } + + tests := []serveCase{ { - name: "not logged in", - args: []string{"serve", "--no-open"}, - opts: []runOption{withClientError(errors.New("authentication required: no credentials found"))}, + name: "not logged in", + args: []string{"serve", "--no-open"}, + opts: func(t *testing.T) []runOption { + return []runOption{withClientError(errors.New("authentication required: no credentials found"))} + }, wantErr: "authentication required: no credentials found", }, + { + name: "port already in use returns bind error", + preBindHost: "127.0.0.1", + args: []string{"serve", "--no-open", "--port", "%PORT%"}, + wantErrPrefix: "listen on 127.0.0.1:", + }, + { + name: "non-loopback host emits warning before bind", + preBindHost: "0.0.0.0", + args: []string{"serve", "--no-open", "--host", "0.0.0.0", "--port", "%PORT%"}, + wantErrPrefix: "listen on 0.0.0.0:", + stderrIncludes: []string{`Warning: binding to "0.0.0.0" exposes the SQL UI to your network. Consider using 127.0.0.1.`}, + }, + { + name: "no-open skips browser", + args: []string{"serve", "--no-open"}, + opts: func(t *testing.T) []runOption { + // Cancel the context before runCommand executes so srv.Serve + // returns immediately instead of blocking on a real listener. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return []runOption{ + withContext(ctx), + withOpenBrowser(func(string) error { + t.Fatal("OpenBrowser must not be called when --no-open is set") + return nil + }), + } + }, + stderrIncludes: []string{ + "Listening on http://127.0.0.1:", + "Press Ctrl+C to stop.", + }, + stderrExcludes: []string{"Failed to open browser"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := tc.args + if tc.preBindHost != "" { + ln, err := net.Listen("tcp", tc.preBindHost+":0") + if err != nil { + t.Fatalf("pre-bind on %s: %v", tc.preBindHost, err) + } + defer ln.Close() + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + args = slices.Clone(tc.args) + for i, a := range args { + if a == "%PORT%" { + args[i] = port + } + } + } + + var opts []runOption + if tc.opts != nil { + opts = tc.opts(t) + } + result := runCommand(t, args, nil, opts...) + + switch { + case tc.wantErr != "": + if result.err == nil { + t.Fatal("expected error, got nil") + } + assertOutput(t, result.err.Error(), tc.wantErr) + case tc.wantErrPrefix != "": + if result.err == nil { + t.Fatal("expected error, got nil") + } + if !strings.HasPrefix(result.err.Error(), tc.wantErrPrefix) { + t.Errorf("err = %q, want prefix %q", result.err.Error(), tc.wantErrPrefix) + } + default: + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + } + + for _, want := range tc.stderrIncludes { + if !strings.Contains(result.stderr, want) { + t.Errorf("stderr missing %q:\n%s", want, result.stderr) + } + } + for _, unwanted := range tc.stderrExcludes { + if strings.Contains(result.stderr, unwanted) { + t.Errorf("stderr should not contain %q:\n%s", unwanted, result.stderr) + } + } + }) } - runCmdTests(t, tests) } From c2c983a22481cea1491ac7d2d4cd3f36fa102355 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 3 Jun 2026 15:35:14 -0400 Subject: [PATCH 34/39] more test coverage --- internal/serve/state_test.go | 268 +++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 internal/serve/state_test.go diff --git a/internal/serve/state_test.go b/internal/serve/state_test.go new file mode 100644 index 0000000..646361f --- /dev/null +++ b/internal/serve/state_test.go @@ -0,0 +1,268 @@ +package serve + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +// populatedState is reused across tests that exercise non-empty serialization. +var populatedState = serveState{ + SelectedDatabaseID: new("db-1"), + EditorHeight: new(240), + EditorSQL: new("select 1;"), +} + +func TestStateStore_Load(t *testing.T) { + tests := []struct { + name string + // setup runs before load. nil means "leave the temp dir empty so + // the state file is missing". + setup func(t *testing.T, store *stateStore) + wantErr bool + check func(t *testing.T, got serveState) + }{ + { + name: "missing file returns empty state", + check: func(t *testing.T, got serveState) { + if got != (serveState{}) { + t.Errorf("load = %+v, want empty state", got) + } + }, + }, + { + name: "round-trip via save restores all fields", + setup: func(t *testing.T, store *stateStore) { + if err := store.save(populatedState); err != nil { + t.Fatalf("save: %v", err) + } + }, + check: func(t *testing.T, got serveState) { + assertStringPtr(t, "SelectedDatabaseID", got.SelectedDatabaseID, "db-1") + assertIntPtr(t, "EditorHeight", got.EditorHeight, 240) + assertStringPtr(t, "EditorSQL", got.EditorSQL, "select 1;") + }, + }, + { + name: "omitted fields remain nil", + setup: func(t *testing.T, store *stateStore) { + writeStateFile(t, store, `{"selectedDatabaseId":"abc"}`) + }, + check: func(t *testing.T, got serveState) { + assertStringPtr(t, "SelectedDatabaseID", got.SelectedDatabaseID, "abc") + if got.EditorHeight != nil { + t.Errorf("EditorHeight = %v, want nil", *got.EditorHeight) + } + if got.EditorSQL != nil { + t.Errorf("EditorSQL = %v, want nil", *got.EditorSQL) + } + }, + }, + { + name: "invalid JSON returns error", + setup: func(t *testing.T, store *stateStore) { + writeStateFile(t, store, "{bad") + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := newStateStore(t.TempDir()) + if tc.setup != nil { + tc.setup(t, store) + } + got, err := store.load() + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("load: %v", err) + } + if tc.check != nil { + tc.check(t, got) + } + }) + } +} + +func TestStateStore_SaveCreatesMissingConfigDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "config") + if err := newStateStore(dir).save(serveState{}); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, stateFileName)); err != nil { + t.Fatalf("expected state file to exist after save: %v", err) + } +} + +func TestStateStore_SaveWritesFileWith0600PermissionsUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission semantics") + } + dir := t.TempDir() + if err := newStateStore(dir).save(serveState{}); err != nil { + t.Fatalf("save: %v", err) + } + info, err := os.Stat(filepath.Join(dir, stateFileName)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0600 { + t.Errorf("perms = %v, want %v", mode, os.FileMode(0600)) + } +} + +func TestStateHandlers(t *testing.T) { + jsonGetHeaders := map[string]string{ + "Content-Type": "application/json", + "Cache-Control": "no-store", + } + + tests := []struct { + name string + method string + body string + presetState *serveState + wantStatus int + wantHeaders map[string]string + checkBody func(t *testing.T, body []byte) + checkStore func(t *testing.T, store *stateStore) + }{ + { + name: "GET returns empty state by default", + method: http.MethodGet, + wantStatus: http.StatusOK, + wantHeaders: jsonGetHeaders, + checkBody: func(t *testing.T, body []byte) { + got := decodeServeState(t, body) + if got != (serveState{}) { + t.Errorf("body = %+v, want empty state", got) + } + }, + }, + { + name: "GET returns previously saved state", + method: http.MethodGet, + presetState: &serveState{SelectedDatabaseID: new("db-9")}, + wantStatus: http.StatusOK, + wantHeaders: jsonGetHeaders, + checkBody: func(t *testing.T, body []byte) { + got := decodeServeState(t, body) + assertStringPtr(t, "SelectedDatabaseID", got.SelectedDatabaseID, "db-9") + }, + }, + { + name: "PUT persists body to store", + method: http.MethodPut, + body: `{"selectedDatabaseId":"db-7","editorHeight":300}`, + wantStatus: http.StatusNoContent, + checkStore: func(t *testing.T, store *stateStore) { + got, err := store.load() + if err != nil { + t.Fatalf("load: %v", err) + } + assertStringPtr(t, "SelectedDatabaseID", got.SelectedDatabaseID, "db-7") + assertIntPtr(t, "EditorHeight", got.EditorHeight, 300) + if got.EditorSQL != nil { + t.Errorf("EditorSQL = %v, want nil (not sent in body)", *got.EditorSQL) + } + }, + }, + { + name: "PUT rejects invalid JSON with 400", + method: http.MethodPut, + body: "{bad", + wantStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := &Server{state: newStateStore(t.TempDir())} + if tc.presetState != nil { + if err := srv.state.save(*tc.presetState); err != nil { + t.Fatalf("preset save: %v", err) + } + } + + var body io.Reader + if tc.body != "" { + body = bytes.NewReader([]byte(tc.body)) + } + req := httptest.NewRequest(tc.method, "/api/state", body) + rr := httptest.NewRecorder() + switch tc.method { + case http.MethodGet: + srv.handleGetState(rr, req) + case http.MethodPut: + srv.handlePutState(rr, req) + default: + t.Fatalf("unsupported method %q", tc.method) + } + + if rr.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d\nbody: %s", rr.Code, tc.wantStatus, rr.Body.String()) + } + for header, want := range tc.wantHeaders { + if got := rr.Header().Get(header); got != want { + t.Errorf("header %s = %q, want %q", header, got, want) + } + } + if tc.checkBody != nil { + tc.checkBody(t, rr.Body.Bytes()) + } + if tc.checkStore != nil { + tc.checkStore(t, srv.state) + } + }) + } +} + +func writeStateFile(t *testing.T, store *stateStore, contents string) { + t.Helper() + if err := os.WriteFile(store.path, []byte(contents), 0600); err != nil { + t.Fatalf("write state file: %v", err) + } +} + +func decodeServeState(t *testing.T, body []byte) serveState { + t.Helper() + var got serveState + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode body: %v\nbody: %s", err, body) + } + return got +} + +func assertStringPtr(t *testing.T, name string, got *string, want string) { + t.Helper() + if got == nil { + t.Errorf("%s = nil, want %q", name, want) + return + } + if *got != want { + t.Errorf("%s = %q, want %q", name, *got, want) + } +} + +func assertIntPtr(t *testing.T, name string, got *int, want int) { + t.Helper() + if got == nil { + t.Errorf("%s = nil, want %d", name, want) + return + } + if *got != want { + t.Errorf("%s = %d, want %d", name, *got, want) + } +} From 53dc84e38dabeb99d83fa9dc2147ca8148de01f9 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 4 Jun 2026 16:43:15 -0400 Subject: [PATCH 35/39] respect read_only config in ghost serve query execution --- internal/serve/connect.go | 7 +++++-- internal/serve/execute.go | 2 +- internal/serve/session.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/serve/connect.go b/internal/serve/connect.go index 55797ce..f2a37f5 100644 --- a/internal/serve/connect.go +++ b/internal/serve/connect.go @@ -54,8 +54,10 @@ func fetchDatabase(ctx context.Context, client api.ClientWithResponsesInterface, const defaultRole = "tsdbadmin" // openDriverForService resolves a ghost-api database, retrieves the password -// for the default role, and opens a Postgres driver against it. -func openDriverForService(ctx context.Context, client api.ClientWithResponsesInterface, projectID, serviceID string) (dbdriver.Driver, error) { +// for the default role, and opens a Postgres driver against it. When readOnly +// is true, the connection is opened with the tsdb_admin.read_only_connection +// GUC set, matching the behavior of `ghost sql` under the read_only config. +func openDriverForService(ctx context.Context, client api.ClientWithResponsesInterface, projectID, serviceID string, readOnly bool) (dbdriver.Driver, error) { database, err := fetchDatabase(ctx, client, projectID, serviceID) if err != nil { return nil, err @@ -76,6 +78,7 @@ func openDriverForService(ctx context.Context, client api.ClientWithResponsesInt Database: database, Role: defaultRole, Password: password, + ReadOnly: readOnly, }) if err != nil { return nil, newConnectErr("building connection string: %v", err) diff --git a/internal/serve/execute.go b/internal/serve/execute.go index f141680..a75a917 100644 --- a/internal/serve/execute.go +++ b/internal/serve/execute.go @@ -29,7 +29,7 @@ func (s *Server) handleExecuteQuery(w http.ResponseWriter, r *http.Request) { return } - driver, connErr := openDriverForService(r.Context(), client, req.ProjectID, req.ServiceID) + driver, connErr := openDriverForService(r.Context(), client, req.ProjectID, req.ServiceID, s.cfg.App.GetConfig().ReadOnly) if connErr != nil { ce := new(connectErr) if errors.As(connErr, &ce) { diff --git a/internal/serve/session.go b/internal/serve/session.go index c3be5fa..2b58e54 100644 --- a/internal/serve/session.go +++ b/internal/serve/session.go @@ -44,7 +44,7 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { return } - driver, err := openDriverForService(r.Context(), client, req.ProjectID, req.ServiceID) + driver, err := openDriverForService(r.Context(), client, req.ProjectID, req.ServiceID, s.cfg.App.GetConfig().ReadOnly) if err != nil { ce := new(connectErr) if errors.As(err, &ce) { From 046b3d7642a8ac95cc94e00c62060fd0988bde66 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 4 Jun 2026 16:43:16 -0400 Subject: [PATCH 36/39] guard arrowResults wait on request context to avoid hang --- internal/serve/arrow_results.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/serve/arrow_results.go b/internal/serve/arrow_results.go index b60e5d2..eedf68b 100644 --- a/internal/serve/arrow_results.go +++ b/internal/serve/arrow_results.go @@ -30,7 +30,15 @@ func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - <-run.ready + // Wait for runQuery to finish buffering. Guard on the request context so a + // stray/duplicate arrowResults POST for a run that errored before ready was + // closed (e.g. the bufErr path in runQuery) doesn't block this handler + // indefinitely. + select { + case <-run.ready: + case <-r.Context().Done(): + return + } rb, err := NewRecordBuilder(run.columns) if err != nil { From 80f2edcc6fe82f1d4e47ef00b9bc638daf5844bb Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 4 Jun 2026 18:07:05 -0400 Subject: [PATCH 37/39] Restore upstream streaming design in ghost serve The initial port of the query backend into `ghost serve` introduced several regressions vs. the original upstream code. This restores the original design where it was changed, and trims only what isn't needed for Postgres-only support. Streaming / memory (the important ones): - Stream rows over a backpressured channel instead of buffering the entire result set in memory. A dedicated query goroutine (streamQuery) scans rows and hands them to handleArrowResults, which writes them straight to the Arrow IPC stream. Memory stays flat for large results and time-to-first-byte is fast again. Multi-statement runs execute prior statements fire-and-forget on the same connection, then stream the final statement (matching upstream). - Restore adaptive Arrow record-batch sizing (initial 100 rows, 5 MiB target, min 5 / max 10000, 2x growth cap) instead of a static 1024-row batch. - Revert the Postgres connection to the extended query protocol (drop the simple-protocol override, which had downsides and wasn't actually needed). - Return structured JSON errors from the widget-facing handlers so the widget's checkApiError can surface the message (plain text was discarded). - Guard arrowResults against concurrent consumers (single-reader, like the original pipe). Cleanups / parity: - Log previously-swallowed Close()/rows errors via a slog logger (like ghost mcp), rather than dropping them. - Remove Postgres-irrelevant adapter code: ColumnCase (Snowflake), Metadata (BigQuery), the QueryArgs struct (Query now takes a string), the unused TotalRowCount method, and the dead SQL() join helper. - Remove the unnecessary RuntimeParams nil-check. - Correct the cancel.go and ErrMultiStatement comments, and drop references to private internal repos from the source comments. - Clarify the timestamptz offset layout: "-07" is Go's signed-offset token (the "-" is the sign placeholder), not a double sign. The code is correct. Tests: rewrite execute_test.go to exercise the streaming path end-to-end with a fake driver (single + multi-statement, concurrent-consumer guard) and add arrow_results_test.go for the adaptive batch sizing. Passes go test -race. --- internal/cmd/serve.go | 7 +- internal/serve/arrow.go | 15 +- internal/serve/arrow_results.go | 108 +++++++++-- internal/serve/arrow_results_test.go | 71 +++++++ internal/serve/cancel.go | 2 +- internal/serve/dbdriver/api.go | 40 ++-- internal/serve/dbdriver/cancel.go | 9 +- internal/serve/dbdriver/driver.go | 17 +- internal/serve/dbdriver/postgres.go | 18 +- internal/serve/dbdriver/rows.go | 34 +--- internal/serve/dbtypes/date.go | 14 +- internal/serve/dbtypes/types.go | 6 +- internal/serve/execute.go | 231 +++++++++++++---------- internal/serve/execute_test.go | 268 ++++++++++++++++++++++----- internal/serve/httperr.go | 31 ++++ internal/serve/server.go | 13 ++ internal/serve/session.go | 7 +- internal/serve/store.go | 63 ++++--- internal/serve/wire.go | 27 +-- internal/serve/wire_test.go | 56 ++---- 20 files changed, 684 insertions(+), 353 deletions(-) create mode 100644 internal/serve/arrow_results_test.go create mode 100644 internal/serve/httperr.go diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 102fb76..409d3a3 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -35,9 +35,10 @@ of this command — press Ctrl+C to stop it.`, } srv, err := serve.New(serve.Config{ - Host: host, - Port: port, - App: app, + Host: host, + Port: port, + App: app, + Logger: newLogger(cmd), }) if err != nil { return err diff --git a/internal/serve/arrow.go b/internal/serve/arrow.go index 7b5c9e2..316fe27 100644 --- a/internal/serve/arrow.go +++ b/internal/serve/arrow.go @@ -14,9 +14,10 @@ import ( "github.com/timescale/ghost/internal/serve/dbtypes" ) -// Ported from github.com/timescale/popsql-query/internal/writer/arrow.go. -// Schema metadata + the synthetic __popsql_row_num__ column are preserved -// because the widget's table renderer depends on both. +// Arrow IPC encoding for query result sets. The schema metadata and the +// synthetic __popsql_row_num__ column are required by the widget's table +// renderer, which expects the same Arrow wire contract as the hosted query +// service. const columnsMetadataKey = "__popsql_columns__" @@ -86,8 +87,9 @@ func (rb *RecordBuilder) AppendRow(row []any) error { return nil } +// RecordRowCount returns the number of rows accumulated in the in-progress +// record batch (reset to 0 by NewRecordBatch). func (rb *RecordBuilder) RecordRowCount() int64 { return rb.recordRowCount } -func (rb *RecordBuilder) TotalRowCount() int64 { return rb.totalRowCount } // NewRecordBatch finalizes the in-progress record and resets the row counter. func (rb *RecordBuilder) NewRecordBatch() arrow.RecordBatch { @@ -309,8 +311,9 @@ func unknownBuilderFn(builder array.Builder, value any) error { } // shouldMarshalJSON returns true for compound types (arrays, slices, maps, -// structs) that aren't sql.Scanner-compliant. Most non-Postgres drivers don't -// hit this in our use case, but it's preserved for parity with popsql-query. +// structs) that aren't sql.Scanner-compliant. Postgres rarely hits this path, +// but it's kept as a safe fallback for any driver value that can't be scanned +// directly into one of the primitive arrow builders. func shouldMarshalJSON(t reflect.Type) bool { switch t.Kind() { case reflect.Pointer: diff --git a/internal/serve/arrow_results.go b/internal/serve/arrow_results.go index eedf68b..0d5882c 100644 --- a/internal/serve/arrow_results.go +++ b/internal/serve/arrow_results.go @@ -4,24 +4,23 @@ import ( "encoding/json" "net/http" + "github.com/apache/arrow-go/v18/arrow" "github.com/apache/arrow-go/v18/arrow/ipc" "github.com/timescale/ghost/internal/serve/dbdriver" ) -const arrowBatchRows = 1024 - // handleArrowResults serves POST /api/arrowResults. The widget fires this // immediately after seeing the executeQuery columns line and expects a raw -// Apache Arrow IPC stream of rows. Rows have already been scanned into -// run.bufferedRows by executeQuery (so we can pick the right result set out -// of a multi-statement run); we just convert them to Arrow record batches -// and stream them out. When we're done we signal Run.done so the -// executeQuery handler can emit its terminator. +// Apache Arrow IPC stream of rows. The query goroutine (streamQuery) streams +// scanned rows over run.rows; we convert them to Arrow record batches and +// write them straight to the response. Backpressure on run.rows keeps memory +// bounded and ensures a fast time-to-first-byte for large result sets. When +// we're done we signal run.done so executeQuery can emit its terminator. func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { var req arrowResultsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } @@ -30,10 +29,18 @@ func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - // Wait for runQuery to finish buffering. Guard on the request context so a - // stray/duplicate arrowResults POST for a run that errored before ready was - // closed (e.g. the bufErr path in runQuery) doesn't block this handler - // indefinitely. + + // Only one caller may drain run.rows. Reject concurrent/duplicate fetches + // (mirrors the upstream single-reader pipe design). Without this guard a second + // caller would silently receive a truncated stream. + if !run.arrowStarted.CompareAndSwap(false, true) { + writeJSONError(w, http.StatusConflict, "arrow results are already being streamed for this run") + return + } + + // Wait for streamQuery to publish columns. Guard on the request context so + // a stray request for a run that errored before columns were produced + // doesn't block this handler indefinitely. select { case <-run.ready: case <-r.Context().Done(): @@ -42,8 +49,9 @@ func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { rb, err := NewRecordBuilder(run.columns) if err != nil { - http.Error(w, "arrow schema: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "arrow schema: "+err.Error()) run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + run.cancelQuery() run.closeDone() return } @@ -56,35 +64,95 @@ func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) { defer ipcWriter.Close() defer run.closeDone() - for _, row := range run.bufferedRows { + // batchRows is the target row count for the next record batch. It starts + // small (fast first byte) and is recomputed after each flush to track a + // target byte size, matching the upstream adaptive batching design. + batchRows := int64(initialRecordRowCount) + for row := range run.rows { if err := r.Context().Err(); err != nil { run.setError(&dbdriver.NormalizedError{Message: "request canceled", Source: "ghost", Cancel: true}) + run.cancelQuery() return } if err := rb.AppendRow(row); err != nil { run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + run.cancelQuery() return } - if rb.RecordRowCount() >= arrowBatchRows { - if err := flushBatch(ipcWriter, rb, w); err != nil { + if rb.RecordRowCount() >= batchRows { + newTarget, err := flushBatch(ipcWriter, rb, w, batchRows) + if err != nil { run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + run.cancelQuery() return } + batchRows = newTarget } } if rb.RecordRowCount() > 0 { - if err := flushBatch(ipcWriter, rb, w); err != nil && run.err == nil { + if _, err := flushBatch(ipcWriter, rb, w, batchRows); err != nil && run.err == nil { run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) } } } -func flushBatch(ipcWriter *ipc.Writer, rb *RecordBuilder, w http.ResponseWriter) error { +// flushBatch finalizes the in-progress record batch, writes it to the IPC +// stream, flushes it to the client, and returns the target row count for the +// next batch (recomputed from the batch just written). +func flushBatch(ipcWriter *ipc.Writer, rb *RecordBuilder, w http.ResponseWriter, oldRowCount int64) (int64, error) { batch := rb.NewRecordBatch() defer batch.Release() + newRowCount := newRecordRowCount(batch, oldRowCount) if err := ipcWriter.Write(batch); err != nil { - return err + return oldRowCount, err } flushWriter(w) - return nil + return newRowCount, nil +} + +const ( + // initialRecordRowCount is the number of rows in the first record batch. + // Kept small so the user sees the first rows quickly. + initialRecordRowCount = 100 + + // maxRecordRowCount caps the number of rows in any record batch. + maxRecordRowCount = 10000 + + // minRecordRowCount is the floor for the number of rows in a record batch. + minRecordRowCount = 5 + + // targetRecordBytes is the target serialized size of a record batch. Any + // given batch can overshoot or undershoot; the next batch's row count is + // adjusted to home in on this target. + targetRecordBytes = 5 * 1024 * 1024 // 5 MiB +) + +// newRecordRowCount computes the ideal number of rows for the next record +// batch from the average bytes-per-row of the last batch, clamped to sane +// bounds. This adaptive sizing keeps memory spikes small and +// time-to-first-byte fast regardless of row width. +func newRecordRowCount(batch arrow.RecordBatch, oldRowCount int64) int64 { + recordBytes := recordSizeBytes(batch) + if recordBytes == 0 || oldRowCount == 0 { + return oldRowCount + } + bytesPerRow := recordBytes / uint64(oldRowCount) + if bytesPerRow == 0 { + bytesPerRow = 1 + } + newRowCount := int64(targetRecordBytes / bytesPerRow) + + // Clamp between the min and max, and limit sudden growth to 2x the + // previous count (in case the last batch was not a representative sample). + newRowCount = min(newRowCount, oldRowCount*2, maxRecordRowCount) + newRowCount = max(newRowCount, minRecordRowCount) + return newRowCount +} + +func recordSizeBytes(batch arrow.RecordBatch) uint64 { + var size uint64 + for _, col := range batch.Columns() { + size += col.Data().SizeInBytes() + } + return size } diff --git a/internal/serve/arrow_results_test.go b/internal/serve/arrow_results_test.go new file mode 100644 index 0000000..1b952b7 --- /dev/null +++ b/internal/serve/arrow_results_test.go @@ -0,0 +1,71 @@ +package serve + +import ( + "testing" + + "github.com/timescale/ghost/internal/serve/dbdriver" + "github.com/timescale/ghost/internal/serve/dbtypes" +) + +// buildBatch appends n single-column string rows and returns the finalized +// record batch so its serialized size can be measured. +func buildBatch(t *testing.T, n int, value string) (int64, func()) { + t.Helper() + cols := dbdriver.Columns{{Name: "n", ScanType: dbtypes.StringType}} + rb, err := NewRecordBuilder(cols) + if err != nil { + t.Fatalf("NewRecordBuilder: %v", err) + } + for i := 0; i < n; i++ { + if err := rb.AppendRow([]any{value}); err != nil { + t.Fatalf("AppendRow: %v", err) + } + } + batch := rb.NewRecordBatch() + newCount := newRecordRowCount(batch, int64(n)) + batch.Release() + return newCount, rb.Release +} + +func TestNewRecordRowCount(t *testing.T) { + t.Run("small narrow rows grow toward the max", func(t *testing.T) { + // 100 tiny rows are far under the 5 MiB target, so the next batch + // should grow, but never more than 2x the previous count. + got, release := buildBatch(t, 100, "x") + defer release() + if got > 200 { + t.Errorf("row count = %d, want <= 200 (2x growth cap)", got) + } + if got < 100 { + t.Errorf("row count = %d, want >= 100 (narrow rows should not shrink)", got) + } + }) + + t.Run("never drops below the floor", func(t *testing.T) { + // A single huge row pushes bytes-per-row way over target; the next + // count is clamped to the minimum rather than going to zero. + huge := make([]byte, targetRecordBytes*2) + for i := range huge { + huge[i] = 'a' + } + got, release := buildBatch(t, 1, string(huge)) + defer release() + if got != minRecordRowCount { + t.Errorf("row count = %d, want %d (min floor)", got, minRecordRowCount) + } + }) + + t.Run("zero previous count is a no-op", func(t *testing.T) { + cols := dbdriver.Columns{{Name: "n", ScanType: dbtypes.StringType}} + rb, err := NewRecordBuilder(cols) + if err != nil { + t.Fatalf("NewRecordBuilder: %v", err) + } + defer rb.Release() + batch := rb.NewRecordBatch() + defer batch.Release() + if got := newRecordRowCount(batch, 0); got != 0 { + t.Errorf("row count = %d, want 0", got) + } + }) +} diff --git a/internal/serve/cancel.go b/internal/serve/cancel.go index e6461d0..39a068e 100644 --- a/internal/serve/cancel.go +++ b/internal/serve/cancel.go @@ -14,7 +14,7 @@ import ( func (s *Server) handleCancelRun(w http.ResponseWriter, r *http.Request) { var req cancelQueryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } run := s.runs.get(req.RunID) diff --git a/internal/serve/dbdriver/api.go b/internal/serve/dbdriver/api.go index 659037e..77b2293 100644 --- a/internal/serve/dbdriver/api.go +++ b/internal/serve/dbdriver/api.go @@ -1,11 +1,10 @@ // Package dbdriver wraps database/sql + pgx to give us OID-aware column // scan-type inference, server-side query cancellation, and a Postgres error -// normalizer suitable for projecting into the wire format the -// popsql-query-widget expects. +// normalizer suitable for projecting into the wire format the query widget +// expects. // -// This is a trimmed-down port of github.com/timescale/popsql-query's -// internal/driver package — Postgres only, no SSH tunneling, no multi-driver -// adapter registry, and no logging side-effects. +// It is Postgres-only: no SSH tunneling, no multi-driver adapter registry, and +// no logging side-effects (callers log Close errors etc.). package dbdriver import ( @@ -13,19 +12,8 @@ import ( "reflect" ) -// ColumnCase controls how column names are presented to the widget. Today we -// always emit them as-is. -type ColumnCase string - -const ( - ColumnCaseDefault ColumnCase = "" - ColumnCaseLower ColumnCase = "lower" - ColumnCaseUpper ColumnCase = "upper" -) - // Column carries column metadata to the widget. JSON shape matches the -// "Column" type defined by @popsql/types and consumed by -// popsql-query-widget's TimescaleQueryClient. +// "Column" type the query widget's client consumes. type Column struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -37,14 +25,8 @@ type Column struct { ScanType reflect.Type `json:"-"` } -// Metadata is reserved for future use; popsql-query's Metadata is only -// populated by BigQuery's bytes-processed counter. -type Metadata struct { - BytesProcessed int64 `json:"bytesProcessed"` -} - // NormalizedError is the canonical error shape consumed by the widget. The -// JSON shape mirrors @popsql/types' ApiFailedResult error. +// JSON shape mirrors the widget client's ApiFailedResult error. type NormalizedError struct { Code string `json:"code,omitempty"` Column int32 `json:"column,omitempty"` @@ -64,9 +46,9 @@ type NormalizedError struct { func (e *NormalizedError) Error() string { return e.Message } -// ErrMultiStatement is returned when the user attempts to run multiple -// statements in a single query. Multi-statement support requires either an -// extended-protocol round trip per statement or pgx's "simple protocol" mode, -// which interpolates parameters client-side. For now we follow popsql-query's -// posture and reject the case. +// ErrMultiStatement is returned when multiple statements are sent in a single +// prepared (extended-protocol) call, which Postgres rejects. Multi-statement +// editor text is handled by running the widget-supplied statements one at a +// time (see streamQuery), so this only fires if a single statement itself +// contains multiple commands. var ErrMultiStatement = errors.New("cannot run multiple statements in a single query") diff --git a/internal/serve/dbdriver/cancel.go b/internal/serve/dbdriver/cancel.go index 6818d01..559a715 100644 --- a/internal/serve/dbdriver/cancel.go +++ b/internal/serve/dbdriver/cancel.go @@ -15,9 +15,12 @@ type canceler func(ctx context.Context) error // into the returned context as a fallback. The returned CancelFunc must be // called when the query is finished to release the watcher goroutine. // -// The reason for the not-a-child context is so that pgx does not abort the -// query mid-flight on its own — we want a graceful cancel through Postgres, -// so we can still surface a useful error to the client. +// The query context is deliberately not a child of parent because pgx reacts +// to context cancellation by closing the underlying database connection, which +// tears down the session (TEMP tables, SET state, in-progress transactions). +// By intercepting the cancellation ourselves and issuing a normal +// pg_cancel_backend() over a side channel instead, we cancel just the running +// query while keeping the connection alive for subsequent queries. func cancelContext(parent context.Context, fn canceler) (context.Context, context.CancelFunc) { newCtx, cancel := context.WithCancelCause(context.Background()) diff --git a/internal/serve/dbdriver/driver.go b/internal/serve/dbdriver/driver.go index 8e2bbd7..502d7c4 100644 --- a/internal/serve/dbdriver/driver.go +++ b/internal/serve/dbdriver/driver.go @@ -27,7 +27,7 @@ type Driver interface { // Query issues a SQL statement and returns Rows. The context returned by // Context must be the one passed in. - Query(ctx context.Context, args QueryArgs) (Rows, error) + Query(ctx context.Context, query string) (Rows, error) // NormalizeError adapts a database/sql or driver-specific error to the // wire NormalizedError shape expected by the widget. @@ -36,12 +36,6 @@ type Driver interface { Close() error } -// QueryArgs is the input to Driver.Query. -type QueryArgs struct { - Query string - ColumnCase ColumnCase -} - // baseDriver is the standard implementation, shared by every concrete driver. // Driver-specific behavior (e.g. Postgres OID overrides, cancellation via // pgconn.CancelRequest) is layered on top by embedding this type. @@ -63,18 +57,17 @@ func (b *baseDriver) Context(ctx context.Context) (context.Context, context.Canc return ctx, func() {} } -func (b *baseDriver) Query(ctx context.Context, args QueryArgs) (Rows, error) { - return b.query(ctx, args, b.scanType) +func (b *baseDriver) Query(ctx context.Context, query string) (Rows, error) { + return b.query(ctx, query, b.scanType) } -func (b *baseDriver) query(ctx context.Context, args QueryArgs, scanTypeFn scanTypeFn) (*baseRows, error) { - rows, err := b.conn.QueryContext(ctx, args.Query) +func (b *baseDriver) query(ctx context.Context, query string, scanTypeFn scanTypeFn) (*baseRows, error) { + rows, err := b.conn.QueryContext(ctx, query) if err != nil { return nil, err } return &baseRows{ Rows: rows, - columnCase: args.ColumnCase, scanTypeFn: scanTypeFn, }, nil } diff --git a/internal/serve/dbdriver/postgres.go b/internal/serve/dbdriver/postgres.go index 0ecc91f..77a3ceb 100644 --- a/internal/serve/dbdriver/postgres.go +++ b/internal/serve/dbdriver/postgres.go @@ -32,14 +32,12 @@ func OpenPostgresDSN(ctx context.Context, dsn string) (Driver, error) { } func openPostgresConfig(ctx context.Context, pgxCfg *pgx.ConnConfig) (d Driver, err error) { - // SimpleProtocol lets users send arbitrary text (multi-statement SQL, - // comments, trailing semicolons) the same way `ghost sql` does. The - // alternative extended-protocol modes need a single, parseable - // statement per call, which is a poor fit for an interactive editor. - pgxCfg.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol - if pgxCfg.RuntimeParams == nil { - pgxCfg.RuntimeParams = map[string]string{} - } + // Use pgx's default exec mode (extended protocol with a prepared-statement + // cache). The widget splits multi-statement editor text into individual + // statements for us, which we run one at a time, so we don't need the + // simple protocol's multi-command support — and the extended protocol + // avoids the simple protocol's downsides (client-side parameter + // interpolation, no prepared-statement caching, weaker type handling). pgxCfg.RuntimeParams["application_name"] = ApplicationName tracer := &postgresQueryTracer{} @@ -101,8 +99,8 @@ func (d *postgresDriver) Context(ctx context.Context) (context.Context, context. }) } -func (d *postgresDriver) Query(ctx context.Context, args QueryArgs) (Rows, error) { - baseRows, err := d.query(ctx, args, d.scanType) +func (d *postgresDriver) Query(ctx context.Context, query string) (Rows, error) { + baseRows, err := d.query(ctx, query, d.scanType) if err != nil { return nil, err } diff --git a/internal/serve/dbdriver/rows.go b/internal/serve/dbdriver/rows.go index 74d262a..38e9137 100644 --- a/internal/serve/dbdriver/rows.go +++ b/internal/serve/dbdriver/rows.go @@ -6,7 +6,6 @@ import ( "fmt" "reflect" "strings" - "unicode" "github.com/timescale/ghost/internal/serve/dbtypes" ) @@ -20,7 +19,6 @@ type Rows interface { Close() error Columns() (Columns, error) - Metadata(ctx context.Context) (*Metadata, error) RowsAffected(ctx context.Context) (*int64, error) } @@ -62,7 +60,6 @@ type scanTypeFn func(columnType *sql.ColumnType) reflect.Type type baseRows struct { *sql.Rows - columnCase ColumnCase scanTypeFn scanTypeFn } @@ -93,7 +90,7 @@ func (r *baseRows) Columns() (Columns, error) { func (r *baseRows) buildColumn(deduper deduper, ct *sql.ColumnType) Column { scanType := r.scanTypeFn(ct) column := Column{ - Name: deduper.dedupe(ct, r.columnCase), + Name: deduper.dedupe(ct), Type: ct.DatabaseTypeName(), Object: scanType == dbtypes.JSONPtrType, Numeric: scanType == dbtypes.NumericPtrType, @@ -109,7 +106,6 @@ func (r *baseRows) buildColumn(deduper deduper, ct *sql.ColumnType) Column { return column } -func (r *baseRows) Metadata(ctx context.Context) (*Metadata, error) { return nil, nil } func (r *baseRows) RowsAffected(ctx context.Context) (*int64, error) { return nil, nil } type deduper map[string]int @@ -124,36 +120,16 @@ func newDeduper(columnTypes []*sql.ColumnType) deduper { func (d deduper) columnKey(name string) string { return strings.ToLower(name) } -func (d deduper) columnName(ct *sql.ColumnType, columnCase ColumnCase) string { +func (d deduper) columnName(ct *sql.ColumnType) string { name := ct.Name() if name == "" { name = "column" } - switch columnCase { - case ColumnCaseDefault: - return name - case ColumnCaseLower: - if d.mixedCase(name) { - return name - } - return strings.ToLower(name) - case ColumnCaseUpper: - if d.mixedCase(name) { - return name - } - return strings.ToUpper(name) - default: - panic(fmt.Errorf("invalid column case: %s", columnCase)) - } -} - -func (d deduper) mixedCase(name string) bool { - return strings.ContainsFunc(name, unicode.IsLower) && - strings.ContainsFunc(name, unicode.IsUpper) + return name } -func (d deduper) dedupe(ct *sql.ColumnType, columnCase ColumnCase) string { - name := d.columnName(ct, columnCase) +func (d deduper) dedupe(ct *sql.ColumnType) string { + name := d.columnName(ct) key := d.columnKey(name) count := d[key] diff --git a/internal/serve/dbtypes/date.go b/internal/serve/dbtypes/date.go index b10c6c1..ae8b816 100644 --- a/internal/serve/dbtypes/date.go +++ b/internal/serve/dbtypes/date.go @@ -85,10 +85,16 @@ func (t *Timestamp) Scan(src any) error { return nil } -// getTimeZoneOffsetLayout returns the timezone-offset portion of the -// timestamp layout. If the offset has a non-zero minutes portion, both hours -// and minutes are included (matching Postgres default behavior); otherwise -// just the hours portion is included. +// getTimeZoneOffsetLayout returns the hour(:minute) portion of Go's numeric +// timezone-offset layout token. If the offset has a non-zero minutes portion, +// both hours and minutes are included (matching Postgres default behavior); +// otherwise just the hours portion is included. +// +// The callers prepend "-" to form the full token (e.g. "-07" or "-07:00"). +// That leading "-" is NOT a literal dash: in Go's reference-time layout it is +// the sign placeholder, which time.Format substitutes with the actual sign of +// the offset. So "-07" renders as "+02" or "-05" as appropriate -- the values +// are correct, not double-signed. func getTimeZoneOffsetLayout(t time.Time) string { _, offset := t.Zone() minutes := (offset % 3600) / 60 diff --git a/internal/serve/dbtypes/types.go b/internal/serve/dbtypes/types.go index 9ba4c6c..c06d56b 100644 --- a/internal/serve/dbtypes/types.go +++ b/internal/serve/dbtypes/types.go @@ -2,9 +2,9 @@ // precision and special values (NaN, +/-Infinity, untyped JSON, hex-encoded // bytea, plain DATE/TIMESTAMP strings) when reading rows out of database/sql. // -// Ported from github.com/timescale/popsql-query/internal/types so the Apache -// Arrow encoding in this package matches the wire contract the -// popsql-query-widget expects from the savannah gateway. +// These types match the wire contract the query widget expects, so the Apache +// Arrow encoding preserves database-side precision and special values when +// rows are streamed to the browser. package dbtypes import ( diff --git a/internal/serve/execute.go b/internal/serve/execute.go index a75a917..aa46191 100644 --- a/internal/serve/execute.go +++ b/internal/serve/execute.go @@ -17,7 +17,7 @@ import ( func (s *Server) handleExecuteQuery(w http.ResponseWriter, r *http.Request) { var req executeQueryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } client, projectID, err := s.loadClient(r.Context()) @@ -39,7 +39,11 @@ func (s *Server) handleExecuteQuery(w http.ResponseWriter, r *http.Request) { } return } - defer driver.Close() + defer func() { + if err := driver.Close(); err != nil { + s.logger.Warn("error closing database connection", "err", err) + } + }() s.runQuery(w, r, req, driver) } @@ -50,7 +54,7 @@ func (s *Server) handleExecuteQuery(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExecuteSessionQuery(w http.ResponseWriter, r *http.Request) { var req executeSessionQueryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } _, projectID, err := s.loadClient(r.Context()) @@ -73,22 +77,19 @@ func (s *Server) handleExecuteSessionQuery(w http.ResponseWriter, r *http.Reques s.runQuery(w, r, req.executeQueryRequest, session.driver) } -// bufferedResultSet is one result set materialized in memory: its column -// descriptors plus every scanned row in order. -type bufferedResultSet struct { - columns dbdriver.Columns - rows [][]any - rowsAffected *int64 -} - // runQuery is the shared body of handleExecuteQuery / handleExecuteSessionQuery. // -// Multi-statement behavior: the widget's worker splits the editor text into a -// statements array which we join with `; ` and run via pgx's simple text -// protocol. PG returns one result set per statement. We buffer all of them -// and surface the last result set that has columns; if none have columns -// (e.g. only DDL/DML), we surface the last result set so its rowsAffected -// still shows. +// The query executes in a dedicated goroutine (streamQuery) that streams rows +// over run.rows. This handler writes the columns NDJSON line as soon as the +// columns are known, which prompts the widget to POST /api/arrowResults; that +// handler drains run.rows into an Arrow IPC stream with backpressure. Rows are +// never collected in full — they flow from the database straight to the wire. +// +// Multi-statement behavior mirrors the upstream query service: the widget's worker splits +// the editor text into a statements array; we run every statement except the +// last against the same connection (so TEMP tables and other session state +// persist), discarding their results, then stream the final statement's result +// set. This is the result the widget displays. func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, req executeQueryRequest, driver dbdriver.Driver) { driverCtx, driverCleanup := driver.Context(r.Context()) defer driverCleanup() @@ -96,54 +97,59 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, req executeQue queryCtx, cancelQuery := context.WithCancel(driverCtx) defer cancelQuery() + statements := req.Statements + if len(statements) == 0 && req.Query != "" { + statements = []string{req.Query} + } + run := &Run{ - id: req.RunID, - projectID: req.ProjectID, - serviceID: req.ServiceID, - startedAt: time.Now(), - cancelQuery: cancelQuery, - ready: make(chan struct{}), - done: make(chan struct{}), + id: req.RunID, + projectID: req.ProjectID, + serviceID: req.ServiceID, + startedAt: time.Now(), + rows: make(chan []any, rowChanBuffer), + executedStatements: int64(len(statements)), + cancelQuery: cancelQuery, + ready: make(chan struct{}), + done: make(chan struct{}), } s.runs.add(run) defer s.runs.delete(req.RunID) - statements := req.Statements - if len(statements) == 0 && req.Query != "" { - statements = []string{req.Query} - } + go s.streamQuery(queryCtx, run, driver, statements) - results, bufErr := bufferStatements(queryCtx, driver, statements) - if bufErr != nil { - writeErrorTerminator(w, req.RunID, driver.NormalizeError(queryCtx, bufErr)) + // Wait for the query goroutine to produce columns (or fail before it got + // that far), or for the client to disconnect. + select { + case <-run.ready: + case <-r.Context().Done(): + cancelQuery() return } - chosen := pickResultSetToSurface(results) - if chosen == nil { - // Should not happen: bufferStatements always emits at least one entry - // on success. Defensively surface an empty result. - chosen = &bufferedResultSet{} - } - - run.columns = chosen.columns - run.bufferedRows = chosen.rows - run.rowCount = int64(len(chosen.rows)) - run.rowsAffected = chosen.rowsAffected - run.executedStatements = int64(len(results)) - close(run.ready) w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-store") - enc := json.NewEncoder(w) - if err := enc.Encode(columnsResult{RunID: req.RunID, Columns: chosen.columns}); err != nil { + + // If the query failed before producing a result set, the widget never sees + // a columns line and so never fetches arrow results. Emit the error + // terminator directly. + if run.err != nil && len(run.columns) == 0 { + _ = enc.Encode(errorResult{RunID: req.RunID, Success: false, Error: run.err}) + flushWriter(w) + run.closeDone() + return + } + + if err := enc.Encode(columnsResult{RunID: req.RunID, Columns: run.columns}); err != nil { // Client disconnected before columns reached the wire. cancelQuery() return } flushWriter(w) - // Wait for arrowResults to finish, or for the client to disconnect. + // Wait for arrowResults to finish streaming, or for the client to + // disconnect. arrowResults closes done once it has drained run.rows. select { case <-run.done: case <-r.Context().Done(): @@ -170,73 +176,112 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, req executeQue flushWriter(w) } -// bufferStatements runs each statement in order against the same driver -// connection (so TEMP tables and other session state from earlier -// statements are visible to later ones) and buffers the rows from each -// result set into memory. +// streamQuery runs the run's statements against the driver connection and +// streams the final statement's rows over run.rows. It mirrors the upstream +// query session: run prior statements fire-and-forget, then stream the last. +// +// Iterating per-statement (rather than relying on sql.Rows.NextResultSet) is +// necessary because pgx's stdlib wrapper only surfaces the first result set of +// a multi-statement Query call. The widget already does the SQL-aware +// statement split for us, so we leverage that here. // -// Iterating per-statement (rather than relying on sql.Rows.NextResultSet) -// is necessary because pgx's stdlib wrapper only surfaces the first -// result set of a multi-statement Query call. The widget already does the -// SQL-aware statement split for us, so we leverage that here. -func bufferStatements(ctx context.Context, driver dbdriver.Driver, statements []string) ([]bufferedResultSet, error) { - out := make([]bufferedResultSet, 0, len(statements)) - for _, stmt := range statements { - rs, err := bufferOneStatement(ctx, driver, stmt) - if err != nil { - return out, err +// On any error it records a NormalizedError on the run; columns/rows produced +// so far are still streamed, and executeQuery emits the error terminator once +// arrowResults finishes. run.rows is always closed on return so arrowResults +// unblocks. +func (s *Server) streamQuery(ctx context.Context, run *Run, driver dbdriver.Driver, statements []string) { + var rowCount int64 + readyOnce := func() { + select { + case <-run.ready: + default: + close(run.ready) } - out = append(out, rs) } - return out, nil -} + defer readyOnce() + defer close(run.rows) + + fail := func(err error) { + run.rowCount = rowCount + run.setError(driver.NormalizeError(ctx, err)) + } + + // Bail early if the context was already canceled, to avoid racing the + // server-side cancel against the start of query execution. + if err := ctx.Err(); err != nil { + fail(err) + return + } -func bufferOneStatement(ctx context.Context, driver dbdriver.Driver, stmt string) (bufferedResultSet, error) { - rows, err := driver.Query(ctx, dbdriver.QueryArgs{Query: stmt}) + // Run every statement except the last fire-and-forget. + for i := 0; i+1 < len(statements); i++ { + if err := runStatement(ctx, driver, statements[i]); err != nil { + fail(err) + return + } + } + + final := "" + if len(statements) > 0 { + final = statements[len(statements)-1] + } + + rows, err := driver.Query(ctx, final) if err != nil { - return bufferedResultSet{}, err + fail(err) + return } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + s.logger.Debug("error closing rows", "err", err) + } + }() - cols, err := rows.Columns() + columns, err := rows.Columns() if err != nil { - return bufferedResultSet{}, err + fail(err) + return } + run.columns = columns + readyOnce() - buf := bufferedResultSet{columns: cols} - targets := cols.ScanTargets() + targets := columns.ScanTargets() for rows.Next() { - if err := ctx.Err(); err != nil { - return buf, err - } if err := rows.Scan(targets...); err != nil { - return buf, err + fail(err) + return + } + select { + case run.rows <- targets.Values(): + rowCount++ + case <-ctx.Done(): + fail(ctx.Err()) + return } - buf.rows = append(buf.rows, targets.Values()) } if err := rows.Err(); err != nil { - return buf, err + fail(err) + return } + if err := rows.Close(); err != nil { + fail(err) + return + } + + run.rowCount = rowCount if ra, _ := rows.RowsAffected(ctx); ra != nil { - buf.rowsAffected = ra + run.rowsAffected = ra } - return buf, nil } -// pickResultSetToSurface picks the result set we display to the widget, -// matching the rule the user asked for: prefer the last result set that -// returned columns; fall back to the last result set if none have columns; -// nil if the slice is empty. -func pickResultSetToSurface(results []bufferedResultSet) *bufferedResultSet { - if len(results) == 0 { - return nil - } - for i := len(results) - 1; i >= 0; i-- { - if len(results[i].columns) > 0 { - return &results[i] - } +// runStatement runs a single statement to completion and discards its result +// set. Used for every statement except the last in a multi-statement run. +func runStatement(ctx context.Context, driver dbdriver.Driver, stmt string) error { + rows, err := driver.Query(ctx, stmt) + if err != nil { + return err } - return &results[len(results)-1] + return rows.Close() } // checkProject rejects requests for a different project than the one the CLI diff --git a/internal/serve/execute_test.go b/internal/serve/execute_test.go index 4006661..7d642c5 100644 --- a/internal/serve/execute_test.go +++ b/internal/serve/execute_test.go @@ -1,60 +1,228 @@ package serve import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http/httptest" + "reflect" + "strings" "testing" + "time" + + "github.com/apache/arrow-go/v18/arrow/ipc" "github.com/timescale/ghost/internal/serve/dbdriver" + "github.com/timescale/ghost/internal/serve/dbtypes" ) -func TestPickResultSetToSurface(t *testing.T) { - one := dbdriver.Column{Name: "n"} - withCols := bufferedResultSet{columns: dbdriver.Columns{one}, rows: [][]any{{1}}} - emptyCols := bufferedResultSet{columns: nil} - otherCols := bufferedResultSet{columns: dbdriver.Columns{{Name: "m"}}, rows: [][]any{{2}}} - - tests := []struct { - name string - in []bufferedResultSet - want *bufferedResultSet - }{ - { - name: "empty input", - in: nil, - want: nil, - }, - { - name: "single result with columns", - in: []bufferedResultSet{withCols}, - want: &withCols, - }, - { - name: "last result with columns wins", - in: []bufferedResultSet{withCols, otherCols}, - want: &otherCols, - }, - { - name: "last column-bearing result wins even when later results are column-less", - in: []bufferedResultSet{withCols, emptyCols}, - want: &withCols, - }, - { - name: "no columns anywhere falls back to last result", - in: []bufferedResultSet{emptyCols, emptyCols, emptyCols}, - want: &emptyCols, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := pickResultSetToSurface(tc.in) - if (got == nil) != (tc.want == nil) { - t.Fatalf("got=%v want=%v", got, tc.want) - } - if got == nil { - return - } - if len(got.columns) != len(tc.want.columns) { - t.Errorf("columns len = %d, want %d", len(got.columns), len(tc.want.columns)) - } - }) +// fakeDriver is an in-memory dbdriver.Driver that returns a fixed result set +// for every Query. It records the statements it was asked to run so tests can +// assert multi-statement behavior. +type fakeDriver struct { + cols dbdriver.Columns + rows [][]any + queries []string +} + +func (d *fakeDriver) Ping(context.Context) error { return nil } +func (d *fakeDriver) PingInterval() time.Duration { return 0 } +func (d *fakeDriver) Close() error { return nil } +func (d *fakeDriver) Context(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithCancel(ctx) +} + +func (d *fakeDriver) Query(_ context.Context, query string) (dbdriver.Rows, error) { + d.queries = append(d.queries, query) + return &fakeRows{cols: d.cols, rows: d.rows, idx: -1}, nil +} + +func (d *fakeDriver) NormalizeError(_ context.Context, err error) *dbdriver.NormalizedError { + return &dbdriver.NormalizedError{Message: err.Error(), Source: "fake"} +} + +type fakeRows struct { + cols dbdriver.Columns + rows [][]any + idx int +} + +func (r *fakeRows) Next() bool { r.idx++; return r.idx < len(r.rows) } +func (r *fakeRows) Scan(dest ...any) error { + row := r.rows[r.idx] + for i := range dest { + reflect.ValueOf(dest[i]).Elem().Set(reflect.ValueOf(row[i])) + } + return nil +} +func (r *fakeRows) Err() error { return nil } +func (r *fakeRows) Close() error { return nil } +func (r *fakeRows) Columns() (dbdriver.Columns, error) { return r.cols, nil } +func (r *fakeRows) RowsAffected(context.Context) (*int64, error) { return nil, nil } + +func newTestServer() *Server { + return &Server{ + runs: newRunStore(), + sessions: newSessionStore(), + logger: slog.New(slog.DiscardHandler), + } +} + +// runStreaming drives runQuery + handleArrowResults concurrently the way the +// widget does (executeQuery first, then arrowResults once the run is +// registered) and returns the decoded NDJSON lines plus the streamed arrow row +// count. +func runStreaming(t *testing.T, s *Server, req executeQueryRequest, driver dbdriver.Driver) (ndjson []map[string]any, arrowRows int64) { + t.Helper() + + execW := httptest.NewRecorder() + execR := httptest.NewRequest("POST", "/api/executeQuery", nil) + done := make(chan struct{}) + go func() { + s.runQuery(execW, execR, req, driver) + close(done) + }() + + // Wait for the run to be registered and its columns to be ready. + var run *Run + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if run = s.runs.get(req.RunID); run != nil { + break + } + time.Sleep(time.Millisecond) + } + if run == nil { + t.Fatal("run was never registered") + } + + arrowW := httptest.NewRecorder() + arrowR := httptest.NewRequest("POST", "/api/arrowResults", strings.NewReader(`{"runId":"`+req.RunID+`"}`)) + s.handleArrowResults(arrowW, arrowR) + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runQuery did not return") + } + + for _, line := range strings.Split(strings.TrimSpace(execW.Body.String()), "\n") { + if line == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("decoding NDJSON line %q: %v", line, err) + } + ndjson = append(ndjson, m) + } + + if body := arrowW.Body.Bytes(); len(body) > 0 { + rdr, err := ipc.NewReader(bytes.NewReader(body)) + if err != nil { + t.Fatalf("opening arrow stream: %v", err) + } + defer rdr.Release() + for rdr.Next() { + arrowRows += rdr.RecordBatch().NumRows() + } + if err := rdr.Err(); err != nil { + t.Fatalf("reading arrow stream: %v", err) + } + } + return ndjson, arrowRows +} + +func stringColumns(names ...string) dbdriver.Columns { + cols := make(dbdriver.Columns, len(names)) + for i, n := range names { + cols[i] = dbdriver.Column{Name: n, ScanType: dbtypes.StringType} + } + return cols +} + +func TestRunQueryStreamsRows(t *testing.T) { + s := newTestServer() + driver := &fakeDriver{ + cols: stringColumns("n"), + rows: [][]any{{"a"}, {"b"}, {"c"}}, + } + req := executeQueryRequest{RunID: "run1", ProjectID: "p", ServiceID: "svc", Statements: []string{"SELECT n FROM t"}} + + ndjson, arrowRows := runStreaming(t, s, req, driver) + + if arrowRows != 3 { + t.Errorf("streamed arrow rows = %d, want 3", arrowRows) + } + if len(ndjson) != 2 { + t.Fatalf("NDJSON lines = %d, want 2 (columns, success): %v", len(ndjson), ndjson) + } + if _, ok := ndjson[0]["columns"]; !ok { + t.Errorf("first NDJSON line should carry columns, got %v", ndjson[0]) + } + last := ndjson[len(ndjson)-1] + if last["success"] != true { + t.Errorf("last NDJSON line should be success, got %v", last) + } + if last["rowCount"].(float64) != 3 { + t.Errorf("rowCount = %v, want 3", last["rowCount"]) + } + if s.runs.get("run1") != nil { + t.Error("run should be deleted after runQuery returns") + } +} + +func TestRunQueryMultiStatementStreamsLast(t *testing.T) { + s := newTestServer() + driver := &fakeDriver{ + cols: stringColumns("n"), + rows: [][]any{{"x"}, {"y"}}, + } + req := executeQueryRequest{ + RunID: "run2", + ProjectID: "p", + ServiceID: "svc", + Statements: []string{"CREATE TEMP TABLE t (n text)", "INSERT INTO t VALUES ('x'),('y')", "SELECT n FROM t"}, + } + + ndjson, arrowRows := runStreaming(t, s, req, driver) + + if len(driver.queries) != 3 { + t.Fatalf("driver ran %d statements, want 3: %v", len(driver.queries), driver.queries) + } + if arrowRows != 2 { + t.Errorf("streamed arrow rows = %d, want 2", arrowRows) + } + last := ndjson[len(ndjson)-1] + if last["executedStatements"].(float64) != 3 { + t.Errorf("executedStatements = %v, want 3", last["executedStatements"]) + } +} + +func TestArrowResultsRejectsConcurrentConsumers(t *testing.T) { + s := newTestServer() + run := &Run{ + id: "run3", + rows: make(chan []any), + ready: make(chan struct{}), + done: make(chan struct{}), + } + run.arrowStarted.Store(true) // simulate a consumer already streaming + s.runs.add(run) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/arrowResults", strings.NewReader(`{"runId":"run3"}`)) + s.handleArrowResults(w, r) + + if w.Code != 409 { + t.Errorf("status = %d, want 409 Conflict", w.Code) + } + var body jsonErrorBody + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("error body is not JSON: %v (%s)", err, w.Body.String()) + } + if body.Error.Message == "" { + t.Error("error body should carry a message") } } diff --git a/internal/serve/httperr.go b/internal/serve/httperr.go new file mode 100644 index 0000000..55df13c --- /dev/null +++ b/internal/serve/httperr.go @@ -0,0 +1,31 @@ +package serve + +import ( + "encoding/json" + "net/http" +) + +// jsonErrorBody mirrors the ErrorResponse shape the hosted query service +// returns. The widget's client runs every non-streaming response through +// checkApiError, which parses `error.message` out of the JSON body; a plain +// text body (e.g. from http.Error) would be discarded, losing the message. +type jsonErrorBody struct { + Error jsonErrorMessage `json:"error"` + Success bool `json:"success"` +} + +type jsonErrorMessage struct { + Message string `json:"message"` +} + +// writeJSONError writes a structured JSON error body with the given HTTP +// status, so the widget can surface the message to the user. +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(jsonErrorBody{ + Error: jsonErrorMessage{Message: message}, + Success: false, + }) +} diff --git a/internal/serve/server.go b/internal/serve/server.go index 32b0305..4cf28ed 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net" "net/http" "strconv" @@ -24,11 +25,17 @@ type Config struct { // call App.Load on each request so OAuth tokens are refreshed and the user // can log in/out in another terminal without restarting the server. App *common.App + // Logger receives diagnostics from the long-running server (e.g. errors + // closing a database connection). Like `ghost mcp`, serve is a long-lived + // backend process, so structured logging to stderr is appropriate. If nil, + // logging is discarded. + Logger *slog.Logger } // Server wraps the HTTP server and exposes the resolved listen address. type Server struct { cfg Config + logger *slog.Logger srv *http.Server ln net.Listener addr string @@ -54,9 +61,15 @@ func New(cfg Config) (*Server, error) { return nil, fmt.Errorf("listen on %s: %w", addr, err) } + logger := cfg.Logger + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + configDir := cfg.App.GetConfig().ConfigDir s := &Server{ cfg: cfg, + logger: logger, ln: ln, addr: ln.Addr().String(), runs: newRunStore(), diff --git a/internal/serve/session.go b/internal/serve/session.go index 2b58e54..d120f8e 100644 --- a/internal/serve/session.go +++ b/internal/serve/session.go @@ -17,7 +17,7 @@ import ( func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { var req createSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } @@ -64,6 +64,7 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { serviceID: req.ServiceID, startedAt: time.Now(), driver: driver, + logger: s.logger, closed: make(chan struct{}), } s.sessions.add(sess) @@ -76,7 +77,7 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCloseSession(w http.ResponseWriter, r *http.Request) { var req sessionRefRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } sess := s.sessions.get(req.SessionID) @@ -97,7 +98,7 @@ func (s *Server) handleCloseSession(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionEvents(w http.ResponseWriter, r *http.Request) { var req sessionRefRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } diff --git a/internal/serve/store.go b/internal/serve/store.go index e60f957..be3808a 100644 --- a/internal/serve/store.go +++ b/internal/serve/store.go @@ -2,47 +2,69 @@ package serve import ( "context" + "log/slog" "sync" + "sync/atomic" "time" "github.com/timescale/ghost/internal/serve/dbdriver" ) +// rowChanBuffer is the capacity of Run.rows. A small buffer smooths out +// per-row scheduling jitter between the producer (the query goroutine) and +// the consumer (the arrowResults handler) without letting memory usage grow +// unbounded: once the buffer fills, the producer blocks on its next send, +// which throttles how fast we read from the database. This backpressure is +// what keeps memory flat for arbitrarily large result sets. +const rowChanBuffer = 100 + // Run coordinates a single in-flight query between the executeQuery and -// arrowResults handlers. executeQuery runs the query to completion (all -// result sets buffered), populates the chosen result set's columns + rows, -// signals ready, writes the columns NDJSON line, then blocks on done. -// arrowResults waits for ready, walks bufferedRows into an Arrow IPC stream, -// then closes done so executeQuery can emit the success/error terminator. +// arrowResults handlers. The query runs in a dedicated goroutine that streams +// scanned rows over the rows channel (see streamQuery). executeQuery waits for +// ready (columns known), writes the columns NDJSON line, then blocks on done. +// arrowResults waits for ready, ranges over rows building an Arrow IPC stream +// with backpressure, then closes done so executeQuery can emit the +// success/error terminator. Rows are never buffered in full — they flow from +// the database straight to the wire. type Run struct { id string projectID string serviceID string startedAt time.Time - // Populated by executeQuery before closing ready. These describe the - // single result set we surface to the widget (per the user-facing rule: - // last result set with columns, or the last result set if none had - // columns). - columns dbdriver.Columns - bufferedRows [][]any + // columns is set by the query goroutine before it closes ready. + columns dbdriver.Columns + + // rows streams scanned rows from the query goroutine to arrowResults. It + // is closed by the query goroutine once the result set is exhausted (or on + // error/cancellation). Backpressure on this channel bounds memory use. + rows chan []any + + // rowCount and rowsAffected are set by the query goroutine before it + // closes rows; they are safe to read after done is closed. rowCount int64 rowsAffected *int64 - // Number of result sets the database returned for this run — used by the + // Number of statements the database executed for this run — used by the // UI to show "Executed N statements" when N > 1. executedStatements int64 + // arrowStarted guards against more than one arrowResults handler draining + // the rows channel. Only the first caller wins; concurrent/duplicate + // fetches are rejected (mirrors the upstream single-reader pipe design). + arrowStarted atomic.Bool + // cancelQuery aborts the in-flight query via pg_cancel_backend (wired // through driver.Context's cancelContext). Used by /api/cancelRun and - // by client-disconnect detection in executeQuery. + // by client-disconnect detection in executeQuery / arrowResults. cancelQuery context.CancelFunc ready chan struct{} done chan struct{} - err *dbdriver.NormalizedError - errOnce sync.Once + err *dbdriver.NormalizedError + errOnce sync.Once + doneOnce sync.Once } func (r *Run) setError(e *dbdriver.NormalizedError) { @@ -50,11 +72,7 @@ func (r *Run) setError(e *dbdriver.NormalizedError) { } func (r *Run) closeDone() { - select { - case <-r.done: - default: - close(r.done) - } + r.doneOnce.Do(func() { close(r.done) }) } // runStore holds all in-flight runs keyed by their widget-generated run id. @@ -95,6 +113,7 @@ type Session struct { startedAt time.Time driver dbdriver.Driver + logger *slog.Logger closed chan struct{} closeErr *dbdriver.NormalizedError @@ -106,7 +125,9 @@ func (s *Session) close(reason *dbdriver.NormalizedError) { s.closeErr = reason close(s.closed) if s.driver != nil { - _ = s.driver.Close() + if err := s.driver.Close(); err != nil && s.logger != nil { + s.logger.Warn("error closing session database connection", "err", err) + } } }) } diff --git a/internal/serve/wire.go b/internal/serve/wire.go index ad83de9..4314343 100644 --- a/internal/serve/wire.go +++ b/internal/serve/wire.go @@ -1,14 +1,12 @@ package serve import ( - "strings" - "github.com/timescale/ghost/internal/serve/dbdriver" ) -// Wire-format types matching what @popsql/query-client's TimescaleQueryClient -// sends to and expects back from the savannah gateway. Source: -// popsql/packages/popsql-query-client/src/{TimescaleQueryClient,client}.ts. +// Wire-format types matching what the query widget's client sends to and +// expects back from the hosted query service. These mirror the widget's +// TimescaleQueryClient request/response shapes. // executeQueryRequest matches TimescaleExecuteQueryRequest. The widget // emits both a top-level `query` field (legacy / unused in practice) and a @@ -24,20 +22,6 @@ type executeQueryRequest struct { Timeout *int64 `json:"timeout,omitempty"` } -// SQL returns the effective query text to execute. Prefers the statements -// array (widget's canonical field) and falls back to the raw query. -func (r executeQueryRequest) SQL() string { - if len(r.Statements) > 0 { - var joined strings.Builder - joined.WriteString(r.Statements[0]) - for _, s := range r.Statements[1:] { - joined.WriteString("; " + s) - } - return joined.String() - } - return r.Query -} - // executeSessionQueryRequest matches TimescaleExecuteSessionQueryRequest. type executeSessionQueryRequest struct { executeQueryRequest @@ -81,9 +65,8 @@ type createSessionResponse struct { // columnsResult is the first NDJSON line written by executeQuery. The widget // uses 'columns' as the discriminator. type columnsResult struct { - RunID string `json:"runId"` - Columns dbdriver.Columns `json:"columns"` - Metadata *dbdriver.Metadata `json:"meta,omitempty"` + RunID string `json:"runId"` + Columns dbdriver.Columns `json:"columns"` } // successResult is the final NDJSON line on a successful run. diff --git a/internal/serve/wire_test.go b/internal/serve/wire_test.go index f777794..c195536 100644 --- a/internal/serve/wire_test.go +++ b/internal/serve/wire_test.go @@ -5,49 +5,17 @@ import ( "testing" ) -func TestExecuteQueryRequest_SQL(t *testing.T) { - tests := []struct { - name string - body string - want string - }{ - { - name: "statements array (widget canonical)", - body: `{"projectId":"p","serviceId":"s","runId":"r","statements":["SELECT 1;"],"stream":true}`, - want: "SELECT 1;", - }, - { - name: "multiple statements joined with ;", - body: `{"projectId":"p","serviceId":"s","runId":"r","statements":["SELECT 1","SELECT 2"]}`, - want: "SELECT 1; SELECT 2", - }, - { - name: "falls back to query field when statements is empty", - body: `{"projectId":"p","serviceId":"s","runId":"r","query":"SELECT 3","statements":[]}`, - want: "SELECT 3", - }, - { - name: "falls back to query field when statements is omitted", - body: `{"projectId":"p","serviceId":"s","runId":"r","query":"SELECT 4"}`, - want: "SELECT 4", - }, - { - name: "empty when both fields are blank", - body: `{"projectId":"p","serviceId":"s","runId":"r"}`, - want: "", - }, +func TestExecuteQueryRequestDecodesStatements(t *testing.T) { + body := `{"projectId":"p","serviceId":"s","runId":"r","statements":["SELECT 1","SELECT 2"],"query":"SELECT 3"}` + var req executeQueryRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("decode: %v", err) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var req executeQueryRequest - if err := json.Unmarshal([]byte(tt.body), &req); err != nil { - t.Fatalf("decode: %v", err) - } - if got := req.SQL(); got != tt.want { - t.Errorf("SQL() = %q, want %q", got, tt.want) - } - }) + if len(req.Statements) != 2 || req.Statements[0] != "SELECT 1" || req.Statements[1] != "SELECT 2" { + t.Errorf("Statements = %v, want [SELECT 1 SELECT 2]", req.Statements) + } + if req.Query != "SELECT 3" { + t.Errorf("Query = %q, want %q", req.Query, "SELECT 3") } } @@ -63,7 +31,7 @@ func TestExecuteSessionQueryRequest_DecodesEmbedded(t *testing.T) { if req.RunID != "r" { t.Errorf("RunID = %q, want %q", req.RunID, "r") } - if got := req.SQL(); got != "SELECT 1" { - t.Errorf("SQL() = %q, want %q", got, "SELECT 1") + if len(req.Statements) != 1 || req.Statements[0] != "SELECT 1" { + t.Errorf("Statements = %v, want [SELECT 1]", req.Statements) } } From be3edc2a1c038416cdf97905dbd2bd7b02afaeed Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Thu, 4 Jun 2026 18:10:52 -0400 Subject: [PATCH 38/39] Modernize loop idioms in serve tests (go fix) --- internal/serve/arrow_results_test.go | 2 +- internal/serve/execute_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/serve/arrow_results_test.go b/internal/serve/arrow_results_test.go index 1b952b7..a47bb4b 100644 --- a/internal/serve/arrow_results_test.go +++ b/internal/serve/arrow_results_test.go @@ -16,7 +16,7 @@ func buildBatch(t *testing.T, n int, value string) (int64, func()) { if err != nil { t.Fatalf("NewRecordBuilder: %v", err) } - for i := 0; i < n; i++ { + for range n { if err := rb.AppendRow([]any{value}); err != nil { t.Fatalf("AppendRow: %v", err) } diff --git a/internal/serve/execute_test.go b/internal/serve/execute_test.go index 7d642c5..5135904 100644 --- a/internal/serve/execute_test.go +++ b/internal/serve/execute_test.go @@ -107,7 +107,7 @@ func runStreaming(t *testing.T, s *Server, req executeQueryRequest, driver dbdri t.Fatal("runQuery did not return") } - for _, line := range strings.Split(strings.TrimSpace(execW.Body.String()), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(execW.Body.String()), "\n") { if line == "" { continue } From c1031a59f0430e476e52e6c1cfcbe9f741d6d53f Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Fri, 5 Jun 2026 13:31:56 -0400 Subject: [PATCH 39/39] Remove unnecessary nil cases in custom data/time Scan methods --- internal/serve/dbtypes/date.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/serve/dbtypes/date.go b/internal/serve/dbtypes/date.go index ae8b816..e092037 100644 --- a/internal/serve/dbtypes/date.go +++ b/internal/serve/dbtypes/date.go @@ -9,8 +9,6 @@ type Date string func (d *Date) Scan(src any) error { switch val := src.(type) { - case nil: - *d = "" case string: *d = Date(val) case time.Time: @@ -25,8 +23,6 @@ type ClockTime string func (c *ClockTime) Scan(src any) error { switch val := src.(type) { - case nil: - *c = "" case string: *c = ClockTime(val) case time.Time: @@ -41,8 +37,6 @@ type ClockTimeTZ string func (c *ClockTimeTZ) Scan(src any) error { switch val := src.(type) { - case nil: - *c = "" case string: *c = ClockTimeTZ(val) case time.Time: @@ -57,8 +51,6 @@ type DateTime string func (d *DateTime) Scan(src any) error { switch val := src.(type) { - case nil: - *d = "" case string: *d = DateTime(val) case time.Time: @@ -73,8 +65,6 @@ type Timestamp string func (t *Timestamp) Scan(src any) error { switch val := src.(type) { - case nil: - *t = "" case string: *t = Timestamp(val) case time.Time: