diff --git a/CHANGELOG.md b/CHANGELOG.md index dc902f06..6144d208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Interactive query playground + live landing-page SDK demo** (`docs/src/lib/playground.ts` (new), `docs/src/components/QueryPlayground.astro` (new), `docs/src/components/HomeQueryDemo.astro` (new), `docs/src/content/docs/playground.mdx` (new), `docs/src/content/docs/index.mdx`, `docs/src/config/sidebar.ts`, `docs/src/styles/global.css`): closes #286. A new **`/playground`** page lets visitors build a structured query against the live `gh_events` table on the public read-only demo at `stats.wavehouse.dev` — column picker, AND-combined filter rows, aggregations + group-by, order/limit, and time range — then **Run** it (or **Watch live** over SSE) entirely in the browser via [`@wavehouse/sdk`](/sdk). As you build, the equivalent SDK chain and the raw `/v1/query` JSON AST update live (copy-pasteable), and the query serializes to a shareable `?q=` URL. The landing-page "Query it like a database. Subscribe to it like a socket." section is now interactive too: the **Query** tab runs a tweakable query and renders rows inline (with an "Open the full playground →" deep link), and the **Live updates** tab opens an SSE tail you can watch events land in; the **Ingest** tab stays illustrative since the demo is read-only. No code is `eval`'d — the structured builder drives a typed `QuerySpec`, so the on-page code box is *generated*, never executed text. A shared runtime (`src/lib/playground.ts`) owns the curated public schema, the SDK client, the spec→builder/code/AST bridge, the results-table renderer, and `?q=` (de)serialization, so the landing demo and the full page never drift. Runs are bounded by a 12s client-side abort on the shared public endpoint, degrade to a stable skeleton without JS, and tear down cleanly across Astro view transitions. - **"Behind a reverse proxy" deployment guide** (`docs/src/content/docs/reverse-proxy.mdx` (new), `docs/src/config/sidebar.ts`, `docs/src/content/docs/deployment.md`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/api.md`, `internal/api/stream.go`, `internal/api/stream_test.go`): closes #241. A new Operations page for the common "WaveHouse behind nginx / Caddy / Cloudflare Tunnel" setup, since several behaviors only matter behind a proxy and weren't documented together. Covers: TLS termination (WaveHouse serves plain HTTP and manages no certs); the request-body size limits and the division of responsibility (WaveHouse ships fixed in-code memory-safety backstops — 1 MiB control / 16 MiB ingest — while the proxy is the tunable *outer* limit, so a missing/loose proxy limit can't OOM the server); Server-Sent Events buffering + idle-timeout tuning (WaveHouse sends one `: connected` comment then **no periodic heartbeat** yet, [#226](https://github.com/Wave-RF/WaveHouse/issues/226), so quiet streams rely on the proxy not closing idle connections and on `EventSource` auto-reconnect); the `?token=` / `since` / `Last-Event-ID` forwarding streams need; `X-Forwarded-For` trust (don't expose `:8080` directly — it's honored, so a direct client could spoof it); and which health paths to expose (`/livez`/`/readyz` internal-optional, `/v1/health` must stay public). Ships full example nginx, Caddy, and Cloudflare-Tunnel configs, and is cross-linked from Deployment, Configuration, and the API reference. One small code change lands with it: the SSE endpoint (`GET /v1/stream`) now sets `X-Accel-Buffering: no` so nginx-class proxies stream events without buffering out of the box (nginx strips the header before the client sees it; Caddy/Cloudflare ignore it). The health-probe guidance is also upgraded from "optional" to a recommendation — keep the bare `/livez`/`/readyz`/`/healthz` paths internal (a public `/readyz` turns each hit into a ClickHouse `Ping`) and expose only `/v1/health` publicly. - **Coverage publishing — a self-hosted Go coverage README badge and GitHub Code Quality PR comments** (`.github/workflows/ci.yml`, `.github/actionlint.yaml` (new), `scripts/cov/main.go`, `scripts/ci/publish-badge.sh` (new), `.testcoverage.yml`, `go.mod`/`go.sum`, `README.md`, `AGENTS.md`, `docs/src/content/docs/development.md`): closes #133, now that the repo is public. Two published surfaces, both **non-gating** — `make cov`'s thresholds stay the only merge gate. (1) **README badge**: a new `cov badge` subcommand renders a [shields.io endpoint](https://shields.io/endpoint) JSON for the merged Go total using the *exact* number `threshold.total` gates (same `.testcoverage.yml` excludes), and a new non-gating `badge` job — the sole holder of `contents:write`, running only on trusted main — publishes it to an orphan `badges` branch via `scripts/ci/publish-badge.sh`, which the README reads over `raw.githubusercontent.com` (unrestricted for a public repo). (2) **PR comments**: the `coverage` job converts the merged Go profile to Cobertura (`go tool gocover-cobertura`, a new pinned Go `tool` dependency, with `-ignore-dirs` mirroring the YAML's global excludes) and uploads it to GitHub Code Quality via `actions/upload-code-coverage` (`code-quality: write`); the `github-code-quality[bot]` posts the aggregate + per-file diff-vs-`main` comment. The upload is `continue-on-error` so this public-preview GitHub feature can never red CI, and fork PRs skip it (no `code-quality` token, per GitHub's own guard). `actionlint` doesn't recognize the preview `code-quality` permission scope yet, so a new `.github/actionlint.yaml` suppresses only that one message. Requires the repo's *Settings → Code quality* enablement for the comments to render. Full design in `.github/workflows/README.md` §"Coverage publishing". diff --git a/docs/src/components/HomeQueryDemo.astro b/docs/src/components/HomeQueryDemo.astro new file mode 100644 index 00000000..aa6bf5aa --- /dev/null +++ b/docs/src/components/HomeQueryDemo.astro @@ -0,0 +1,505 @@ +--- +// The landing "Query it like a database. Subscribe to it like a socket." +// section, made interactive. The three tabs keep their original snippets, but +// Query and Live now run for real against the public read-only demo at +// stats.wavehouse.dev via @wavehouse/sdk (shared runtime in src/lib/playground.ts): +// - Ingest stays illustrative — the public demo is read-only, so the write +// path is shown, not run (boot the stack locally to ingest). +// - Query: tweak the inline controls, watch the generated SDK chain update, +// hit Run to fetch live gh_events rows — or open the full /playground. +// - Live updates: hit Run to open an SSE tail and watch events land. +// Server-rendered snippets keep the section highlighted + complete +// before hydration; the script owns the controls, the live code box, and the +// run/stream wiring with view-transition-safe teardown. See issue #286. +import { Code } from "@astrojs/starlight/components"; + +const ingestCode = `import { createClient } from "@wavehouse/sdk"; + +const wh = createClient({ baseURL: "https://wavehouse.example.com" }); + +// Returns immediately — buffered in the WAL, schema-validated +// at the edge, batch-flushed to ClickHouse behind the scenes. +await wh.from("clicks").insert({ page: "/home", button: "signup" });`; + +const liveCode = `// Historical backfill + real-time stream, deduplicated at the seam. +const lq = wh.from("gh_events") + .selectAll() + .where("event_type", "in", ["star", "fork", "pull_request"]) + .orderBy("event_ts", "desc") + .limit(20) + .liveQuery({ + initial: (result) => render(result.data ?? []), + next: (event) => prepend(event.data), + }); + +// later: lq.close();`; +--- + +
+
+ + + +
+ + {/* Ingest — illustrative (demo is read-only) */} + + + {/* Query — runnable + tweakable */} +
+
+ + + + Open the full playground → +
+
+
+
+

Press Run query to fetch live rows.

+
+
+

+
+ + {/* Live updates — runnable SSE tail */} + + + +
+ + + + diff --git a/docs/src/components/QueryPlayground.astro b/docs/src/components/QueryPlayground.astro new file mode 100644 index 00000000..e78813c2 --- /dev/null +++ b/docs/src/components/QueryPlayground.astro @@ -0,0 +1,971 @@ +--- +// Interactive structured-query builder for the docs /playground page. Drives +// the shared runtime in src/lib/playground.ts: every control edits a typed +// QuerySpec, which renders live to (a) a results table, (b) a copy-pasteable +// @wavehouse/sdk chain, and (c) the raw JSON AST posted to /v1/query. "Run" +// and "Watch live" execute against the public read-only role on +// stats.wavehouse.dev (our GitHub-activity dogfood) — no eval, the code box is +// generated, never executed text. Server-rendered chrome (column checkboxes, +// selects) keeps the panel layout-complete before hydration; the script owns +// the dynamic filter/aggregation rows and all wiring. Closes #286. +import { COLUMNS, TIME_RANGES } from "../lib/playground"; +--- + +
+
+
+ + +
+ + {/* Rows mode — column projection */} +
+ Columns none = all allowed +
+ { + COLUMNS.map((c) => ( + + )) + } +
+
+ + {/* Aggregate mode — group-by + aggregations */} + + + {/* Filters (both modes) */} +
+ Filters combined with AND +
+ +
+ + {/* Order / limit / time range */} +
+ + + +
+ +
+ + + + + +
+
+ +
+
+ + + +
+ +
+
+

Press Run query to fetch live rows.

+
+
+ + +
+ + + + +
+ + + + diff --git a/docs/src/config/sidebar.ts b/docs/src/config/sidebar.ts index a3d294c5..d2edc836 100644 --- a/docs/src/config/sidebar.ts +++ b/docs/src/config/sidebar.ts @@ -9,6 +9,7 @@ export const sidebar: StarlightUserConfig["sidebar"] = [ { label: "Home", link: "/" }, { label: "Getting Started", slug: "getting-started" }, { label: "Why WaveHouse?", slug: "why-wavehouse" }, + { label: "Query playground", slug: "playground", badge: { text: "live", variant: "tip" } }, { label: "Guides", items: [ diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index b10e97b1..388b5abb 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -25,8 +25,9 @@ hero: variant: minimal --- -import { Card, CardGrid, LinkCard, LinkButton, Tabs, TabItem } from '@astrojs/starlight/components'; +import { Card, CardGrid, LinkCard, LinkButton } from '@astrojs/starlight/components'; import HomepageCtaTracking from '../../components/HomepageCtaTracking.astro'; +import HomeQueryDemo from '../../components/HomeQueryDemo.astro';
@@ -104,47 +105,9 @@ If you're building user-facing analytics, **WaveHouse is like Supabase for Click ## Query it like a database. Subscribe to it like a socket. -The zero-dependency [TypeScript SDK](/sdk) wraps the whole surface — typed inserts, a chainable query builder, and live queries that backfill history before streaming: - - - - ```ts - import { createClient } from '@wavehouse/sdk'; - - const wh = createClient({ baseURL: 'https://wavehouse.example.com' }); - - // Returns immediately — buffered in the WAL, schema-validated - // at the edge, batch-flushed to ClickHouse behind the scenes. - await wh.from('clicks').insert({ page: '/home', button: 'signup' }); - ``` - - - ```ts - // Chainable, type-safe, never throws — branch on { data, error }. - const { data, error } = await wh.from('clicks') - .select('page', 'button', 'score') - .where('page', '=', '/home') - .orderBy('received_timestamp', 'desc') - .limit(100); - ``` - - - ```ts - // Historical backfill + real-time stream, deduplicated at the seam. - const lq = wh.from('clicks') - .selectAll() - .where('page', '=', '/home') - .orderBy('received_timestamp', 'desc') - .limit(100) - .liveQuery({ - initial: (result) => setRows(result.data ?? []), - next: (event) => addRow(event.data), - }); - - // later: lq.close(); - ``` - - +The zero-dependency [TypeScript SDK](/sdk) wraps the whole surface — typed inserts, a chainable query builder, and live queries that backfill history before streaming. The **Query** and **Live updates** tabs below are live: tweak the query, hit **Run**, and watch it execute in your browser against a public read-only demo — or open the full [query playground](/playground). + + ## Up in five minutes diff --git a/docs/src/content/docs/playground.mdx b/docs/src/content/docs/playground.mdx new file mode 100644 index 00000000..89623db4 --- /dev/null +++ b/docs/src/content/docs/playground.mdx @@ -0,0 +1,57 @@ +--- +title: Query playground +description: Build and run live WaveHouse queries in your browser against a public read-only demo — the @wavehouse/sdk chain and raw query AST are generated as you go. +tableOfContents: false +--- + +import QueryPlayground from '../../components/QueryPlayground.astro'; + +Build a query with the controls below and hit **Run query**. It executes in your +browser through [`@wavehouse/sdk`](/sdk) against a **public, read-only** WaveHouse +instance at `stats.wavehouse.dev` — our own dogfood deployment that ingests +GitHub activity for [Wave-RF/WaveHouse](https://github.com/Wave-RF/WaveHouse) +into a `gh_events` table. As you build, the equivalent SDK chain and the raw JSON +query AST update live, so you can copy either straight into your own project. + + + +## The `gh_events` table + +The demo role exposes these columns. There's no separate schema call — the +playground knows them, and the real ingest contract lives in +[Wave-RF/WaveHouse-Stats](https://github.com/Wave-RF/WaveHouse-Stats). + +| Column | Type | Meaning | +| --- | --- | --- | +| `event_type` | string | GitHub event family — `push`, `pull_request`, `issues`, `star`, `fork`, `release`, … | +| `action` | string | Event sub-action — `opened`, `closed`, `created`, `published`, … | +| `actor_login` | string | GitHub username that triggered the event | +| `event_ts` | datetime | When the event happened (UTC) | +| `number` | number | Issue / PR number (`0` when not applicable) | +| `title` | string | Issue / PR / release title | +| `ref` | string | Git ref for `push` / `create` / `delete` | +| `repo_name` | string | `owner/repo` the event belongs to | + +## Things to try + +- **Rows mode**: select `actor_login`, `title`, `event_ts`; filter `event_type` `in` `pull_request, issues`; order by `event_ts` desc. +- **Aggregate mode**: group by `event_type`, add a `count` aggregation, order by `total` desc — the live leaderboard of what's happening in the repo. +- **Watch live**: keep a filter on `event_type` `in` `star, fork`, hit **Watch live**, then [star the repo](https://github.com/Wave-RF/WaveHouse) and watch your event land over SSE. + +## Notes & limits + +This is a shared, public endpoint, so a few guardrails apply — the same ones any +WaveHouse operator can configure per [role](/access-control): + +- **Read-only.** The demo role can query and stream but not ingest. To run the + ingest side, boot the stack locally — see [Getting Started](/getting-started). +- **Capped.** Each run is limited to 1000 rows and gives up after 12 seconds in + your browser; the demo role enforces its own server-side row cap on top. + Playground queries are ad-hoc structured reads, not pre-defined + [pipes](/pipes) — but they still share the same in-process read cache, so a + repeated identical query can come straight back from cache (`X-Cache: HIT`). +- **Public data only.** Columns like internal IDs aren't exposed to the demo role. + +Want the full picture of the SDK surface? Start with the +[SDK overview](/sdk), then [Queries](/sdk/queries) and +[Streaming & live queries](/sdk/streaming). diff --git a/docs/src/lib/playground.ts b/docs/src/lib/playground.ts new file mode 100644 index 00000000..4cd8f765 --- /dev/null +++ b/docs/src/lib/playground.ts @@ -0,0 +1,504 @@ +// Shared runtime for the docs query playground — the single source of truth +// behind both the landing "Query it like a database" tabs (HomeQueryDemo.astro) +// and the full /playground builder (QueryPlayground.astro). It owns: +// - the curated public schema of the demo's `gh_events` table, +// - a browser @wavehouse/sdk client bound to the public read-only role, +// - a structured QuerySpec ⇄ SDK-chain / raw-AST / live-results bridge. +// +// Everything here runs in the visitor's browser against stats.wavehouse.dev +// (our own GitHub-activity dogfood, Wave-RF/WaveHouse-Stats) — the same public +// endpoint LiveDemo.astro reads. No eval: the builder UIs drive this typed +// spec, so the "code box" is generated, copy-pasteable SDK, never executed text. + +import type { QueryBuilder, Result, StreamSubscriber, WaveHouseClient } from "@wavehouse/sdk"; +import { createClient } from "@wavehouse/sdk"; + +/** Every demo row is an untyped record (the demo has no generated Database type). */ +type Row = Record; + +/** Build-time overridable so a fork / staging docs build can point elsewhere. */ +export const STATS_BASE_URL: string = + import.meta.env.PUBLIC_WAVEHOUSE_STATS_URL || "https://stats.wavehouse.dev"; + +/** The one table the public demo role exposes. */ +export const STATS_TABLE = "gh_events"; + +/** The timestamp column every time-range filter targets. */ +export const TIME_COLUMN = "event_ts"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +export type ColumnKind = "string" | "number" | "datetime"; + +export interface ColumnDef { + name: string; + kind: ColumnKind; + desc: string; +} + +// The public read-only role can't read /v1/schema (it's an admin surface), so +// the column set is curated here from the role's allowed projection — the same +// fields the gh_events ingest contract exposes publicly. `actor_id` and +// `received_timestamp` are deliberately omitted: the demo role rejects them. +// Keep in sync with the Wave-RF/WaveHouse-Stats producer. +export const COLUMNS: readonly ColumnDef[] = [ + { name: "event_type", kind: "string", desc: "GitHub event family (push, pull_request, star…)" }, + { name: "action", kind: "string", desc: "Event sub-action (opened, closed, created…)" }, + { name: "actor_login", kind: "string", desc: "GitHub username that triggered the event" }, + { name: "event_ts", kind: "datetime", desc: "When the event happened (UTC)" }, + { name: "number", kind: "number", desc: "Issue / PR number (0 when not applicable)" }, + { name: "title", kind: "string", desc: "Issue / PR / release title" }, + { name: "ref", kind: "string", desc: "Git ref for push / create / delete" }, + { name: "repo_name", kind: "string", desc: "owner/repo the event belongs to" }, +]; + +export const COLUMN_NAMES: readonly string[] = COLUMNS.map((c) => c.name); + +export function columnKind(name: string): ColumnKind { + return COLUMNS.find((c) => c.name === name)?.kind ?? "string"; +} + +/** Human-meaningful event types — a curated subset (the table also carries CI noise). */ +export const EVENT_TYPE_SUGGESTIONS: readonly string[] = [ + "push", + "pull_request", + "issues", + "issue_comment", + "pull_request_review", + "star", + "fork", + "release", + "create", + "delete", +]; + +// --------------------------------------------------------------------------- +// Operators, aggregations, time ranges +// --------------------------------------------------------------------------- + +export type FilterOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "in" | "like" | "not_like"; + +export const FILTER_OPS: readonly { op: FilterOp; label: string }[] = [ + { op: "=", label: "= equals" }, + { op: "!=", label: "≠ not equals" }, + { op: ">", label: "> greater" }, + { op: ">=", label: "≥ at least" }, + { op: "<", label: "< less" }, + { op: "<=", label: "≤ at most" }, + { op: "in", label: "in (a, b, …)" }, + { op: "like", label: "like %pattern%" }, + { op: "not_like", label: "not like %pattern%" }, +]; + +const OP_SET = new Set(FILTER_OPS.map((o) => o.op)); + +// SDK operator → wire operator. Mirrors OP_MAP in clients/ts/src/query-builder.ts +// (used only to render the raw-AST preview without a network round-trip; the +// actual request is built by the SDK in applySpec). +const WIRE_OP: Record = { + "=": "eq", + "!=": "neq", + ">": "gt", + ">=": "gte", + "<": "lt", + "<=": "lte", + in: "in", + like: "like", + not_like: "not_like", +}; + +export const AGG_FNS = ["count", "countDistinct", "sum", "avg", "min", "max"] as const; +export type AggFn = (typeof AGG_FNS)[number]; + +export const TIME_RANGES: readonly { label: string; value: string }[] = [ + { label: "Last hour", value: "1h" }, + { label: "Last 24 hours", value: "24h" }, + { label: "Last 7 days", value: "168h" }, + { label: "Last 30 days", value: "720h" }, + { label: "All time", value: "" }, +]; + +// --------------------------------------------------------------------------- +// QuerySpec — the structured query the builders produce +// --------------------------------------------------------------------------- + +export type QueryMode = "rows" | "aggregate"; + +export interface FilterSpec { + column: string; + op: FilterOp; + value: string; +} + +export interface AggSpec { + fn: AggFn; + column: string; + alias: string; +} + +export interface OrderSpec { + column: string; + dir: "asc" | "desc"; +} + +export interface QuerySpec { + mode: QueryMode; + /** Projected columns in "rows" mode; empty = select every allowed column. */ + columns: string[]; + filters: FilterSpec[]; + /** Group-by keys + aggregations, used only in "aggregate" mode. */ + groupBy: string[]; + aggregations: AggSpec[]; + orderBy: OrderSpec[]; + limit: number; + /** time_range.since on event_ts ("" = all time). */ + since: string; +} + +/** The demo role caps rows at 5000; keep the default tight and the ceiling honest. */ +export const MAX_LIMIT = 1000; + +export function emptySpec(): QuerySpec { + return { + mode: "rows", + columns: [], + filters: [], + groupBy: [], + aggregations: [], + orderBy: [], + limit: 50, + since: "168h", + }; +} + +/** A friendly, runnable starting point: recent human activity, newest first. */ +export function exampleSpec(): QuerySpec { + return { + mode: "rows", + columns: ["event_type", "actor_login", "number", "title", "event_ts"], + filters: [{ column: "event_type", op: "in", value: "pull_request, issues, star, release" }], + groupBy: [], + aggregations: [], + orderBy: [{ column: "event_ts", dir: "desc" }], + limit: 25, + since: "720h", + }; +} + +// --------------------------------------------------------------------------- +// Value coercion +// --------------------------------------------------------------------------- + +/** + * Coerce a raw text field into the JSON value the backend expects: a number for + * the numeric column, an array for `in`, a string otherwise. A non-numeric + * value typed against `number` falls through as a string so the SDK surfaces a + * clean server error rather than silently sending NaN. + */ +export function coerceValue(column: string, op: FilterOp, raw: string): unknown { + const numeric = columnKind(column) === "number"; + if (op === "in") { + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return numeric ? parts.map((p) => coerceNumber(p)) : parts; + } + return numeric ? coerceNumber(raw) : raw; +} + +function coerceNumber(raw: string): number | string { + const n = Number(raw); + return raw.trim() !== "" && Number.isFinite(n) ? n : raw; +} + +// --------------------------------------------------------------------------- +// Spec → SDK builder (the one that actually runs) + code/AST previews +// --------------------------------------------------------------------------- + +export function createStatsClient(): WaveHouseClient { + return createClient({ baseURL: STATS_BASE_URL }); +} + +/** Apply a spec to a fresh QueryBuilder — the single path both Run and live use. */ +export function applySpec(wh: WaveHouseClient, spec: QuerySpec): QueryBuilder { + const projected = spec.mode === "aggregate" ? spec.groupBy : spec.columns; + let q = wh.from(STATS_TABLE).select(...projected); + if (spec.mode === "aggregate") { + for (const a of spec.aggregations) { + q = + a.fn === "count" ? q.count(a.column || "*", a.alias) : q.aggregate(a.fn, a.column, a.alias); + } + if (spec.groupBy.length > 0) q = q.groupBy(...spec.groupBy); + } + for (const f of spec.filters) { + if (!f.column) continue; + q = q.where(f.column, f.op, coerceValue(f.column, f.op, f.value)); + } + for (const o of spec.orderBy) q = q.orderBy(o.column, o.dir); + if (spec.since) q = q.timeRange(TIME_COLUMN, spec.since); + if (spec.limit) q = q.limit(spec.limit); + return q; +} + +/** + * Run a spec and return its rows (the Result error arm is surfaced, not thrown). + * Queries are user-authored and ad-hoc (not pre-defined pipes) against a shared + * public endpoint, so the caller passes an AbortSignal to bound a slow run + * client-side. + */ +export async function runSpec( + wh: WaveHouseClient, + spec: QuerySpec, + signal?: AbortSignal, +): Promise> { + return applySpec(wh, spec).fetch({ signal }); +} + +/** Default client-side timeout (ms) for a playground run on the shared demo. */ +export const RUN_TIMEOUT_MS = 12_000; + +/** Subscribe to live events matching a spec's filters (column projection + filters applied client-side). */ +export function streamSpec( + wh: WaveHouseClient, + spec: QuerySpec, + subscriber: StreamSubscriber, +): () => void { + const projected = spec.mode === "aggregate" ? spec.groupBy : spec.columns; + let q = wh.from(STATS_TABLE).select(...projected); + for (const f of spec.filters) { + if (f.column) q = q.where(f.column, f.op, coerceValue(f.column, f.op, f.value)); + } + const controller = q.stream(); + const unsub = controller.subscribe(subscriber); + return () => { + unsub(); + controller.close(); + }; +} + +function quote(s: string): string { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function literal(v: unknown): string { + if (Array.isArray(v)) return `[${v.map(literal).join(", ")}]`; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return quote(String(v)); +} + +function aggCall(a: AggSpec): string { + if (a.fn === "count") return `.count(${quote(a.column || "*")}, ${quote(a.alias)})`; + if (a.fn === "countDistinct") return `.countDistinct(${quote(a.column)}, ${quote(a.alias)})`; + return `.${a.fn}(${quote(a.column)}, ${quote(a.alias)})`; +} + +/** Render the spec as a copy-pasteable @wavehouse/sdk chain. */ +export function specToCode(spec: QuerySpec): string { + const chain: string[] = [`.from(${quote(STATS_TABLE)})`]; + if (spec.mode === "aggregate") { + if (spec.groupBy.length > 0) chain.push(`.select(${spec.groupBy.map(quote).join(", ")})`); + for (const a of spec.aggregations) chain.push(aggCall(a)); + if (spec.groupBy.length > 0) chain.push(`.groupBy(${spec.groupBy.map(quote).join(", ")})`); + } else if (spec.columns.length > 0) { + chain.push(`.select(${spec.columns.map(quote).join(", ")})`); + } else { + chain.push(".selectAll()"); + } + for (const f of spec.filters) { + if (!f.column) continue; + chain.push( + `.where(${quote(f.column)}, ${quote(f.op)}, ${literal(coerceValue(f.column, f.op, f.value))})`, + ); + } + for (const o of spec.orderBy) chain.push(`.orderBy(${quote(o.column)}, ${quote(o.dir)})`); + if (spec.since) chain.push(`.timeRange(${quote(TIME_COLUMN)}, ${quote(spec.since)})`); + if (spec.limit) chain.push(`.limit(${spec.limit})`); + + return [ + `import { createClient } from "@wavehouse/sdk";`, + "", + `const wh = createClient({ baseURL: ${quote(STATS_BASE_URL)} });`, + "", + "const { data, error } = await wh", + ...chain.map((c) => ` ${c}`), + " ;", + ].join("\n"); +} + +/** + * Render the raw JSON AST the SDK posts to /v1/query. Mirrors the projection + * rules in QueryBuilder._buildAST (clients/ts/src/query-builder.ts) so the + * preview matches the wire body without issuing a request. + */ +export function specToAst(spec: QuerySpec): Record { + const ast: Record = {}; + const hasAggs = spec.mode === "aggregate" && spec.aggregations.length > 0; + const projected = spec.mode === "aggregate" ? spec.groupBy : spec.columns; + if (projected.length > 0) ast.columns = [...projected]; + else if (!hasAggs) ast.select_all = true; + if (hasAggs) { + ast.aggregations = spec.aggregations.map((a) => ({ + fn: a.fn, + column: a.fn === "count" ? a.column || "*" : a.column, + alias: a.alias, + })); + } + const filters = spec.filters + .filter((f) => f.column) + .map((f) => ({ + column: f.column, + op: WIRE_OP[f.op], + value: coerceValue(f.column, f.op, f.value), + })); + if (filters.length > 0) ast.filters = filters; + // group_by tracks applySpec/_buildAST (group-by keys present in aggregate + // mode regardless of whether aggregations were added yet), not hasAggs — + // otherwise the AST preview would drop group_by while the SDK-code preview + // and the real request still carry it. + if (spec.mode === "aggregate" && spec.groupBy.length > 0) ast.group_by = [...spec.groupBy]; + if (spec.orderBy.length > 0) + ast.order_by = spec.orderBy.map((o) => ({ column: o.column, dir: o.dir })); + ast.limit = spec.limit || MAX_LIMIT; + if (spec.since) ast.time_range = { column: TIME_COLUMN, since: spec.since }; + return ast; +} + +// --------------------------------------------------------------------------- +// Results rendering +// --------------------------------------------------------------------------- + +function cellText(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} + +/** Build a results (or an empty-state

) from query rows. */ +export function buildResultsTable(rows: Row[]): HTMLElement { + if (rows.length === 0) { + const p = document.createElement("p"); + p.className = "wh-pg__empty"; + p.textContent = "No rows matched. Loosen the filters or widen the time range."; + return p; + } + // Column order: first row's keys, then any keys later rows introduce. + const cols: string[] = []; + const seen = new Set(); + for (const row of rows) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { + seen.add(k); + cols.push(k); + } + } + } + + const table = document.createElement("table"); + table.className = "wh-pg__table"; + + const thead = table.createTHead(); + const headRow = thead.insertRow(); + for (const c of cols) { + const th = document.createElement("th"); + th.textContent = c; + if (columnKind(c) === "number") th.classList.add("wh-pg__td--num"); + headRow.appendChild(th); + } + + const tbody = table.createTBody(); + for (const row of rows) { + const tr = tbody.insertRow(); + for (const c of cols) { + const td = tr.insertCell(); + const text = cellText(row[c]); + td.textContent = text; + td.title = text; + if (typeof row[c] === "number") td.classList.add("wh-pg__td--num"); + } + } + return table; +} + +// --------------------------------------------------------------------------- +// URL state (structured, never code — safe to share) +// --------------------------------------------------------------------------- + +/** Serialize a spec to a single compact `q` URL parameter. */ +export function encodeSpec(spec: QuerySpec): string { + return encodeURIComponent(JSON.stringify(spec)); +} + +/** + * Parse a `q` parameter back into a spec, hard-validating every field against + * the known column/op/agg sets. Anything unrecognized is dropped — the result + * only ever drives the typed SDK builder, never eval, but staying strict keeps + * a hand-edited URL from producing a confusing builder state. + */ +export function decodeSpec(raw: string | null): QuerySpec | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(decodeURIComponent(raw)); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const p = parsed as Record; + const base = emptySpec(); + + const isCol = (c: unknown): c is string => typeof c === "string" && COLUMN_NAMES.includes(c); + const cols = (v: unknown): string[] => (Array.isArray(v) ? v.filter(isCol) : []); + + const spec: QuerySpec = { + mode: p.mode === "aggregate" ? "aggregate" : "rows", + columns: cols(p.columns), + groupBy: cols(p.groupBy), + filters: Array.isArray(p.filters) + ? p.filters + .filter( + (f): f is FilterSpec => + typeof f === "object" && + f !== null && + isCol((f as FilterSpec).column) && + OP_SET.has((f as FilterSpec).op) && + typeof (f as FilterSpec).value === "string", + ) + .map((f) => ({ column: f.column, op: f.op, value: f.value })) + : [], + aggregations: Array.isArray(p.aggregations) + ? (p.aggregations as unknown[]) + .filter( + (a): a is AggSpec => + typeof a === "object" && + a !== null && + (AGG_FNS as readonly string[]).includes((a as AggSpec).fn) && + typeof (a as AggSpec).alias === "string", + ) + .map((a) => ({ + fn: a.fn, + column: typeof a.column === "string" ? a.column : "", + alias: a.alias, + })) + : [], + orderBy: Array.isArray(p.orderBy) + ? p.orderBy + .filter( + (o): o is OrderSpec => + typeof o === "object" && o !== null && isCol((o as OrderSpec).column), + ) + .map((o) => ({ column: o.column, dir: o.dir === "desc" ? "desc" : "asc" })) + : [], + limit: + typeof p.limit === "number" && Number.isFinite(p.limit) + ? Math.min(MAX_LIMIT, Math.max(1, Math.floor(p.limit))) + : base.limit, + since: + typeof p.since === "string" && TIME_RANGES.some((t) => t.value === p.since) + ? p.since + : base.since, + }; + return spec; +} diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index dbd000df..8d03b1ad 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -2000,3 +2000,55 @@ body:has(.wh-404) .content-panel:has(> .sl-container > h1) { transition: none !important; } } + +/* Query-playground results table — emitted at runtime by buildResultsTable() + in src/lib/playground.ts and rendered both on the /playground page + (QueryPlayground.astro) and in the landing query demo (HomeQueryDemo.astro), + so it lives here (site-wide) rather than in either component's is:global + block, which would only load on its own page. */ +.wh-pg__table { + width: 100%; + border-collapse: collapse; + font-family: var(--sl-font-mono); + font-size: 0.74rem; +} +.wh-pg__table th, +.wh-pg__table td { + text-align: left; + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--wh-code-border); + white-space: nowrap; + max-width: 20rem; + overflow: hidden; + text-overflow: ellipsis; +} +.wh-pg__table thead th { + position: sticky; + top: 0; + background: color-mix(in oklab, var(--wh-code-bg) 85%, var(--wh-surface)); + color: var(--wh-accent-text); + font-weight: 700; + z-index: 1; +} +.wh-pg__table tbody tr:hover td { + background: color-mix(in oklab, var(--wh-accent) 7%, transparent); +} +.wh-pg__td--num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.wh-pg__empty, +.wh-pg__error { + margin: 0; + padding: 1.5rem 1rem; + text-align: center; + color: var(--wh-ink-subtle); + font-size: 0.82rem; +} +.wh-pg__error { + color: var(--wh-rose); + font-family: var(--sl-font-mono); + font-size: 0.76rem; + white-space: pre-wrap; + text-align: left; +}