Skip to content

Add ghost serve: local web UI for running SQL against ghost databases#22

Open
murrayju wants to merge 39 commits into
mainfrom
murrayju/serve
Open

Add ghost serve: local web UI for running SQL against ghost databases#22
murrayju wants to merge 39 commits into
mainfrom
murrayju/serve

Conversation

@murrayju
Copy link
Copy Markdown
Member

@murrayju murrayju commented May 29, 2026

Overview

Adds ghost serve, a CLI command that opens a local web UI for running SQL against your ghost databases.

Demo

https://zoom.us/clips/share/UE3nq3DDToyIlXDHptkxEQ

How it's built

The goal was to stand up a working query experience with as little new UI / wire-protocol / driver code as possible. The heavy lifting comes from two existing pieces, stitched together here:

  • @timescale/popsql-query-widget-cdn — the entire query experience (Monaco editor, run controls, results grid, history sidebar). This is the CDN variant of the widget: it lazily loads its DuckDB-WASM result-cache runtime (~73 MiB of duckdb-eh.wasm + browser worker) from cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm the first time a query runs, so those assets don't have to be bundled into the ghost binary.
  • The upstream query backend (Go) — the wire protocol the widget expects: NDJSON columns + Apache Arrow IPC row stream, plus session / cancel / run lifecycle.

Frontend

  • The widget is rendered unmodified inside a thin React + Vite shell. The shell picks a database from a dropdown and feeds the widget the props it already expects (projectId, serviceId, sessionKey, etc.). The built SPA is embedded into the Go binary via //go:embed.
  • A small Zustand store persists three pieces of UI state (selected DB, editor SQL, editor height) by PUTing to a new /api/state endpoint backed by a JSON file in ~/.config/ghost/. localStorage isn't usable because the listen port is random by default and localStorage is keyed by origin.

Backend

Rather than proxy queries through ghost-api, the widget's wire endpoints (/api/executeQuery, /api/arrowResults, sessions, cancel) run in-process, hitting Postgres directly via pgx/v5.

  • internal/serve/dbdriver/ + dbtypes/ are slim, Postgres-only ports of the upstream driver packages (OID-aware scan types, custom receivers for Date / Numeric / JSON, server-side cancellation, error normalization). Multi-driver machinery that only existed for other adapters (Snowflake column-casing, BigQuery metadata, the per-call options struct) has been dropped.
  • internal/serve/arrow.go is a port of the upstream Arrow IPC writer.
  • Results stream end to end. A dedicated query goroutine (streamQuery) scans rows and hands them, one at a time, to the /api/arrowResults handler over a small buffered channel. That handler converts them straight into Arrow IPC record batches and writes them to the wire. Backpressure on the channel keeps memory flat for arbitrarily large result sets — rows are never collected in full — and the batch size adapts in real time (small first batch for fast time-to-first-byte, growing toward a ~5 MiB target). This preserves the upstream streaming design.
  • Multi-statement runs. The widget's worker pre-splits the editor text into individual statements. The server runs every statement except the last fire-and-forget against the same connection (so TEMP tables and other session state persist), then streams the final statement's result set. Iterating per-statement works around pgx-stdlib only exposing the first result set of a multi-statement Query.
  • Credentials are refreshed per-request via the same common.App.Load pattern the MCP server uses, so a long-running serve session doesn't get stuck with an expired OAuth token.
  • Like ghost mcp, the server logs diagnostics (e.g. connection-close errors) to stderr via slog.

What's actually new in this PR

  • The ghost serve Cobra command, the React shell, the Zustand store, and the state file.
  • The bootstrap / databases passthrough endpoints.
  • The two-handler streaming wire protocol (executeQuery NDJSON + concurrent arrowResults Arrow stream), the per-statement execution loop, the simple session manager, and the Cmd+Enter selection preservation plugin.
  • Respects the global read_only config when executing queries (same as ghost sql/psql/MCP), so a read-only user can't issue writes through the web UI.
  • Widget-facing handlers return structured JSON errors so the widget can surface the message to the user.

Binary size

Each platform binary grows by ~9.5 MiB over v0.18.0 (the embedded SPA is ~6.4 MiB; the rest is the Go side picking up workers / dbdriver / arrow / etc.). DuckDB-WASM itself is loaded from CDN at query time and is not part of the binary. See the size-comparison comment for the full per-platform table.

Trying it out

ghost serve

Picks a free port, opens your browser, lets you run SQL. --no-open skips the browser, --port N pins a port.

@murrayju
Copy link
Copy Markdown
Member Author

murrayju commented Jun 2, 2026

In case this is useful for review, I asked Claude to analyze the differences from the source popsql-query code and what is ported into this PR. Here's the resulting analysis:

image

@murrayju
Copy link
Copy Markdown
Member Author

murrayju commented Jun 2, 2026

Stats around binary size change
image

murrayju added 26 commits June 3, 2026 14:21
Step 1 of the ghost serve plan: a minimal Cobra command (gated behind
GHOST_EXPERIMENTAL) that boots an in-process net/http server on a
loopback interface, embeds a Vite/React placeholder via embed.FS, and
proxies a single read-only ghost-api passthrough at /api/databases.

What is here:

- internal/cmd/serve.go - Cobra command with --port / --host / --no-open.
- internal/serve/server.go - net/http mux, listener with OS-picked port,
  graceful shutdown on cmd.Context() cancel.
- internal/serve/assets.go - embed.FS-backed asset handler with SPA
  fallback (no-extension -> index.html), Vite-style immutable cache for
  /assets/*, no-cache for index.html, plus a placeholder page when no UI
  bundle has been built.
- internal/serve/bootstrap.go - /api/bootstrap returns projectId+version.
- internal/serve/databases.go - /api/databases thin passthrough to
  ListDatabases.
- web/ - Vite 7 + React 19 + Tailwind v3 + TanStack Query workspace.
  Header + DB picker stub; widget integration deferred to step 2.
- bun - self-bootstrapping wrapper script (pinned to bun-v1.3.11),
  copied from ../ox. No new system dependencies for the build.
- scripts/build-web.sh - runs bun install + bun run build, copies
  web/dist into internal/serve/web/ for the //go:embed directive.
- check - invokes build-web.sh before go install.

Query execution (executeQuery / arrowResults / sessions / cancel) lands
in step 2 alongside the type-mapping port from popsql-query.
- bun does not honor --cwd as a global flag in front of `run`; rewrite
  build-web.sh to cd into web/ instead.
- Pin @timescale/popsql-query-widget to 0.0.0-dev.156 (the latest
  published canary; the package is not yet on a stable version line).
- Commit web/bun.lock and ignore web/tsconfig.tsbuildinfo.

Full Step 1 pipeline now validates end-to-end: scripts/build-web.sh
produces internal/serve/web/{index.html,assets/...} and the rebuilt
Go binary embeds + serves them with correct cache headers and SPA
fallback.
Step 2 of the ghost serve plan: in-process query execution that matches
the popsql-query-widget wire contract, no proxy to ghost-api or the
savannah gateway needed.

What is here:

internal/serve/dbtypes/
  Ported verbatim from popsql-query/internal/types/. Custom scan
  receivers that preserve database-side formatting and special values
  (NaN, +/-Infinity, JSON, bytea hex, plain DATE/TIMESTAMP). GUID
  (MSSQL-only) is dropped.

internal/serve/dbdriver/
  Postgres-only port of popsql-query/internal/driver/. Provides:
  - Driver/baseDriver interfaces and a Postgres adapter backed by
    pgx/v5/stdlib so we get OID-aware ColumnType.ScanType().
  - cancelContext, which wires pgConn.CancelRequest into the parent
    context so query cancellation flows through pg_cancel_backend.
  - NormalizedError shape matching the widget's failure type.
  Drops the SSH tunnel, SSL custom-config, and multi-driver registry
  (ghost only targets Timescale Postgres). Uses SimpleProtocol exec
  mode so user-typed multi-statement SQL with comments works the same
  way ghost sql does.

internal/serve/arrow.go
  Ported from popsql-query/internal/writer/arrow.go. RecordBuilder
  wraps array.RecordBuilder, appends the synthetic
  __popsql_row_num__ column, and embeds the original column
  descriptors in the schema metadata under __popsql_columns__ (both
  contracts the widget's table renderer assumes).

internal/serve/wire.go
  Request/response types matching what @popsql/query-client's
  TimescaleQueryClient sends. The widget puts the SQL in a
  `statements` array (not the legacy `query` field) so we read both.

internal/serve/store.go
  In-memory run + session registries.

internal/serve/connect.go
  Resolves database via ghost-api, runs CheckReady, fetches the
  tsdbadmin password (with the same actionable error if missing), and
  opens a pgx driver against the resulting DSN.

internal/serve/execute.go
  POST /api/executeQuery (one-shot) and POST /api/executeSessionQuery.
  Streams a single NDJSON columns line, blocks on Run.done while
  arrowResults consumes the rows, then emits the success/error
  terminator. Client disconnect cancels the underlying PG query.

internal/serve/arrow_results.go
  POST /api/arrowResults. Streams an Apache Arrow IPC record batch per
  1024 rows, then closes Run.done so executeQuery can finalize.

internal/serve/session.go
  POST /api/createSession / sessionEvents (long-lived NDJSON status
  stream) / closeSession. The widget's session manager retries
  sessionEvents up to 15 times on disconnect, so transient hiccups
  are handled for free.

internal/serve/cancel.go
  POST /api/cancelRun -> pgConn.CancelRequest.

web/src/components/QueryPanel.tsx
  Renders the unmodified popsql QueryWidget against the local server.
  sessionKey is derived from the database ID so changing the picker
  invalidates the session and the underlying PG connection.

web/vite.config.ts
  - copyPopsqlQueryWidgetAssets plugin (ported from web-cloud) emits
    the duckdb / monaco worker + wasm sidecars that the widget loads
    via `new URL(<variable>, import.meta.url)` (Vite's static
    analysis misses them).
  - vite-plugin-node-polyfills shims Buffer / crypto / process /
    stream — same list web-cloud uses with this widget.
  - optimizeDeps.exclude on the widget so its workers resolve their
    sibling chunks.

web/package.json
  Pin React 18.3 (widget calls findDOMNode which is gone in React 19),
  add @timescale/popsql-query-widget@0.0.0-dev.156 and
  vite-plugin-node-polyfills.

End-to-end smoke-tested against a real Timescale Postgres database:
- SELECT 1; renders 1 row in the result table.
- SELECT 1::int, 'hello'::text, '2024-01-01'::date,
  '2024-01-01 12:00:00+00'::timestamptz, 3.14::numeric,
  '{"k":"v"}'::jsonb renders all six columns with the right types.
- Sessions: changing the DB picker invalidates the session and a
  fresh connection opens transparently.
- Promote `ghost serve` to the public command surface (registered
  unconditionally in root.go).
- Update CLAUDE.md to describe internal/serve/ + web/ alongside the
  existing internal/{cmd,mcp,common,...} entries.
- Update README.md Commands table + Usage example.
- Regenerate docs/cli/ from cobra (adds docs/cli/ghost_serve.md and
  the corresponding link in docs/cli/ghost.md).
- Add focused tests:
  * internal/cmd/serve_test.go - asserts the not-logged-in error path
    routes through App.GetClient like every other command.
  * internal/serve/wire_test.go - covers the statements-vs-query
    fallback for executeQueryRequest.SQL() plus the embedded-struct
    decode path used by executeSessionQueryRequest.
  * internal/serve/assets_test.go - exercises the placeholder
    page (fresh checkout, no embedded UI) and, when a real bundle is
    present, the SPA fallback + cache-control headers.
  * internal/serve/store_test.go - runStore add/get/delete, Run
    setError/closeDone idempotence, sessionStore.closeAll.
- Drop the favicon 404 (data: URL placeholder) and add name/aria-label
  to the picker so the only remaining a11y warnings come from inside
  the widget itself.
Both workflows now run scripts/build-web.sh (sourcing the
@timescale/popsql-query-widget from GitHub Packages via the
auto-provisioned secrets.GITHUB_TOKEN) before any go command, so the
embedded bundle in internal/serve/web/ is populated when go build
captures it via //go:embed.

- test.yaml: runs the bundle build before `go mod tidy -diff` /
  `go build` / `go test` so coverage exercises the real asset
  handler (cache headers, SPA fallback, etc.) instead of the
  placeholder fallback.
- release.yaml: runs the bundle build before goreleaser-action so
  every released binary embeds the production-built UI.

The Dockerfile is left unchanged: in release mode it copies a
pre-built binary, and the binary already has the bundle embedded.
For local docker builds the placeholder kicks in as before.
…sult

Before this change, multi-statement input was joined with `; ` and sent
to pgx as a single Query call. pgx's stdlib wrapper only exposes the
first result set through database/sql, so editor content like
`CREATE TEMP TABLE t (...); INSERT ...; SELECT * FROM t` would show
the CREATE TABLE result (empty) instead of the user-relevant rows.

New behavior:
- Server iterates the widget's `statements` array on the same driver
  connection, buffering each result set. Session state (TEMP tables,
  GUCs, search_path, etc.) propagates across statements because
  baseDriver pins a single sql.Conn per driver.
- The displayed result is the last result set that returned columns,
  falling back to the last result set if none had columns (so DDL-only
  runs still show their rowsAffected counter).
- The success NDJSON line carries `executedStatements: N` for any
  caller that needs the count.
- The Vite app shows "Executed N statements" in the toolbar via the
  widget's renderToolbarAppendLeft hook when N > 1, computed from the
  editor text by an SQL-aware counter that skips comments, single- and
  double-quoted regions, and dollar-quoted blocks.
- `runSelection={true}` plus `runButtonLabelWithSelection="Run selection"`
  flips the button label automatically when the user has text selected
  in the editor (widget-native behavior, ported from web-cloud).

Also marks Decimal128-for-NUMERIC as declined in plan.md (the costs
outweigh the UX benefits — see the plan for the full reasoning).
The widget's execute-query action itself does not clear the editor
selection — calling editor.trigger('keyboard', 'execute-query', null)
from a script leaves it intact. But pressing Cmd+Enter through a real
keyboard event somehow collapses the selection to the document start
during the native event flow (Monaco's keybinding service interaction,
suspected), only on this path; the Run button click is fine.

Workaround: install our own editor action with the same keybindings
via QueryPanel's editorPlugins. Monaco prefers the later-registered
action, so ours takes priority on Cmd+Enter. We snapshot the
selection, dispatch the widget's execute-query action via
editor.trigger (which doesn't trigger the misbehavior), then restore
the selection if it was non-empty. Net effect: Cmd+Enter now matches
the button-click behavior.
Move the default-selection logic into the databases queryFn so it runs when
the query completes, and update the URL synchronously in the selection-change
handler. Extracts the default-picking logic into pickDefaultDatabaseId.
The bun wrapper is only used to build the web workspace, so it belongs
inside web/ rather than at the repo root. scripts/build-web.sh now calls
./bun from inside web/, and the bun download cache lives at web/download/.
The serve command was capturing the API client and project ID at startup
and reusing them for the lifetime of the process. OAuth access tokens
expire after about an hour, so a long-running server would start returning
401 from /api/databases (and every other API call) once that happened.

Hold a *common.App reference instead and call app.Load(ctx) on each
request that needs API access — same pattern the MCP server uses. Load
re-runs refreshTokenIfNeeded, which renews the OAuth token via its
refresh token and persists the new access token to disk.
The server's port is random by default, so localStorage (keyed by origin)
isn't reliable for restoring UI state. Instead, persist via a small JSON
file at <configDir>/serve-state.json behind GET/PUT /api/state.

What's persisted:
- selected database
- editor height
- editor SQL content
When the selected database is restored from saved state (no ?db= in URL),
also write it back to the URL so the address bar reflects the actual
selection.
Replaces the useEffect+isFirstSave debounce pattern with explicit setter
actions that schedule a debounced PUT inline. URL ?db= sync also lives
on the setter, so the URL and persisted state stay in lockstep.

App still gates render on hydration so QueryWidget mounts with its
final initial values.
The query widget now surfaces the worker's identifyCommands split on
the onQueryComplete callback (via @timescale/popsql-query-widget
0.0.0-dev.157). Use that instead of our own countStatements walk,
and drop the helper + the lastRunSQLRef bookkeeping that fed it.
murrayju added 2 commits June 3, 2026 14:21
Replace @timescale/popsql-query-widget@0.0.0-dev.158 with the new CDN
variant @timescale/popsql-query-widget-cdn@0.0.0-dev.161. The CDN variant
loads DuckDB-WASM (~73 MiB of duckdb-{eh,mvp}.wasm + their browser
workers) from cdn.jsdelivr.net at runtime instead of bundling them.

Net effect on the embedded ghost binary: internal/serve/web/ shrinks
from ~83 MiB to ~6 MiB, so each platform binary gains only ~9 MiB over
v0.18.0 instead of the ~85 MiB the original PR added.

Drop the popsqlQueryWidgetAssets() Vite plugin (the manual wasm copy +
mvp pruning it used to perform is no longer needed: the new widget
ships no wasm sidecars at all).
@murrayju
Copy link
Copy Markdown
Member Author

murrayju commented Jun 3, 2026

Binary size impact: v0.18.0 vs current PR head

Built every GOOS/GOARCH combo from .goreleaser.yaml with the same CGO_ENABLED=0 -trimpath -ldflags="-s -w …" settings goreleaser uses for releases.

Platform v0.18.0 PR #22 Δ Δ %
darwin/amd64 18.63 MiB 28.45 MiB +9.82 MiB +52.7%
darwin/arm64 17.48 MiB 27.03 MiB +9.55 MiB +54.6%
linux/386 17.41 MiB 26.84 MiB +9.43 MiB +54.2%
linux/amd64 18.49 MiB 28.20 MiB +9.71 MiB +52.5%
linux/arm64 17.25 MiB 26.69 MiB +9.44 MiB +54.7%
windows/386 17.74 MiB 27.26 MiB +9.52 MiB +53.7%
windows/amd64 18.85 MiB 28.66 MiB +9.81 MiB +52.1%
windows/arm64 17.33 MiB 26.76 MiB +9.42 MiB +54.4%
TOTAL 143.18 MiB 219.89 MiB +76.71 MiB +53.6%

Each binary gains ~9.5 MiB of payload — almost entirely the //go:embed-ed internal/serve/web/ SPA (~6.4 MiB on disk, dominated by results.worker-*.js and editor.worker-*.js plus the React + Monaco main chunk). The widget's DuckDB-WASM runtime (~73 MiB) is not bundled — it loads on demand from cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm the first time a query is run.

@murrayju murrayju marked this pull request as ready for review June 3, 2026 18:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new ghost serve CLI subcommand that runs a local HTTP server and serves an embedded React SPA for executing SQL against Ghost Postgres databases via the PopSQL query widget wire protocol (NDJSON + Arrow IPC).

Changes:

  • Introduces internal/serve/ HTTP server with wire-protocol handlers, session/run stores, Postgres driver wrapper, and Arrow IPC encoding.
  • Adds a new web/ Vite+React app (Tailwind/Zustand/React Query) embedded into the Go binary via //go:embed, plus build scripts.
  • Updates docs, repo tooling, and CI workflows to build the web bundle before Go builds/tests/releases.

Reviewed changes

Copilot reviewed 54 out of 58 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
web/vite.config.ts Vite dev proxy + polyfills config
web/tsconfig.json TS compiler configuration
web/tailwind.config.ts Tailwind content/theme config
web/src/styles.css Tailwind directives
web/src/store.ts UI state store + persistence PUT
web/src/main.tsx SPA entry + React Query provider
web/src/components/QueryPanel.tsx PopSQL widget wrapper panel
web/src/app.tsx Bootstrap/databases fetching + layout
web/postcss.config.cjs PostCSS Tailwind/autoprefixer
web/package.json Web deps + bun scripts
web/index.html SPA HTML shell
web/bun Self-bootstrapping bun wrapper
web/biome.json Biome formatter/linter rules
web/.npmrc.example GitHub Packages auth template
web/.gitignore Web build/artifact ignores
scripts/build-web.sh Build SPA and stage for embed
README.md Mentions ghost serve command
plan.md Design/architecture plan document
internal/serve/wire.go Widget request/response types
internal/serve/wire_test.go Wire type tests
internal/serve/web/.gitkeep Embed dir placeholder
internal/serve/store.go In-memory run/session stores
internal/serve/store_test.go Store tests
internal/serve/state.go Persisted UI state file + handlers
internal/serve/session.go Session lifecycle endpoints
internal/serve/server.go HTTP server + routing
internal/serve/execute.go Query execution + NDJSON stream
internal/serve/execute_test.go Result-set selection tests
internal/serve/dbtypes/types.go Custom scan type registry
internal/serve/dbtypes/numeric.go NUMERIC JSON marshaling
internal/serve/dbtypes/json.go Raw JSON scan/marshal
internal/serve/dbtypes/date.go Date/time scan receivers
internal/serve/dbtypes/binary.go BYTEA hex scan receiver
internal/serve/dbdriver/rows.go Rows wrapper + column metadata
internal/serve/dbdriver/postgres.go Postgres driver + cancel/error normalize
internal/serve/dbdriver/driver.go Base driver abstractions
internal/serve/dbdriver/cancel.go Server-side cancel context helper
internal/serve/dbdriver/api.go Driver API + wire error types
internal/serve/databases.go /api/databases passthrough
internal/serve/connect.go Resolve DB + password + connect
internal/serve/cancel.go /api/cancelRun handler
internal/serve/bootstrap.go /api/bootstrap handler
internal/serve/assets.go Embedded SPA asset handler
internal/serve/assets_test.go Asset handler tests
internal/serve/arrow.go Arrow schema + record building
internal/serve/arrow_results.go Arrow IPC streaming endpoint
internal/cmd/serve.go New ghost serve cobra command
internal/cmd/serve_test.go Basic serve command test
internal/cmd/root.go Registers serve command
go.mod Adds Arrow + UUID deps
go.sum Dependency lock updates
docs/cli/ghost.md Adds serve to CLI index
docs/cli/ghost_serve.md Generated serve command docs
CLAUDE.md Documents new serve/web structure
check Runs web build before Go tooling
.gitignore Ignores web/build + embed artifacts
.github/workflows/test.yaml Builds web bundle in CI tests
.github/workflows/release.yaml Builds web bundle in releases

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/serve/dbtypes/date.go
Comment thread internal/serve/dbtypes/date.go
Comment thread internal/serve/dbtypes/date.go Outdated
Comment on lines +12 to +13
case nil:
d = nil
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude agreed that this was wrong, and I'm inclined to agree as well. @nathanjcochran would appreciate your thoughts, and whether to also apply to popsql-query. I'm guessing this code path is never exercised.

02e3b71

Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's wrong. But I think you're right that this code path is never actually exercised.

The types we scan into are always pointers (see here), and when we call reflect.New() here to instantiate a value of that type, it returns a pointer to it. So we end up with pointers to pointers, which are what get passed to Scan() here. It's not well documented, but I think that if Scan is scanning NULL into a **T, it just sets the inner pointer to nil, and doesn't actually call the *T's custom Scan(src any) method. At least, I'm pretty sure that's the case, because our other custom types don't even handle nil (e.g. see here). So really, not handling nil at all is probably the correct approach. I'll make that change.

Comment thread internal/serve/state.go Outdated
Comment thread internal/cmd/serve.go
Comment thread web/bun
Comment thread internal/serve/state.go
Comment thread internal/cmd/serve_test.go Outdated
murrayju and others added 8 commits June 3, 2026 14:53
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Justin Murray <justin@murrayju.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Justin Murray <justin@murrayju.com>
Signed-off-by: Justin Murray <justin@tigerdata.com>
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really finished reviewing, but got through most of the popsql-query stuff and figured I'd leave some comments. The main things that are surprising to me are the things from popsql-query that were thrown out/omitted in favor of less-ideal designs (such as the fact that we're buffering all rows in memory now instead of streaming them to the caller, and the fact that the arrow record batch size is static now instead of dynamically adjusted in real-time 😕). I'd say it doesn't really matter much, but given that the original code we're copying already has (much) better solutions for these things, I'm not sure why we'd throw them out...

I also flagged a lot of things that are probably unnecessary now, but I don't actually have strong feelings on whether we get rid of them. Just figured I'd mention them in case you wanted to clean things up as much as possible. Also happy to take a crack at that later myself.

Comment thread internal/serve/dbdriver/api.go Outdated
Comment on lines +40 to +44
// Metadata is reserved for future use; popsql-query's Metadata is only
// populated by BigQuery's bytes-processed counter.
type Metadata struct {
BytesProcessed int64 `json:"bytesProcessed"`
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can probably just get rid of this, since it was only used by BigQuery

Comment thread internal/serve/dbdriver/api.go Outdated
Comment on lines +16 to +24
// ColumnCase controls how column names are presented to the widget. Today we
// always emit them as-is.
type ColumnCase string

const (
ColumnCaseDefault ColumnCase = ""
ColumnCaseLower ColumnCase = "lower"
ColumnCaseUpper ColumnCase = "upper"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can probably get rid of this. I think it was only used for Snowflake

Comment thread internal/serve/dbdriver/api.go Outdated
Comment on lines +67 to +72
// ErrMultiStatement is returned when the user attempts to run multiple
// statements in a single query. Multi-statement support requires either an
// extended-protocol round trip per statement or pgx's "simple protocol" mode,
// which interpolates parameters client-side. For now we follow popsql-query's
// posture and reject the case.
var ErrMultiStatement = errors.New("cannot run multiple statements in a single query")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to add some color here: popsql-query does support multiple statements now, but it relies on them being parsed by the front-end and sent as an array of statements in the Statements request field. It just can't be done in a single query (which is what this error protects against).

Comment thread internal/serve/dbdriver/cancel.go Outdated
Comment on lines +18 to +20
// The reason for the not-a-child context is so that pgx does not abort the
// query mid-flight on its own — we want a graceful cancel through Postgres,
// so we can still surface a useful error to the client.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite accurate. The real reason is because pgx responds to context cancellation by closing the database connection, which aborts the session, meaning subsequent queries don't happen within the same session context. I forget the exact reason for this pgx behavior, but I think there are old GitHub issues discussing it. In any case, by capturing the context cancellation and handling it ourselves by issuing a "normal" query cancellation via the wire protocol, we can keep the connection open so that subsequent queries operate within the same session.

Comment thread internal/serve/dbdriver/driver.go Outdated
Comment on lines +39 to +43
// QueryArgs is the input to Driver.Query.
type QueryArgs struct {
Query string
ColumnCase ColumnCase
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could probably get rid of this struct entirely and make the Query() function just take a query string parameter instead (I think that's what it originally did, before I had to add support for some other options related to other drivers - but I don't think they're necessarily relevant for Postgres).

Comment thread internal/serve/dbtypes/types.go Outdated
// precision and special values (NaN, +/-Infinity, untyped JSON, hex-encoded
// bytea, plain DATE/TIMESTAMP strings) when reading rows out of database/sql.
//
// Ported from github.com/timescale/popsql-query/internal/types so the Apache
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably shouldn't leave explicit references to popsql-query (an internal private repo) in this public repo. There are a number of references like this in comments scattered throughout.

Comment thread internal/serve/arrow.go Outdated
}

func (rb *RecordBuilder) RecordRowCount() int64 { return rb.recordRowCount }
func (rb *RecordBuilder) TotalRowCount() int64 { return rb.totalRowCount }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function appears to be unused. Not sure why Claude decided to add it.

Comment thread internal/serve/arrow_results.go Outdated
func (s *Server) handleArrowResults(w http.ResponseWriter, r *http.Request) {
var req arrowResultsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

popsql-query used to return well-structured JSON errors, which the front-end knew how to parse. It looks like this is just returning plain text errors now. Why the change?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea, I wasn't consulted 😓. I'm guessing that it didn't really review the popsql-node code all that much, and just decided this was simpler.

Comment thread internal/serve/arrow_results.go Outdated
defer ipcWriter.Close()
defer run.closeDone()

for _, row := range run.bufferedRows {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that it buffers all result rows is memory is a major regression in the design. The original would stream rows over a channel, and backpressure ensured that memory usage remained low. This has the potential to use up lots of memory on the user's machine for large result sets, and also means it will take longer for users to see the first row (since they all get loaded into memory before any get returned to the user). Not sure why the change in design, given that it's just objectively worse now 😕.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah... I'd very much like to preserve the original design.

Comment thread internal/serve/arrow_results.go Outdated
Comment on lines +60 to +65
if rb.RecordRowCount() >= arrowBatchRows {
if err := flushBatch(ipcWriter, rb, w); err != nil {
run.setError(&dbdriver.NormalizedError{Message: err.Error(), Source: "ghost"})
return
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original had much more sophisticated logic for dynamically determining the ideal arrow record batch size (see here). Again, the main benefit was limiting massive memory spikes, but it also helped ensure a fast time-to-first-byte. That all appears to have been thrown out in favor of a simple static batch size 😕.

Comment on lines +28 to +41
run := s.runs.get(req.RunID)
if run == nil {
http.NotFound(w, r)
return
}
// Wait for runQuery to finish buffering. Guard on the request context so a
// stray/duplicate arrowResults POST for a run that errored before ready was
// closed (e.g. the bufErr path in runQuery) doesn't block this handler
// indefinitely.
select {
case <-run.ready:
case <-r.Context().Done():
return
}
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this changed, but it doesn't appear to have the same safety against concurrent callers as the original code.

EDIT: seems to be related to the changes from popsql-query's writer package, and buffering rows in memory, etc.

Comment thread internal/serve/connect.go
return &connectErr{
norm: &dbdriver.NormalizedError{
Message: fmt.Sprintf(format, args...),
Source: "ghost",
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source field usually holds the client type (i.e. postgresql). I know it's a required field on the corresponding popsql NormalizedError type definition, but I honestly don't remember what it's used for, or if stuffing ghost in there would have any adverse effects.

Comment thread internal/serve/execute.go Outdated
Comment on lines +226 to +240
// pickResultSetToSurface picks the result set we display to the widget,
// matching the rule the user asked for: prefer the last result set that
// returned columns; fall back to the last result set if none have columns;
// nil if the slice is empty.
func pickResultSetToSurface(results []bufferedResultSet) *bufferedResultSet {
if len(results) == 0 {
return nil
}
for i := len(results) - 1; i >= 0; i-- {
if len(results[i].columns) > 0 {
return &results[i]
}
}
return &results[len(results)-1]
}
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is probably the reason that all rows are being buffered in memory now. There is no way, in principal, to know which is "the last result set that returned columns" before you've iterated through all the result sets and checked. And that requires buffering them in memory, so you can return whichever ends up being the last one with columns. Presumably, this is something you asked for, and it led Claude to make this change?

Fwiw, the way popsql-query did it was just to throw away the result sets for all statements except the last, and stream the last result set to the client in real-time (whether or not it had columns/rows). I think that's probably the only way to do this that doesn't require either buffering all result sets in memory, or streaming all result sets to the client. And I honestly think it's the more correct behavior anyways, because even if the final statement doesn't return columns/rows, it might still return a command tag that indicates what happened. And without that, I think a lot of users might assume their final statement wasn't executed for some reason.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think you are right. I didn't explicitly ask for it to look for the last statement with columns, I just asked it to wire up support for multiple statements. It came up with that requirement on its own 😓

murrayju added 2 commits June 4, 2026 18:07
The initial port of the query backend into `ghost serve` introduced several
regressions vs. the original upstream code. This restores the original design
where it was changed, and trims only what isn't needed for Postgres-only
support.

Streaming / memory (the important ones):
- Stream rows over a backpressured channel instead of buffering the entire
  result set in memory. A dedicated query goroutine (streamQuery) scans rows
  and hands them to handleArrowResults, which writes them straight to the
  Arrow IPC stream. Memory stays flat for large results and time-to-first-byte
  is fast again. Multi-statement runs execute prior statements fire-and-forget
  on the same connection, then stream the final statement (matching upstream).
- Restore adaptive Arrow record-batch sizing (initial 100 rows, 5 MiB target,
  min 5 / max 10000, 2x growth cap) instead of a static 1024-row batch.
- Revert the Postgres connection to the extended query protocol (drop the
  simple-protocol override, which had downsides and wasn't actually needed).
- Return structured JSON errors from the widget-facing handlers so the
  widget's checkApiError can surface the message (plain text was discarded).
- Guard arrowResults against concurrent consumers (single-reader, like the
  original pipe).

Cleanups / parity:
- Log previously-swallowed Close()/rows errors via a slog logger (like ghost
  mcp), rather than dropping them.
- Remove Postgres-irrelevant adapter code: ColumnCase (Snowflake), Metadata
  (BigQuery), the QueryArgs struct (Query now takes a string), the unused
  TotalRowCount method, and the dead SQL() join helper.
- Remove the unnecessary RuntimeParams nil-check.
- Correct the cancel.go and ErrMultiStatement comments, and drop references to
  private internal repos from the source comments.
- Clarify the timestamptz offset layout: "-07" is Go's signed-offset token
  (the "-" is the sign placeholder), not a double sign. The code is correct.

Tests: rewrite execute_test.go to exercise the streaming path end-to-end with
a fake driver (single + multi-statement, concurrent-consumer guard) and add
arrow_results_test.go for the adaptive batch sizing. Passes go test -race.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants