Open with AI
Development
Everything you need to build, test, lint, and contribute to WaveHouse — from first-clone to hot-reload dev server to full end-to-end SDK tests. If you’re only trying the product, start with the Getting Started guide instead.
Prerequisites
Section titled “Prerequisites”You need these on your PATH before any make recipe will work end-to-end:
| Tool | Required version | Why | Install |
|---|---|---|---|
| Go | 1.26+ (matches go.mod) | Compiles cmd/wavehouse; also runs the pinned tool deps (gotestsum, gofumpt, goimports, govulncheck, deadcode, gsa, goda) via go tool | go.dev/dl |
| GNU Make | 4.0+ | The Makefile uses --output-sync=target (Make 4 only) and bash-pinned recipes. macOS ships with BSD Make 3.81, which will not work | macOS: brew install make then use gmake or put $(brew --prefix make)/libexec/gnubin on your PATH. Linux: usually already installed |
| bash | 4+ recommended | Recipes are pinned to bash; the helper scripts under scripts/ use set -euo pipefail and bash arrays | macOS default is bash 3.2 (works for current recipes, but brew install bash is safer); Linux distros ship 4+ |
| Docker (or Podman) | Engine 20.10+ with the Compose v2 plugin (docker compose, no hyphen) | Compose stacks under deployments/compose/; the E2E and integration suites boot ClickHouse via testcontainers (no compose file) | Docker Desktop, colima, or Podman with podman-compose / the podman compose plugin. The testcontainers Go library also honors DOCKER_HOST for rootless Podman setups |
| Node.js | 22 LTS — pinned via .nvmrc at the repo root | Runtime for pnpm and the Vitest suites. Pinned to match CI (setup-node uses 22) and to avoid Node-major surprises; older Vitest versions in this repo were known to crash on Node 26 with a V8 heap-allocation abort | nodejs.org or nvm use / fnm use / volta (all read .nvmrc) |
| pnpm | 11.1+ (pinned via packageManager in the root package.json) | Package manager for the TypeScript SDK, E2E test harness, and docs site (managed as a single pnpm workspace from the repo root); make build-ts, make test-ts, make test-e2e, make build-docs, make dev-docs, make preview-docs all shell out to pnpm | corepack enable && corepack prepare pnpm@11.1.3 --activate (recommended), or npm i -g pnpm |
| git + curl | any recent | git for source + version metadata in builds; curl is used by the Makefile to fetch the pinned golangci-lint binary into .bin/ | usually preinstalled |
Auto-installed by make tools
Section titled “Auto-installed by make tools”Run make tools once after cloning to populate everything that doesn’t have to be on your PATH:
golangci-lintv2.11.4 → installed to.bin/<os>_<arch>/(version-pinned in the Makefile; bumping the version triggers a reinstall). Not ingo.modbecause its dependency tree conflicts with the main module.airv1.65.1 → installed to.bin/<os>_<arch>/viago install; used bymake devfor hot-reload. Same exclusion principle asgolangci-lint— air’s transitive deps (Hugo, Sass libs) would bloatgo.sum.- Go
tooldeps (gotestsum,gofumpt,goimports,govulncheck,go-test-coverage,gocover-cobertura,deadcode,gsa,goda) — pinned ingo.modvia nativetooldirectives (Go 1.24+), invoked withgo tool <name>.make toolsrunsgo mod downloadso they’re cached; they compile lazily on first invocation. - pnpm deps for
clients/ts/,tests/e2e/sdk/, anddocs/(viapnpm install --frozen-lockfile).make toolsruns only the pnpm install; the Playwright Chromium binary (~130 MB) is fetched on-demand bymake build-docs/make dev-docsvia the internalinstall-playwright-docstarget, so Go-only contributors don’t pay the download cost. When you do hitbuild-docs/dev-docs, Chromium is required by two parts of the docs build:rehype-mermaid(SVG diagram rendering) and thediagram-pngintegration (docs/src/integrations/diagram-png.mjs), which rasterizes each diagram to light/dark PNGs (a solid surface-card variant plus a transparent-background variant for slide decks) atastro:build:donefor the Copy/Download buttons in the zoom lightbox. Both reuse the same Playwright Chromium, as does the manualdocs/scripts/screenshot.mjsQA helper.starlight-links-validatorruns underbuild-docs/ CI only — thedev-docswatch loop skips it so a mid-edit dangling link doesn’t fail every rebuild (CI still enforces link validity before merge; runDOCS_WATCH_STRICT=1 make dev-docsto keep the validator on locally). The--with-depsflag (which apt-installs Chromium’s system libraries:libnspr4,libnss3, etc.) is only added when$CIis set, so contributor laptops don’t get an unexpectedsudoprompt. On Linux dev machines without those libs already present, runpnpm exec playwright install-deps chromiumonce manually. The docs site is a pnpm workspace package (wavehouse-docs); the root Makefile drives it directly viapnpm --filter(no sub-Makefile) — the*-docstargets show up inmake help. It is also a real@wavehouse/sdkconsumer (the landing page’s live demo imports the workspace package), socheck-docs/build-docs/dev-docsbuild the SDK first viabuild-ts; if you drive Astro directly through pnpm (e.g.pnpm --filter wavehouse-docs run start), runmake build-tsonce first so the dep resolves.
Verify your setup
Section titled “Verify your setup”go version # go1.26+make --version # GNU Make 4.xdocker compose versionnode --version # v22.x (matches .nvmrc and CI)pnpm --version # 11.1+If any of those are wrong/missing, the Makefile recipes will fail with confusing errors (e.g. --output-sync is unrecognized on Make 3.81; pnpm: command not found on make test-ts).
Optional but recommended
Section titled “Optional but recommended”| Tool | Why | Install |
|---|---|---|
| Claude Code | The repo ships team-wide configuration in .claude/ — slash commands, subagents, hooks, status line. See Claude Code & AI agents for setup. | brew install --cask claude-code (macOS) or follow official install |
| worktrunk | Wraps git worktree for parallel-agent workflows. Project hooks live in .config/wt.toml (auto-runs make tools on new worktrees, make verify on pre-merge). | brew install worktrunk && wt config shell install |
Quick Start
Section titled “Quick Start”This is the fastest way to get a fully functional local environment:
# 1. Clone and bootstrap (Go modules + golangci-lint + pnpm deps)git clone https://github.com/Wave-RF/WaveHouse.gitcd WaveHousemake tools
# 2. Start ClickHouse (the only external dependency)docker compose -f deployments/compose/dependencies.yaml up -d clickhouse
# 3. Create a table in ClickHousedocker compose -f deployments/compose/dependencies.yaml exec clickhouse \ clickhouse-client --query " CREATE TABLE IF NOT EXISTS clicks ( page String, button String, score Float64, received_timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') ) ENGINE = MergeTree() ORDER BY (page) "
# 4. Run with hot-reload (recompiles on every .go file save)make devWaveHouse is now running at http://localhost:8080 in standalone mode with:
- Embedded NATS (JetStream) — no external MQ needed
- L1 cache only (Ristretto) — no external cache needed
- Fail-closed by default —
config.yamlseeds no policy, so every request is denied until you seed one (see Test the API) - Dedup disabled by default — no Pebble needed
- Schema discovery — automatically finds your ClickHouse tables
Test the API
Section titled “Test the API”make dev is fail-closed — config.yaml seeds no policy, so every request is denied. Point it at the shipped dev policy (the public trial role: read/write clicks/events, no token) and (re)start it:
WH_POLICY_FILE_PATH=deployments/compose/dev-policy.yaml make devThen the tokenless data-plane calls work (create a clicks table first — see the Getting Started walkthrough):
# Ingest an eventcurl -s -X POST "http://localhost:8080/v1/ingest?table=clicks" \ -H "Content-Type: application/json" \ -d '{"page": "/home", "button": "signup", "score": 42.5}'# → {"ok":true}
# Query it back (wait ~5s for the batch flush)curl -s -X POST "http://localhost:8080/v1/query?table=clicks" \ -H "Content-Type: application/json" \ -d '{"columns": ["page", "button", "score"], "limit": 10}'
# Open an SSE stream for a specific table (Ctrl+C to stop)curl -N "http://localhost:8080/v1/stream?table=clicks"
# With gap-fill (replays events since the given timestamp, then switches to live)curl -N "http://localhost:8080/v1/stream?table=clicks&since=2026-03-24T11:00:00Z"
# Liveness / readiness (no auth required)curl http://localhost:8080/livez # → {"status":"ok"}curl http://localhost:8080/readyz # → {"status":"ready"}The admin surface — /v1/schema, /v1/admin/query (raw SQL), /v1/dlq/stats — needs the admin role, which the public trial role doesn’t have. Mint an admin JWT (see Validating tokens below) and pass it:
curl -s http://localhost:8080/v1/schema -H "Authorization: Bearer $TOKEN" | jqcurl -s -X POST http://localhost:8080/v1/admin/query -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"sql": "SELECT * FROM clicks LIMIT 10"}'curl -s http://localhost:8080/v1/dlq/stats -H "Authorization: Bearer $TOKEN"How make dev works
Section titled “How make dev works”make dev is a one-stop convenience target for backend and frontend
development. The recipe is essentially:
dev: deps-up $(AIR) air -c .air.tomldeps-up runs docker compose ... up -d --wait clickhouse, which blocks until the ClickHouse container’s /ping healthcheck flips to healthy. $(AIR) lazily installs air to .bin/<os>_<arch>/ if missing. Then air takes over: it watches cmd/ and internal/ (the .go and .yaml files within them), rebuilds tmp/wavehouse on change, and restarts the binary. Config is not hot-reloaded: make dev runs the binary with WH_CONFIG=.config.local.yaml — a gitignored personal copy seeded once from config.yaml on first run (it won’t re-copy if it already exists). So to change dev config, edit .config.local.yaml (not config.yaml) and restart make dev; air watches neither root file.
air is pinned to a specific version and installed via go install rather than a go.mod tool directive — its transitive deps (Hugo, godartsass, Sass libs) would bloat go.sum for everyone. Same exclusion principle as golangci-lint.
While make dev is running you get:
- WaveHouse on
http://localhost:8080withcors_allowed_origins: ["*"], so a browser-based app on any localhost port can hit the API directly. - A placeholder JWT secret (
change-me-in-production) ships inconfig.yaml, but no policy is seeded — so the stack is fail-closed until you seed one (see Test the API). Override the secret viaWH_AUTH_JWT_SECRET. - ClickHouse on
http://localhost:8123(HTTP) andlocalhost:9000(native protocol), Compose project namewavehouse-devso containers/volumes are namespaced. - Hot reload: editing any
.gofile undercmd/orinternal/triggers a debounced rebuild + restart. Config isn’t hot-reloaded —make devloads.config.local.yaml(a gitignored copy seeded once fromconfig.yaml), so edit.config.local.yamland restart to apply config changes. Air’s stdout/stderr stream live so you see compile errors and server logs in the same terminal.
Dev convenience targets
Section titled “Dev convenience targets”These are the small targets behind make dev — useful directly when you want
to run WaveHouse outside of air (e.g. make build && ./bin/wavehouse), or
when you need to poke at ClickHouse:
| Target | What it does |
|---|---|
make deps-up | Start ClickHouse and block until healthy. Idempotent. |
make deps-down | Stop ClickHouse. Data volume is preserved. |
make deps-logs | docker compose logs -f clickhouse (Ctrl+C detaches; container keeps running). |
make deps-shell | Drop into a clickhouse-client REPL on the running container. |
make deps-wipe | Stop ClickHouse and destroy its data volume. Use when you want a clean schema. |
make clean-all | Nuclear option — every make artifact + dev/E2E containers + volumes + data/. |
Stopping make dev: Ctrl+C stops air, which propagates SIGINT to WaveHouse for a graceful shutdown (NATS JetStream flush, etc.). ClickHouse stays up — re-running make dev is fast because the volume is preserved. Use make deps-down or make deps-wipe to stop ClickHouse explicitly.
Running with observability
Section titled “Running with observability”WaveHouse natively exports standard OpenTelemetry (OTLP) data to 127.0.0.1:4317. Rather than coupling a heavy observability database stack to the dev server, we provide three lightweight, single-container dashboard options.
You run these in a separate terminal tab alongside make dev or your test suites (make test-e2e).
They block the terminal and stream logs; simply press Ctrl+C to instantly tear them down and clean up the container.
| Target | What it does | UI URL |
|---|---|---|
make obs-aspire | Boots the Aspire dashboard. Extremely fast, in-memory only, and requires no login. Ideal for quick trace and log debugging. | http://localhost:18888 |
make obs-grafana | Boots Grafana LGTM (Loki, Grafana, Tempo, Prometheus). Pre-configured to bypass login. Best for advanced UI charting and trace-to-log correlation. | http://localhost:3000 |
make obs-front | Boots OTel-Front for a basic, alternative trace viewer. | http://localhost:8000 |
Typical Workflow:
- Open Tab 1: run
make obs-aspire(UI opens automatically) - Open Tab 2: run
make dev(ormake test-e2e) - View traces, metrics, and logs flowing into the UI instantly. No accounts or auth tokens required.
Using the SDK against make dev
Section titled “Using the SDK against make dev”There’s no bundled playground — point the published @wavehouse/sdk client at your local server (baseURL: "http://localhost:8080"), with the dev policy seeded so requests are authorized:
WH_POLICY_FILE_PATH=deployments/compose/dev-policy.yaml make devSee the SDK guide for the client API and examples.
Frontend devs running their own dev server (Vite, Next.js, etc.) can import { createClient } from '@wavehouse/sdk' and point baseURL: 'http://localhost:8080'; CORS is permissive so cross-origin browser requests just work.
Validating tokens
Section titled “Validating tokens”There is no auth on/off switch — the JWT middleware always runs, but authorization is the policy’s job (a nil/unseeded policy denies every token-based caller, admins included — only the operator key below still reaches the admin surface). To exercise token auth in dev, seed a policy and set a known secret — the dev policy’s admin_role defaults to admin, so a JWT with role: admin unlocks the admin surface:
WH_POLICY_FILE_PATH=deployments/compose/dev-policy.yaml WH_AUTH_JWT_SECRET=my-secret make devThe operator key is a non-JWT alternative: set one and send it in an Authorization: Operator <key> header (or the X-Operator-Key alias). Before any policy is seeded it reaches the admin surface — enough to seed or restore a policy over HTTP (the break-glass path). Once a policy is loaded, the key’s role resolves to admin, so it then has full data-plane access too (pipes, queries, streaming, ingest) — handy for trialing without minting a JWT:
WH_AUTH_OPERATOR_KEY=dev-operator-key make dev# ...then, in another shell — the admin surface works even with no policy seeded:curl -H "Authorization: Operator dev-operator-key" http://localhost:8080/v1/admin/policy# the X-Operator-Key alias works too:curl -H "X-Operator-Key: dev-operator-key" http://localhost:8080/v1/admin/policyThen mint a token (role == the policy admin_role) and call an admin endpoint:
# Using jwt-cli (https://github.com/mike-engel/jwt-cli)export TOKEN=$(jwt encode --secret "my-secret" '{"role": "admin", "exp": 9999999999}')
curl -s -X POST http://localhost:8080/v1/admin/query \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT * FROM clicks LIMIT 10"}'Enable Dedup (Optional)
Section titled “Enable Dedup (Optional)”Set WH_DEDUPE_ENABLED=true and WH_DEDUPE_ID_FIELD=event_id:
WH_DEDUPE_ENABLED=true WH_DEDUPE_ID_FIELD=event_id make devThen include the dedup field in your ingest body:
curl -s -X POST "http://localhost:8080/v1/ingest?table=clicks" \ -H "Content-Type: application/json" \ -d '{"event_id": "550e8400-e29b-41d4-a716-446655440001", "page": "/home"}'# → {"ok":true}
# Same event_id again → deduplicatedcurl -s -X POST "http://localhost:8080/v1/ingest?table=clicks" \ -H "Content-Type: application/json" \ -d '{"event_id": "550e8400-e29b-41d4-a716-446655440001", "page": "/home"}'# → {"duplicate":true}Using an .env File
Section titled “Using an .env File”export WH_CH_ADDR=localhost:9000Then:
source .envgo run ./cmd/wavehouseBuilding
Section titled “Building”# Build the binary to bin/make build
# Build individual binariesgo build -o bin/wavehouse ./cmd/wavehouseRunning Modes at a Glance
Section titled “Running Modes at a Glance”| What you want | Command |
|---|---|
| Hot-reload standalone dev server | make dev |
| Standalone binary (default config) | make build && ./bin/wavehouse |
| Standalone via Docker Compose | docker compose -f deployments/compose/standalone.yaml up -d |
| Infrastructure deps only (ClickHouse) | docker compose -f deployments/compose/dependencies.yaml up -d clickhouse |
Testing
Section titled “Testing”How It Works
Section titled “How It Works”All test commands use gotestsum for pytest-style colored output with pass/fail icons, durations, and a summary. Tool versions are pinned in go.mod via tool directives — the Makefile uses go run so no global installation is needed.
All tests run with Go’s race detector (-race) enabled by default. WaveHouse is highly concurrent (NATS consumers, singleflight caching, SSE hubs) — the race detector catches data races that would panic in production.
Quick Reference
Section titled “Quick Reference”# Prefix any test target with V=1 for verbose output, e.g. `V=1 make test`
# Unit tests (compact output) — alias for `test-unit`make test
# Run specific test(s)make test ARGS="-run TestValidate"
# Go integration tests (requires Docker)make test-integration
# SDK vitest unit tests + coverage + gate against suites.ts-unit# (`make cov` auto-merges ts-unit + ts-e2e — no separate command)make test-ts
# E2E SDK suite against bin/wavehouse-covmake test-e2e
# All four suites sequentially + merged coveragemake test-all
# Full CI: parallel verify + builds (Go + SDK + docs) + test + test-ts,# then test-integration + test-e2e + covmake ci
# Merge available covdata + gate against total thresholdmake covEach test target writes covdata to tmp/coverage/<suite>/data/, renders a textfmt + HTML report, and gates against the per-suite threshold in .testcoverage.yml. make cov merges whichever suites have run and gates against the total.
Verbose output: Use V=1 to switch from compact testdox format to full verbose output. This is a standard Makefile convention (make test -v can’t work because -v is a make flag).
Extra flags: All test targets accept ARGS="..." for additional go test flags (e.g., -run, -count, -timeout).
Note on timing: gotestsum’s DONE ... in X.XXXs reports pure test execution time. The total wall time includes Go compiling all packages — the first run compiles everything (~15s), subsequent runs use the build cache (~1s).
Test Structure
Section titled “Test Structure”| Category | Location | Docker? | Command |
|---|---|---|---|
| Unit tests | internal/*/_test.go | No | make test |
| SDK unit tests | clients/ts/src/**/*.test.ts | No | make test-ts (always includes coverage + gate) |
| Integration tests (Go) | tests/integration/*_test.go | Yes | make test-integration |
| E2E tests (SDK) | tests/e2e/sdk/*.test.ts | Yes | make test-e2e |
- Unit tests live beside the code they test (e.g.,
internal/discovery/discovery_test.go). They use mocks or embedded NATS (in-process, no Docker needed). - Integration tests use the
//go:build integrationbuild tag. ThesetupTestEnvhelper starts a ClickHouse testcontainer, embedded NATS, ingest worker, and a full API router viahttptest.Server. DLQ tests useassert.Eventuallywith a 30-second timeout for the 5-second ingest worker batch window.
Shared test utilities live in internal/testutil/ (e.g., testutil.NopLogger() for silencing embedded NATS output).
Adding New Tests
Section titled “Adding New Tests”- Unit test for
internal/foo/→ createinternal/foo/foo_test.go(same package). - Integration test needing Docker → add a subtest under
tests/integration/(e.g. a new file with//go:build integration). - E2E test via SDK → add a
tests/e2e/sdk/*.test.tsfile. These tests exercise the full pipeline (ingest → ClickHouse → query) through the TypeScript SDK. Run withmake test-e2e. - Test helpers → add to
internal/testutil/(Go) ortests/e2e/sdk/helpers.ts(E2E).
E2E Tests via SDK
Section titled “E2E Tests via SDK”The primary E2E integration test suite lives in tests/e2e/sdk/. It uses the TypeScript SDK as the test harness — every ingest→query test simultaneously validates the full Go backend pipeline and confirms SDK compatibility.
Architecture:
scripts/orchestrator— the E2E entrypoint behindmake test-e2e: it starts a clean ClickHouse testcontainer per run, launches thewavehouse-covbinary on a random free port, runs the SDK suite against it, then SIGINTs the binary to flush coverage. No Compose file is involved. CI runs the exact same path.tests/e2e/sdk/setup.ts— SmartglobalSetupthat probes ports before starting Docker services, so tests work seamlessly whether you started services manually or let the setup do it.tests/e2e/sdk/helpers.ts— JWT factories, typed client constructors, async wait helpers, direct ClickHouse query helper.
Running E2E tests:
# Build the cover binary, install deps, run all E2E testsmake test-e2emake test-e2e builds bin/wavehouse-cov (coverage-instrumented) and runs the orchestrator under scripts/orchestrator/ to wire ClickHouse + the cover binary into the suite. covdata flushes on SIGINT into tmp/coverage/e2e/data/.
If you already have make dev running, the setup detects the healthy WaveHouse on :8080 and skips starting it via Docker — only ClickHouse is started if needed.
Test files (tests/e2e/sdk/*.test.ts): admin, auth, batching, cache, dlq, ingest, ndjson, query, streaming, stress.
Linting
Section titled “Linting”make lintgolangci-lint is installed separately (not in go.mod — its massive dependency tree causes conflicts). If not found, make lint prints install instructions.
Install options:
- macOS:
brew install golangci-lint - Binary: See golangci-lint.run/welcome/install/
- Go install:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
The configuration is in .golangci.yml (v2 format with default: none for explicit control) — that file is the authoritative list of enabled linters. Highlights:
- errcheck — Unchecked error returns
- govet — Suspicious constructs
- staticcheck — Static analysis
- unused — Unused code
- gosec — Security issues
- gocritic — Opinionated style checks
- revive — Extensible linter (replaces golint)
- ineffassign — Ineffective assignments
- misspell — Spelling errors in comments/strings
- bodyclose — Unclosed HTTP response bodies
- noctx — HTTP requests without context
- errorlint — Proper error wrapping checks (
%w,errors.Is/As) - tparallel — Missing
t.Parallel()in test subtests
Formatting (gofumpt — strict superset of gofmt — and goimports import grouping) is enforced through the v2 formatters: section rather than as linters.
Project Structure
Section titled “Project Structure”WaveHouse/├── cmd/ # Binary entry points│ └── wavehouse/ # Standalone all-in-one binary├── internal/ # Private application packages│ ├── api/ # HTTP handlers, router, middleware│ ├── auth/ # JWT/JWKS authentication middleware│ ├── cache/ # L1 (Ristretto) + L2 caching│ ├── chsql/ # Shared ClickHouse SQL helpers (quoting + bind-safety)│ ├── config/ # YAML + env var configuration│ ├── dedupe/ # Optional deduplication (Pebble)│ ├── discovery/ # ClickHouse schema introspection + validation│ ├── ingest/ # Batch buffering + DLQ + Active Sweeper│ ├── mq/ # NATS message queue abstraction│ ├── observability/ # OpenTelemetry pipeline (traces/metrics/logs + Prometheus)│ ├── pipes/ # Named query pipes (NATS KV + .sql bootstrap)│ ├── policy/ # Access control policies (evaluation + NATS KV store)│ ├── query/ # Structured query AST + SQL builder│ └── testutil/ # Shared test helpers and mocks├── tests/ # Integration & E2E tests│ ├── integration/ # Go integration tests (//go:build integration)│ └── e2e/ # E2E suite (orchestrator + ClickHouse testcontainer)│ ├── fixtures/ # ClickHouse DDL + config/policy fixtures│ └── sdk/ # E2E specs driven through the TypeScript SDK (Vitest)├── deployments/│ ├── compose/ # Docker Compose files (standalone.yaml, dependencies.yaml)│ ├── Dockerfile # Runtime image│ └── Dockerfile.goreleaser # Release image (built by GoReleaser)├── docs/ # Documentation├── config.yaml # Default configuration file├── Makefile # Build, test, lint, deploy targets├── .golangci.yml # Linter configuration├── .goreleaser.yaml # Release build configuration└── .air.toml # Hot-reload configurationCode Conventions
Section titled “Code Conventions”- Strict Go formatting: Use
gofumpt(a stricter superset ofgofmt, enforced by CI). Runmake fmtto format. - Interface-first design: Core behaviors (
Cache,Deduplicator,Publisher,Subscriber) are defined as interfaces so implementations can be swapped behind a stable contract. - Package boundaries: The
internal/directory ensures packages are private to this module. - Error handling: Return errors to callers. Use
slogfor structured logging. - Schema-driven: ClickHouse is the schema source of truth. WaveHouse discovers and validates against real table schemas.
Makefile Targets
Section titled “Makefile Targets”Run make help to see all targets. Key ones:
| Target | Description |
|---|---|
make help | Show all targets with descriptions (always the source of truth) |
make tools | Bootstrap: install pinned tools (golangci-lint, air), Go modules, pnpm deps |
| Dev | |
make dev | Hot-reload dev server: ClickHouse via Compose + WaveHouse under air on :8080 |
make deps-up | Start ClickHouse alone (idempotent; blocks until healthy) |
make deps-down | Stop ClickHouse (preserves data volume) |
make deps-logs | Tail ClickHouse logs |
make deps-shell | clickhouse-client REPL on the running container |
make deps-wipe | Stop ClickHouse AND destroy its data volume (DESTRUCTIVE) |
| Observability | |
make obs-aspire | Prebuilt 0-config o11y UI to show WaveHouse metrics, logs, and traces locally |
make obs-grafana | Grafana alternative to aspire, more advanced and complicated |
make obs-front | Custom graphs like grafana, but is simpler and easier to configure like aspire |
| Static checks | |
make fmt | Check formatting across Go (gofumpt) + TS (Biome). Run make fix to apply. |
make tidy | Verify go.mod/go.sum are tidy (run make fix to apply) |
make lint | Run linters across Go (golangci-lint) + TS (Biome) |
make vulncheck | Run govulncheck (V=1 for full call stacks) |
make verify | Repo-wide static checks: Go (tidy + fmt + vulncheck + lint) + TS (Biome + tsc typecheck) (parallel-safe: make -j verify) |
make fix | Auto-fixes across Go (tidy + gofumpt + goimports + lint --fix) and TS (Biome --write) |
| Build | |
make build | Compile wavehouse → bin/wavehouse (debug symbols kept) |
make build-release | Stripped release-style build → bin/wavehouse-release |
make build-cover | Coverage-instrumented build → bin/wavehouse-cov (used by E2E) |
make build-ts | Build TypeScript SDK → clients/ts/dist/ |
| Test | |
make test | Alias for test-unit |
make test-unit | Go unit tests + render coverage + gate suite threshold |
make test-integration | Go integration tests (requires Docker) + coverage gate |
make test-ts | SDK vitest unit tests + v8 coverage + gate against suites.ts-unit (matches Go’s “always coverage” pattern) |
make cov | Merge Go + TS coverage and gate against thresholds. Auto-runs after make test-all and make ci; standalone make cov is “show me the merged numbers without re-running.” Each side skips silently if its data is missing, but make cov fails if both are empty (you ran it before any test target). |
make test-e2e | E2E SDK suite against bin/wavehouse-cov + coverage gate |
make test-all | All four suites sequentially + merged coverage gate |
make ci | Full pipeline: parallel verify + builds + unit/SDK tests, then integration + E2E + cov |
| Analysis (informational, not in CI) | |
make size | Binary size analysis → tmp/analysis/ (text + SVG + interactive HTML) |
make audit-cgo | Audit dependency tree for C files (builds use CGO_ENABLED=0) |
make deadcode | Find unreachable functions |
make dep-cut | Top cuttable deps by transitive weight (LIMIT=N to override) |
make binary-analysis | Combined: size + audit-cgo + deadcode |
| Cleanup (tiered — compose explicitly for partial resets) | |
make clean | Build outputs only (bin/, dist/, clients/ts/dist/, docs/dist/, docs/.dev-dist/) |
make clean-test | Test outputs only (tmp/ — coverage data, logs, NATS state) |
make clean-tools | Installed tools and pnpm deps (.bin/, node_modules/) |
make clean-all | Full reset: above + data/ + Docker volumes |
All test targets accept ARGS="..." for pass-through go test flags. Build targets accept TAGS="..." for Go build tags. V=1 switches to verbose gotestsum output.
Dependency Management
Section titled “Dependency Management”Updating Dependencies
Section titled “Updating Dependencies”go get -u ./... # Update all direct deps to latest minor/patchgo mod tidy # Remove unused, add missingVulnerability Scanning
Section titled “Vulnerability Scanning”govulncheck analyzes your actual call graph — not just the module graph — so it only reports vulnerabilities in code paths you use.
make vulncheckFor a combined security scan, run make verify — it runs vulncheck alongside lint, and gosec is one of the linters enabled in .golangci.yml. This is also what CI runs on every push and pull request.
Dependabot
Section titled “Dependabot”Dependabot is configured in .github/dependabot.yml to open weekly grouped PRs for three update configs:
- Go modules (root) — outdated or vulnerable Go dependencies, commit prefix
deps: - GitHub Actions (root) — outdated action versions tracked against the SHA pins in
ci.yml/release.yml, commit prefixci: - npm — pnpm workspace (root) — covers all three TypeScript packages (the docs site, the SDK, and the E2E tests) in one grouped PR, commit prefix
deps:
PRs are grouped per config to reduce noise. The npm config is pointed at the workspace root (directory: /), not the individual member directories. The repo has a single root pnpm-lock.yaml, and Dependabot only updates a lockfile co-located with the manifest it targets — so a per-member config (the previous setup) bumped a member’s package.json without regenerating the root lockfile, and every such PR then failed CI’s pnpm install --frozen-lockfile with ERR_PNPM_OUTDATED_LOCKFILE. Pointing at the root lets Dependabot read pnpm-workspace.yaml, walk every member, and update the one lockfile.
No auto-merge. Dependabot PRs go through the same merge gate as any other PR — an approval from the @Wave-RF/wavehouse-admins team (the ruleset’s required_reviewers rule) plus the required checks. (The former dependabot-automerge.yml, which auto-approved and merged patch/minor bumps hands-off, was removed — every bump now gets a human admin review.)
Releasing the SDK
Section titled “Releasing the SDK”The TypeScript SDK (@wavehouse/sdk, in clients/ts/) publishes to npm via .github/workflows/publish-npm.yml using OIDC trusted publishing — no NPM_TOKEN. It is independent of the server’s Go/Docker release (release.yml): the v* (server) and sdk-v* (SDK) tag globs are disjoint, so the two never collide. There are two channels:
- Dev snapshots. Every push to
mainpublishes0.0.0-dev.<hash>under thedevdist-tag — but only when the builtdist/actually changed (the version is a hash of the build output, so an unchanged build resolves to an already-published version and is skipped). Install the bleeding edge withnpm install @wavehouse/sdk@dev. - Tagged releases. Pushing a
sdk-vX.Y.Ztag publishes that version and creates a GitHub Release. A stable version goes to thelatestdist-tag; a prerelease (sdk-v0.2.0-rc.1) is published underalpha/beta/rc/next— derived from the suffix — and marked as a GitHub pre-release. The tag must matchclients/ts/package.json’sversion, or the job fails fast.
To cut a release:
# 1. Bump "version" in clients/ts/package.json, commit, and merge to main.# 2. Tag the release commit and push the tag:git tag sdk-v0.1.0git push origin sdk-v0.1.0CI & review automation
Section titled “CI & review automation”This repo has three tiers of AI automation sitting alongside the normal CI checks. Full detail lives in AGENTS.md; this section covers the contributor-facing behavior.
PR title and Conventional Commits
Section titled “PR title and Conventional Commits”PR titles must match Conventional Commits format and stay ≤ 72 characters — the title becomes the squash-merge commit subject. Both rules are enforced by the PR title job under the required CI check (.github/workflows/ci.yml); validate locally with scripts/lint-pr-title.sh "<title>":
<type>(optional-scope)(optional-!): <lowercase subject, no trailing period>Allowed types: feat, fix, docs, refactor, test, chore, ci, deps, build, perf, revert, style.
The ! before : marks a breaking change per Conventional Commits 1.0.0 (e.g., feat!: remove deprecated endpoint, refactor(api)!: rename handlers). Titles are also capped at 72 characters — they become squash-merge commit subjects (Dependabot PRs are exempt from the cap).
If the title doesn’t match, a sticky comment posts on the PR explaining the format (from the PR housekeeping workflow, which mirrors the same script); it auto-removes once the title is fixed. Fixing the title needs no new push — the edit triggers housekeeping, which re-runs the failed PR title job (the job re-reads the title from the API, not the stale event payload).
Required status checks
Section titled “Required status checks”The main branch protection ruleset requires one status check to pass before any PR can merge:
CI— the aggregator job of.github/workflows/ci.yml. The workflow is a job DAG over the same Makefile targets localmake ciruns:lint(make verify),unit(make test-unit test-ts),integration(make test-integration),e2e(make -j test-e2e— builds its own SDK dist + cover binary on a warm cache, runs the suite exactly like a local run),coverage(make covover every suite’s uploaded coverage fragment + threshold gates, like localmake ci’s final step),docs-build(make build-docswhen docs-affecting files changed, uploading the docs dist artifact),PR title(Conventional Commits), and the docs preview/deploy jobs. The aggregator fails if any job failed or was canceled and treats skipped jobs as passing — docs-only PRs skip the Go test suites by design, and fork PRs run everything except the (secret-bearing) docs deploys. Every run’s Summary page gets a per-job wall-clock table from the non-gatingTiming summaryjob. The full architecture — DAG diagram, design invariants, cache policy, how to add a job — lives in.github/workflows/README.md.
The PR housekeeping workflow still runs on every PR (labels + the title explainer comment) but is no longer a required check.
The ruleset also requires an approval from the @Wave-RF/wavehouse-admins team (the required_reviewers rule — this is what mandates an admin sign-off, replacing the old Admin approval status-check workflow), plus 1 approving review, approval of the most recent push by someone other than its author, resolution of all review threads, linear history, no branch deletion, no force-push, and squash-merge only. Repository admins may bypass these requirements when merging their own PR (e.g. a trivial .github change) but still cannot push directly to main.
Approved, green PRs land through a merge queue (“Merge when ready”): the queue re-runs the required CI check against the PR merged with current main (a merge_group event — the CI workflow runs the full test suite for these) and fast-forwards only on green. That integration re-test replaces the old “branch is out-of-date with the base branch” requirement — queued PRs don’t need manual branch updates, and the queue never pushes to the PR branch.
Dependabot PRs go through the same admin review as any other PR — there is no auto-merge (see the Dependabot section above).
Merge behavior
Section titled “Merge behavior”Squash-only merges. The PR title becomes the commit subject (with (#NN) appended automatically), the PR body becomes the commit message. Keep PR bodies tight — they land in git log on main. The PR template gives the right shape (Summary / Test plan / Related Issues).
Include Closes #NN in the PR body to auto-close the related issue on merge. Alternatively, link the issue in the sidebar’s Development section — that triggers auto-close even without the keyword.
Auto-merge is enabled repo-wide: click “Enable auto-merge (squash)” on a PR and it merges once checks + approvals land.
AI reviewers
Section titled “AI reviewers”Advisory PR review comes from marketplace apps configured at the org/repo level:
- CodeRabbit — automated PR review; auto-reviews on open + push, re-trigger with
@coderabbitai review. - Copilot — tied to individual reviewer subscriptions; shows up on PRs where a maintainer with Copilot Pro is listed as a reviewer.
Both are advisory — the ruleset’s required_reviewers rule (an @Wave-RF/wavehouse-admins approval) plus its thread-resolution / linear-history / required-check rules are the actual merge-gate.
Reviewer assignment and the Task Board
Section titled “Reviewer assignment and the Task Board”Reviewer assignment is GitHub-native, and so is the board:
- Reviewer assignment: the
main branch protectionruleset’srequired_reviewersrule requests the@Wave-RF/wavehouse-adminsteam on every PR, and the team’s code-review assignment (configured on the team) auto-assigns and load-balances a specific member. No workflow is involved;dismiss_stale_reviews_on_pushclears approvals on new commits, and GitHub re-requests per its own rules. - Merge gate: the ruleset’s
required_reviewersrule requires anAPPROVEDreview from the@Wave-RF/wavehouse-adminsteam; it also adds review-thread resolution, linear history, and squash-only. Auto-merge (squash) takes over once checks and approvals land. - Task Board (Projects v2, project #7): card placement and status are handled by GitHub-native Projects v2 automation configured in the project UI — there is no workflow-driven board state machine. Priority lives on the board’s
Priorityfield (set during issue triage, below).
Dependabot PRs go through the same admin review as any other PR — there is no auto-merge (see the Dependabot section above).
Invoking bots manually
Section titled “Invoking bots manually”- CodeRabbit: comment
@coderabbitai reviewto re-trigger a review, or@coderabbitai <question>to ask it something. Works in both top-level and inline review comments. - Copilot: the re-request-review button on the PR page sends a fresh request.
Review-response expectations
Section titled “Review-response expectations”Every review comment (human or AI) must get a substantive reply before merge — not “fixed” alone. The ruleset’s required_review_thread_resolution: true means unresolved conversations literally block merge. Agents working on PRs follow the pattern documented in AGENTS.md §“Review Response”: accept / push back / defer, reply with detail, resolve when settled.
When pushing back on a bot’s suggestion, end the reply with the bot’s mention (e.g. @coderabbitai) to invite a counter-reply so the dialog actually loops.
Issue triage
Section titled “Issue triage”.github/workflows/triage.yml classifies new and edited issues via GitHub Models (gpt-4o-mini) and applies:
area/*labels based on the issue body (areas pulled dynamically from thearea/*repo labels — adding a newarea/foolabel with a description is all you need; no workflow edit)securityif the model flags a security concernbreaking-changeif the model flags a public-API break- Priority on the Task Board project #7 via the board’s
Priorityfield (requiresPROJECT_BOARD_TOKENsecret — labels apply with or without it)
Auto-labeling PRs
Section titled “Auto-labeling PRs”The PR housekeeping workflow (.github/workflows/housekeeping.yml) runs actions/labeler with .github/labeler.yml to apply area/*, dependencies, github_actions, go, and documentation labels to PRs based on the files they change. Sync-mode: labels follow the current changed-file set.
When adding a new internal/<pkg>/ package
Section titled “When adding a new internal/<pkg>/ package”Follow the checklist in AGENTS.md §“Common Tasks / Adding a new internal package” — the automation-relevant steps are:
- Create a matching
area/<pkg>repo label with a meaningful description (triage reads the description as the classifier’s per-area hint). - Add the path → label mapping to
.github/labeler.ymlso PRs touching the new package get auto-labeled.
Triage picks up the new label automatically; no workflow edit needed.