diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b8c5c03..0f6c6e7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,6 +25,11 @@ jobs: with: go-version-file: "go.mod" + - name: Build web bundle + env: + NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/build-web.sh + - name: Install package_cloud gem run: sudo gem install package_cloud --no-doc diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3256a8b..cc33ce7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,6 +11,9 @@ jobs: test: name: Test runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - name: Checkout uses: actions/checkout@v6 @@ -25,6 +28,11 @@ jobs: - name: ShellCheck run: shellcheck scripts/install.sh + - name: Build web bundle + env: + NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/build-web.sh + - name: Mod run: go mod tidy -diff diff --git a/.gitignore b/.gitignore index e2ff153..2f77570 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,12 @@ mcp-publisher # Local files todo.md new.md + +# ghost serve +web/download/ +web/node_modules/ +web/dist/ +web/.npmrc +web/tsconfig.tsbuildinfo +internal/serve/web/* +!internal/serve/web/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 9764bf1..a77b68e 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, id, 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, id, 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 `web/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 32f9cfe..39ada21 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: @@ -82,6 +83,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/check b/check index c1a7797..48feb64 100755 --- a/check +++ b/check @@ -1,6 +1,8 @@ #!/bin/sh set -ex +./scripts/build-web.sh + go mod tidy go install ./... go fmt ./... diff --git a/docs/cli/ghost.md b/docs/cli/ghost.md index 39764fa..4e74cc3 100644 --- a/docs/cli/ghost.md +++ b/docs/cli/ghost.md @@ -63,6 +63,7 @@ monthly usage. * [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..1c061b5 --- /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 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/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/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/root.go b/internal/cmd/root.go index b452133..6a451aa 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -144,6 +144,7 @@ monthly usage.`, cmd.AddCommand(buildUpgradeCmd(app)) cmd.AddCommand(buildInvoiceCmd(app)) cmd.AddCommand(buildOveragesCmd(app)) + cmd.AddCommand(buildServeCmd(app)) wrapCommands(cmd, app) diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go new file mode 100644 index 0000000..409d3a3 --- /dev/null +++ b/internal/cmd/serve.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/serve" +) + +func buildServeCmd(app *common.App) *cobra.Command { + var port int + var host string + var noOpen bool + + cmd := &cobra.Command{ + Use: "serve", + Short: "Launch a local web UI for running SQL queries", + Long: `Start a local web server 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.`, + Example: ` # 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`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + 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, + Port: port, + App: app, + Logger: newLogger(cmd), + }) + if err != nil { + return err + } + + url := srv.URL() + cmd.PrintErrf("Listening on %s\n", url) + + if !noOpen { + if err := common.OpenBrowser(url); err != nil { + cmd.PrintErrf("Failed to open browser: %v\n", err) + } + } + cmd.PrintErrln("Press Ctrl+C to stop.") + + return srv.Serve(cmd.Context()) + }, + } + + cmd.Flags().IntVar(&port, "port", 0, "TCP port to listen on (0 = auto)") + cmd.Flags().StringVar(&host, "host", "127.0.0.1", "interface to bind (loopback by default)") + cmd.Flags().BoolVar(&noOpen, "no-open", false, "do not open the browser") + + return cmd +} diff --git a/internal/cmd/serve_test.go b/internal/cmd/serve_test.go new file mode 100644 index 0000000..d896fea --- /dev/null +++ b/internal/cmd/serve_test.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "context" + "errors" + "net" + "slices" + "strconv" + "strings" + "testing" +) + +func TestServeCmd(t *testing.T) { + 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: 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) + } + } + }) + } +} diff --git a/internal/serve/arrow.go b/internal/serve/arrow.go new file mode 100644 index 0000000..316fe27 --- /dev/null +++ b/internal/serve/arrow.go @@ -0,0 +1,326 @@ +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" +) + +// 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__" + +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 +} + +// 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 } + +// 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. 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: + 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..0d5882c --- /dev/null +++ b/internal/serve/arrow_results.go @@ -0,0 +1,158 @@ +package serve + +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" +) + +// 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. 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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + run := s.runs.get(req.RunID) + if run == nil { + http.NotFound(w, r) + return + } + + // 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(): + return + } + + rb, err := NewRecordBuilder(run.columns) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "arrow schema: "+err.Error()) + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + run.cancelQuery() + 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() + defer run.closeDone() + + // 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() >= 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, batchRows); err != nil && run.err == nil { + run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"}) + } + } +} + +// 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 oldRowCount, err + } + flushWriter(w) + 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..a47bb4b --- /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 range n { + 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/assets.go b/internal/serve/assets.go new file mode 100644 index 0000000..26ad702 --- /dev/null +++ b/internal/serve/assets.go @@ -0,0 +1,106 @@ +package serve + +import ( + "embed" + "errors" + "io/fs" + "mime" + "net/http" + "path" + "strings" +) + +//go:embed all:web +var embeddedAssets embed.FS + +// webFS is the subtree of embeddedAssets rooted at "web/". Populated in init +// so each request handler doesn't need to repeat the fs.Sub call. +var webFS fs.FS + +func init() { + sub, err := fs.Sub(embeddedAssets, "web") + if err != nil { + panic(err) + } + webFS = sub +} + +// hasBundledUI reports whether scripts/build-web.sh has been run and the +// resulting index.html is present in the embed. When false, the server falls +// back to a small placeholder page that tells the user how to build the UI. +func hasBundledUI() bool { + _, err := fs.Stat(webFS, "index.html") + return err == nil +} + +const placeholderHTML = ` +ghost serve + + +

ghost serve

+

The web UI bundle has not been built into this binary.

+

Run ./scripts/build-web.sh from the repo root, then rebuild the binary.

+ +` + +// newAssetHandler serves embedded SPA files. Behavior: +// - "/" -> index.html +// - exact match in webFS -> served with detected content type +// - last segment has no "." -> SPA fallback to index.html +// - otherwise -> 404 +// +// Cache headers: /assets/* gets immutable + max-age=1y (Vite hashed files); +// everything else gets no-cache. +func newAssetHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !hasBundledUI() { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write([]byte(placeholderHTML)) + return + } + + urlPath := r.URL.Path + if urlPath == "/" { + urlPath = "/index.html" + } + clean := strings.TrimPrefix(path.Clean(urlPath), "/") + + data, err := fs.ReadFile(webFS, clean) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + http.Error(w, "asset read error", http.StatusInternalServerError) + return + } + // SPA fallback: paths without an extension (no "." in last segment) + // fall through to index.html so client-side routing works. + last := path.Base(clean) + if strings.Contains(last, ".") { + http.NotFound(w, r) + return + } + data, err = fs.ReadFile(webFS, "index.html") + if err != nil { + http.Error(w, "index.html missing from bundle", http.StatusInternalServerError) + return + } + clean = "index.html" + } + + ext := path.Ext(clean) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + + if strings.HasPrefix(clean, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + + _, _ = w.Write(data) + }) +} 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/bootstrap.go b/internal/serve/bootstrap.go new file mode 100644 index 0000000..05b5ab9 --- /dev/null +++ b/internal/serve/bootstrap.go @@ -0,0 +1,27 @@ +package serve + +import ( + "encoding/json" + "net/http" + + "github.com/timescale/ghost/internal/config" +) + +type bootstrapResponse struct { + ProjectID string `json:"projectId"` + Version string `json:"version"` +} + +func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { + _, projectID, err := s.loadClient(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(bootstrapResponse{ + ProjectID: projectID, + Version: config.Version, + }) +} diff --git a/internal/serve/cancel.go b/internal/serve/cancel.go new file mode 100644 index 0000000..39a068e --- /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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + 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..f2a37f5 --- /dev/null +++ b/internal/serve/connect.go @@ -0,0 +1,92 @@ +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. 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 + } + 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, + ReadOnly: readOnly, + }) + 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/databases.go b/internal/serve/databases.go new file mode 100644 index 0000000..1f3b9e8 --- /dev/null +++ b/internal/serve/databases.go @@ -0,0 +1,53 @@ +package serve + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/timescale/ghost/internal/common" +) + +type databaseListItem struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` +} + +func (s *Server) handleDatabases(w http.ResponseWriter, r *http.Request) { + client, projectID, err := s.loadClient(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + resp, err := client.ListDatabasesWithResponse(r.Context(), projectID) + if err != nil { + http.Error(w, fmt.Sprintf("list databases: %v", err), http.StatusBadGateway) + return + } + if resp.StatusCode() != http.StatusOK { + http.Error(w, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSONDefault).Error(), resp.StatusCode()) + return + } + if resp.JSON200 == nil { + http.Error(w, errors.New("empty response from API").Error(), http.StatusBadGateway) + return + } + + out := make([]databaseListItem, len(*resp.JSON200)) + for i, db := range *resp.JSON200 { + out[i] = databaseListItem{ + ID: db.Id, + Name: db.Name, + Status: string(db.Status), + Type: string(db.Type), + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/internal/serve/dbdriver/api.go b/internal/serve/dbdriver/api.go new file mode 100644 index 0000000..77b2293 --- /dev/null +++ b/internal/serve/dbdriver/api.go @@ -0,0 +1,54 @@ +// 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 query widget +// expects. +// +// 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 ( + "errors" + "reflect" +) + +// Column carries column metadata to the widget. JSON shape matches the +// "Column" type the query widget's client consumes. +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:"-"` +} + +// NormalizedError is the canonical error shape consumed by the widget. The +// JSON shape mirrors the widget client's 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 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 new file mode 100644 index 0000000..559a715 --- /dev/null +++ b/internal/serve/dbdriver/cancel.go @@ -0,0 +1,47 @@ +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 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()) + + 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..502d7c4 --- /dev/null +++ b/internal/serve/dbdriver/driver.go @@ -0,0 +1,191 @@ +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, query string) (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 +} + +// 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, query string) (Rows, error) { + return b.query(ctx, query, b.scanType) +} + +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, + 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..77a3ceb --- /dev/null +++ b/internal/serve/dbdriver/postgres.go @@ -0,0 +1,173 @@ +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) { + // 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{} + 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, query string) (Rows, error) { + baseRows, err := d.query(ctx, query, 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..38e9137 --- /dev/null +++ b/internal/serve/dbdriver/rows.go @@ -0,0 +1,150 @@ +package dbdriver + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + + "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) + 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 + 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), + 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) 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) string { + name := ct.Name() + if name == "" { + name = "column" + } + return name +} + +func (d deduper) dedupe(ct *sql.ColumnType) string { + name := d.columnName(ct) + 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..e092037 --- /dev/null +++ b/internal/serve/dbtypes/date.go @@ -0,0 +1,95 @@ +package dbtypes + +import ( + "fmt" + "time" +) + +type Date string + +func (d *Date) Scan(src any) error { + switch val := src.(type) { + 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 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 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 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 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 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 + 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..c06d56b --- /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. +// +// 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 ( + "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..aa46191 --- /dev/null +++ b/internal/serve/execute.go @@ -0,0 +1,316 @@ +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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + client, projectID, err := s.loadClient(r.Context()) + if err != nil { + writeErrorTerminator(w, req.RunID, &dbdriver.NormalizedError{Message: err.Error(), Source: "ghost", Connect: true}) + return + } + if !checkProject(w, req.RunID, req.ProjectID, projectID) { + return + } + + 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) { + writeErrorTerminator(w, req.RunID, ce.Normalized()) + } else { + writeErrorTerminator(w, req.RunID, &dbdriver.NormalizedError{Message: connErr.Error(), Source: "ghost", Connect: true}) + } + return + } + defer func() { + if err := driver.Close(); err != nil { + s.logger.Warn("error closing database connection", "err", err) + } + }() + + s.runQuery(w, r, req, driver) +} + +// 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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + _, projectID, err := s.loadClient(r.Context()) + if err != nil { + writeErrorTerminator(w, req.RunID, &dbdriver.NormalizedError{Message: err.Error(), Source: "ghost", Connect: true}) + return + } + if !checkProject(w, req.RunID, req.ProjectID, 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) +} + +// runQuery is the shared body of handleExecuteQuery / handleExecuteSessionQuery. +// +// 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() + + 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(), + 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) + + go s.streamQuery(queryCtx, run, driver, statements) + + // 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 + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + enc := json.NewEncoder(w) + + // 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 streaming, or for the client to + // disconnect. arrowResults closes done once it has drained run.rows. + select { + case <-run.done: + case <-r.Context().Done(): + cancelQuery() + 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, + ExecutedStatements: run.executedStatements, + }) + } + flushWriter(w) +} + +// 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. +// +// 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) + } + } + 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 + } + + // 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 { + fail(err) + return + } + defer func() { + if err := rows.Close(); err != nil { + s.logger.Debug("error closing rows", "err", err) + } + }() + + columns, err := rows.Columns() + if err != nil { + fail(err) + return + } + run.columns = columns + readyOnce() + + targets := columns.ScanTargets() + for rows.Next() { + if err := rows.Scan(targets...); err != nil { + fail(err) + return + } + select { + case run.rows <- targets.Values(): + rowCount++ + case <-ctx.Done(): + fail(ctx.Err()) + return + } + } + if err := rows.Err(); err != nil { + fail(err) + return + } + if err := rows.Close(); err != nil { + fail(err) + return + } + + run.rowCount = rowCount + if ra, _ := rows.RowsAffected(ctx); ra != nil { + run.rowsAffected = ra + } +} + +// 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 rows.Close() +} + +// checkProject rejects requests for a different project than the one the CLI +// is logged into. Single-user defense in depth. +func checkProject(w http.ResponseWriter, runID, requestProjectID, activeProjectID string) bool { + if requestProjectID == activeProjectID { + 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/execute_test.go b/internal/serve/execute_test.go new file mode 100644 index 0000000..5135904 --- /dev/null +++ b/internal/serve/execute_test.go @@ -0,0 +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" +) + +// 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.SplitSeq(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 new file mode 100644 index 0000000..4cf28ed --- /dev/null +++ b/internal/serve/server.go @@ -0,0 +1,155 @@ +package serve + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "time" + + "github.com/timescale/ghost/internal/api" + "github.com/timescale/ghost/internal/common" +) + +// Config configures a Server instance. +type Config struct { + // Host is the bind address. Use "127.0.0.1" for loopback-only (recommended). + Host string + // Port is the bind port. 0 lets the OS choose a free port. + Port int + // App provides access to the ghost-api client and active project. Handlers + // 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 + runs *runStore + sessions *sessionStore + state *stateStore +} + +// New constructs a Server with all routes registered. The listener is bound +// (so the resolved address is available immediately) but not yet serving. +// Call Serve to begin handling requests. +func New(cfg Config) (*Server, error) { + if cfg.Host == "" { + cfg.Host = "127.0.0.1" + } + if cfg.App == nil { + return nil, errors.New("serve: app is required") + } + + addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + ln, err := net.Listen("tcp", addr) + if err != nil { + 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(), + sessions: newSessionStore(), + state: newStateStore(configDir), + } + + mux := http.NewServeMux() + mux.Handle("GET /healthz", healthzHandler()) + mux.Handle("GET /api/bootstrap", http.HandlerFunc(s.handleBootstrap)) + mux.Handle("GET /api/databases", http.HandlerFunc(s.handleDatabases)) + 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("GET /api/state", http.HandlerFunc(s.handleGetState)) + mux.Handle("PUT /api/state", http.HandlerFunc(s.handlePutState)) + mux.Handle("/", newAssetHandler()) + + 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 +// was 0). +func (s *Server) Addr() string { return s.addr } + +// URL returns the http://addr URL clients should connect to. +func (s *Server) URL() string { return "http://" + s.addr } + +// Serve starts handling requests and blocks until ctx is canceled. On +// cancellation the server is gracefully shut down with a 5s deadline. +func (s *Server) Serve(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + err := s.srv.Serve(s.ln) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errCh <- err + }() + + select { + case <-ctx.Done(): + 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 + } +} + +func healthzHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + }) +} + +// loadClient reloads credentials from disk (refreshing the OAuth token if +// needed) and returns a ghost-api client bound to the active project. Called +// per request so a long-running server doesn't keep using a stale token after +// it expires. +func (s *Server) loadClient(ctx context.Context) (api.ClientWithResponsesInterface, string, error) { + _, client, projectID, err := s.cfg.App.Load(ctx) + if err != nil { + return nil, "", err + } + if client == nil { + _, _, clientErr := s.cfg.App.GetClient() + if clientErr != nil { + return nil, "", clientErr + } + return nil, "", errors.New("authentication required") + } + return client, projectID, nil +} diff --git a/internal/serve/session.go b/internal/serve/session.go new file mode 100644 index 0000000..d120f8e --- /dev/null +++ b/internal/serve/session.go @@ -0,0 +1,132 @@ +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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + enc := json.NewEncoder(w) + + client, projectID, err := s.loadClient(r.Context()) + if err != nil { + _ = enc.Encode(createSessionResponse{ + Success: false, + Error: &dbdriver.NormalizedError{Message: err.Error(), Source: "ghost", Connect: true}, + }) + return + } + if req.ProjectID != 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(), client, req.ProjectID, req.ServiceID, s.cfg.App.GetConfig().ReadOnly) + 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, + logger: s.logger, + 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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + 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 { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + 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/state.go b/internal/serve/state.go new file mode 100644 index 0000000..2d6809f --- /dev/null +++ b/internal/serve/state.go @@ -0,0 +1,113 @@ +package serve + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "sync" +) + +// serveState is the persisted UI state for `ghost serve`. Pointer fields let +// callers omit values they don't want to overwrite (encoded with omitempty). +type serveState struct { + SelectedDatabaseID *string `json:"selectedDatabaseId,omitempty"` + EditorHeight *int `json:"editorHeight,omitempty"` + EditorSQL *string `json:"editorSql,omitempty"` +} + +const stateFileName = "serve-state.json" + +// stateStore persists serveState to a JSON file in the user's config dir. +// Writes are atomic (temp file + rename) and serialized via a mutex so +// concurrent PUTs can't interleave. +type stateStore struct { + path string + lock sync.Mutex +} + +func newStateStore(configDir string) *stateStore { + return &stateStore{path: filepath.Join(configDir, stateFileName)} +} + +func (s *stateStore) load() (serveState, error) { + s.lock.Lock() + defer s.lock.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return serveState{}, nil + } + return serveState{}, fmt.Errorf("read state file: %w", err) + } + var state serveState + if err := json.Unmarshal(data, &state); err != nil { + return serveState{}, fmt.Errorf("parse state file: %w", err) + } + return state, nil +} + +func (s *stateStore) save(state serveState) error { + s.lock.Lock() + defer s.lock.Unlock() + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".serve-state.json.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + 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 { + return fmt.Errorf("rename temp file: %w", err) + } + return nil +} + +func (s *Server) handleGetState(w http.ResponseWriter, _ *http.Request) { + state, err := s.state.load() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(state) +} + +func (s *Server) handlePutState(w http.ResponseWriter, r *http.Request) { + var state serveState + if err := json.NewDecoder(r.Body).Decode(&state); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.state.save(state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} 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) + } +} diff --git a/internal/serve/store.go b/internal/serve/store.go new file mode 100644 index 0000000..be3808a --- /dev/null +++ b/internal/serve/store.go @@ -0,0 +1,171 @@ +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. 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 + + // 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 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 / arrowResults. + cancelQuery context.CancelFunc + + ready chan struct{} + done chan struct{} + + err *dbdriver.NormalizedError + errOnce sync.Once + doneOnce sync.Once +} + +func (r *Run) setError(e *dbdriver.NormalizedError) { + r.errOnce.Do(func() { r.err = e }) +} + +func (r *Run) closeDone() { + r.doneOnce.Do(func() { 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 + logger *slog.Logger + + 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 { + if err := s.driver.Close(); err != nil && s.logger != nil { + s.logger.Warn("error closing session database connection", "err", err) + } + } + }) +} + +// 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/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/web/.gitkeep b/internal/serve/web/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/serve/wire.go b/internal/serve/wire.go new file mode 100644 index 0000000..4314343 --- /dev/null +++ b/internal/serve/wire.go @@ -0,0 +1,99 @@ +package serve + +import ( + "github.com/timescale/ghost/internal/serve/dbdriver" +) + +// 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 +// `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"` +} + +// 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"` +} + +// 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"` + ExecutedStatements int64 `json:"executedStatements,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/internal/serve/wire_test.go b/internal/serve/wire_test.go new file mode 100644 index 0000000..c195536 --- /dev/null +++ b/internal/serve/wire_test.go @@ -0,0 +1,37 @@ +package serve + +import ( + "encoding/json" + "testing" +) + +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) + } + 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") + } +} + +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 len(req.Statements) != 1 || req.Statements[0] != "SELECT 1" { + t.Errorf("Statements = %v, want [SELECT 1]", req.Statements) + } +} diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100755 index 0000000..c3e5e06 --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +# Build the React web app and stage its output into internal/serve/web/ for +# Go's //go:embed directive. +# +# Requires NPM_AUTH_TOKEN to be exported (a GitHub PAT with read:packages +# scope, or secrets.GITHUB_TOKEN in CI). The token is referenced from +# web/.npmrc; copy web/.npmrc.example -> web/.npmrc once per checkout. + +repoRoot="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repoRoot" + +if [ ! -f web/.npmrc ]; then + cp web/.npmrc.example web/.npmrc +fi + +(cd web && ./bun install) +(cd web && ./bun run build) + +embedDir="internal/serve/web" +find "$embedDir" -mindepth 1 ! -name '.gitkeep' -delete +cp -R web/dist/. "$embedDir/" diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..bf01524 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +tsconfig.tsbuildinfo diff --git a/web/.npmrc.example b/web/.npmrc.example new file mode 100644 index 0000000..1a01701 --- /dev/null +++ b/web/.npmrc.example @@ -0,0 +1,2 @@ +@timescale:registry=https://npm.pkg.github.com/ +//npm.pkg.github.com/:_authToken=${NPM_AUTH_TOKEN} diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..aa8543d --- /dev/null +++ b/web/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "useKeyWithMouseEvents": "off", + "noStaticElementInteractions": "off" + }, + "suspicious": { + "noUnknownAtRules": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/web/bun b/web/bun new file mode 100755 index 0000000..9d072df --- /dev/null +++ b/web/bun @@ -0,0 +1,17 @@ +#!/bin/bash + +version="bun-v1.3.14" +scriptDir="$(cd "$(dirname "$0")" && pwd)" +downloadDir="$scriptDir/download/bun/${version}" +bunCmd="$downloadDir/bin/bun" + +if [ ! -f "$bunCmd" ]; then + echo Installing bun to "$bunCmd" + bashArgs=() + if [ "$version" != "latest" ]; then + bashArgs=(-s "$version") + fi + curl -fsSL https://bun.sh/install | BUN_INSTALL="$downloadDir" bash "${bashArgs[@]}" +fi + +exec "$bunCmd" "$@" diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..f8afc1e --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,709 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@ghost-cli/web", + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "@timescale/popsql-query-widget-cdn": "0.0.0-dev.161", + "framer-motion": "^12.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.14", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.9", + "@types/node": "^22.10.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", + }, + }, + }, + "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=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + + "@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/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=="], + + "@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-cdn": ["@timescale/popsql-query-widget-cdn@0.0.0-dev.161", "https://npm.pkg.github.com/download/@timescale/popsql-query-widget-cdn/0.0.0-dev.161/6c6c601988cbe8240d2657ef8655c687d3e80c5e", { "peerDependencies": { "framer-motion": "12.x", "react": "17.x || 18.x", "react-dom": "17.x || 18.x" } }, "sha512-HoXOeTQf+ibNDYJkUruy0G2PnaUpc8GTBDXwBIQ4fjGMwwSHXTqj3nTosYbaARsWmQrBMFFlfoWPVH3dLi1Zig=="], + + "@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/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@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=="], + + "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=="], + + "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=="], + + "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=="], + + "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=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "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=="], + + "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=="], + + "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=="], + + "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=="], + + "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=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "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=="], + + "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=="], + + "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=="], + + "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": ["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=="], + + "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=="], + + "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=="], + + "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=="], + + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + + "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/index.html b/web/index.html new file mode 100644 index 0000000..7e700f9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + ghost + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6161cef --- /dev/null +++ b/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@ghost-cli/web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "biome check", + "check": "./bun i --silent && ./bun run typecheck && ./bun run lint --write" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "@timescale/popsql-query-widget-cdn": "0.0.0-dev.161", + "framer-motion": "^12.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.9", + "@types/node": "^22.10.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" + } +} diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/app.tsx b/web/src/app.tsx new file mode 100644 index 0000000..55bfe07 --- /dev/null +++ b/web/src/app.tsx @@ -0,0 +1,145 @@ +import { useQuery } from '@tanstack/react-query'; +import '@timescale/popsql-query-widget-cdn/index.css'; + +import { QueryPanel } from './components/QueryPanel'; +import { type PersistedState, useServeStore } from './store'; + +interface Bootstrap { + projectId: string; + version: string; +} + +interface Database { + id: string; + name: string; + status: string; + type?: string; +} + +async function fetchJSON(path: string): Promise { + const res = await fetch(path); + if (!res.ok) throw new Error(`${path}: ${res.status} ${res.statusText}`); + return res.json() as Promise; +} + +const READY_STATUSES = new Set(['ready', 'running']); + +function pickDefaultDatabaseId(databases: Database[]): string | null { + if (databases.length === 1) return databases[0]?.id ?? null; + const ready = databases.filter((db) => READY_STATUSES.has(db.status)); + if (ready.length === 1) return ready[0]?.id ?? null; + return null; +} + +export function App() { + const bootstrap = useQuery({ + queryKey: ['bootstrap'], + queryFn: () => fetchJSON('/api/bootstrap'), + }); + const persistedState = useQuery({ + queryKey: ['state'], + queryFn: async () => { + const data = await fetchJSON('/api/state'); + useServeStore.getState().hydrate(data); + return data; + }, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + const hydrated = useServeStore((s) => s.hydrated); + + if (bootstrap.isError || persistedState.isError) { + return ( +
+ Failed to load app config +
+ ); + } + if (!bootstrap.data || !hydrated) { + return null; + } + return ; +} + +interface ReadyAppProps { + bootstrap: Bootstrap; +} + +function ReadyApp({ bootstrap }: ReadyAppProps) { + const selectedId = useServeStore((s) => s.selectedDatabaseId); + const setSelectedDatabaseId = useServeStore((s) => s.setSelectedDatabaseId); + const editorSql = useServeStore((s) => s.editorSql); + const setEditorSql = useServeStore((s) => s.setEditorSql); + const editorHeight = useServeStore((s) => s.editorHeight); + const setEditorHeight = useServeStore((s) => s.setEditorHeight); + + const databases = useQuery({ + queryKey: ['databases'], + queryFn: async () => { + const data = await fetchJSON('/api/databases'); + if (!useServeStore.getState().selectedDatabaseId) { + const defaultId = pickDefaultDatabaseId(data); + if (defaultId) + useServeStore.getState().setSelectedDatabaseId(defaultId); + } + return data; + }, + refetchInterval: 10_000, + }); + + const selected = databases.data?.find((db) => db.id === selectedId) ?? null; + + return ( +
+
+
+ ghost +
+
+ {databases.isError ? ( + Failed to load databases + ) : ( + + )} +
+
+
+ {!selected ? ( +
+ Select a database to run queries. +
+ ) : ( + + )} +
+
+ ); +} diff --git a/web/src/components/QueryPanel.tsx b/web/src/components/QueryPanel.tsx new file mode 100644 index 0000000..ae121b0 --- /dev/null +++ b/web/src/components/QueryPanel.tsx @@ -0,0 +1,96 @@ +import { + ContextMenuContext, + ContextMenuProvider, + ExecuteQueryEngine, + QueryWidget, + QueryWidgetProvider, + TimescaleResultsCacheContextProvider, +} from '@timescale/popsql-query-widget-cdn'; +import type React from 'react'; +import { useCallback, useState } from 'react'; + +interface Props { + projectId: string; + databaseId: string; + databaseName: string; + query: string; + onQueryChange: (next: string) => void; + editorHeight: number; + onResizeEditor: (height: number) => void; +} + +// 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: _databaseName, + query, + onQueryChange, + editorHeight, + onResizeEditor, +}: Props) { + const [statementCount, setStatementCount] = useState(0); + + const handleQueryComplete = useCallback( + (args: { statementCount?: number }) => { + setStatementCount(args.statementCount ?? 0); + }, + [], + ); + + const renderToolbarAppendLeft = useCallback( + ({ isRunning }: { isRunning: boolean }) => { + if (isRunning || statementCount <= 1) return null; + return ( + + Executed {statementCount} statements + + ); + }, + [statementCount], + ); + + const getExecuteQueryData = useCallback( + ({ runId, query: q }: { runId: string; query: string }) => ({ + engine: ExecuteQueryEngine.timescaleQuery, + params: { + projectId, + serviceId: databaseId, + query: q, + runId, + }, + }), + [projectId, databaseId], + ); + + return ( + + + + + + {({ render }: { render: () => React.ReactNode }) => render()} + + + + + ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..45d348c --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,21 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +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/store.ts b/web/src/store.ts new file mode 100644 index 0000000..c3cad6e --- /dev/null +++ b/web/src/store.ts @@ -0,0 +1,88 @@ +import { create } from 'zustand'; + +export interface PersistedState { + selectedDatabaseId?: string; + editorHeight?: number; + editorSql?: string; +} + +interface ServeStore { + hydrated: boolean; + selectedDatabaseId: string | null; + editorHeight: number; + editorSql: string; + hydrate: (saved: PersistedState) => void; + setSelectedDatabaseId: (id: string | null) => void; + setEditorSql: (sql: string) => void; + setEditorHeight: (height: number) => void; +} + +export const DEFAULT_EDITOR_HEIGHT = 240; + +function getUrlDbId(): string | null { + return new URLSearchParams(window.location.search).get('db'); +} + +function setUrlDbId(id: string | null) { + const url = new URL(window.location.href); + if (id) url.searchParams.set('db', id); + else url.searchParams.delete('db'); + window.history.replaceState(null, '', url.toString()); +} + +let saveTimer: ReturnType | null = null; +const SAVE_DEBOUNCE_MS = 400; + +function schedulePersist(snapshot: PersistedState) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + void fetch('/api/state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot), + }); + }, SAVE_DEBOUNCE_MS); +} + +function snapshotFor(store: ServeStore): PersistedState { + return { + selectedDatabaseId: store.selectedDatabaseId ?? undefined, + editorSql: store.editorSql, + editorHeight: store.editorHeight, + }; +} + +export const useServeStore = create((set, get) => ({ + hydrated: false, + selectedDatabaseId: null, + editorHeight: DEFAULT_EDITOR_HEIGHT, + editorSql: '', + hydrate: (saved) => { + // URL takes precedence over saved state for the selected DB. Write the + // resolved id back to the URL so the address bar always reflects the + // active selection (matches the behavior of later setSelectedDatabaseId + // calls). + const selectedDatabaseId = getUrlDbId() ?? saved.selectedDatabaseId ?? null; + if (selectedDatabaseId) setUrlDbId(selectedDatabaseId); + set({ + hydrated: true, + selectedDatabaseId, + editorSql: saved.editorSql ?? '', + editorHeight: saved.editorHeight ?? DEFAULT_EDITOR_HEIGHT, + }); + }, + setSelectedDatabaseId: (id) => { + set({ selectedDatabaseId: id }); + setUrlDbId(id); + schedulePersist(snapshotFor(get())); + }, + setEditorSql: (sql) => { + set({ editorSql: sql }); + schedulePersist(snapshotFor(get())); + }, + setEditorHeight: (height) => { + set({ editorHeight: height }); + schedulePersist(snapshotFor(get())); + }, +})); 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..1ba3d28 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,38 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +const ghostServePort = process.env.GHOST_SERVE_DEV_PORT ?? '5174'; + +export default defineConfig({ + 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'] }), + ], + 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-cdn'], + }, + 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, + }, +});