# SDK Reference & CLI

> **Section:** [TypeScript SDK](https://wavehouse.dev/sdk.md)
> **Related:** [SDK Admin & System](https://wavehouse.dev/sdk/admin.md) · [SDK Pipes](https://wavehouse.dev/sdk/pipes.md) · [SDK Queries](https://wavehouse.dev/sdk/queries.md) · [SDK Streaming & Live Queries](https://wavehouse.dev/sdk/streaming.md)
> **Also:** [HTML version](https://wavehouse.dev/sdk/reference) · [Docs index](https://wavehouse.dev/llms.txt)

---
Cross-cutting reference for `@wavehouse/sdk`: cancellation, the error model behind every [`Result<T>`](/sdk#result-type), the complete API tree at a glance, and the tooling that ships in the package.

## AbortController Support

All async operations accept an `AbortSignal` for cancellation:

```ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5s timeout

const { data, error } = await wh.from('clicks').fetch({ signal: controller.signal });
if (error?.code === 'ABORTED') {
  console.log('Request timed out');
}
```

---

## Error Handling

The SDK **never throws** for anything the server returns — all API errors come back in `Result.error`. It does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR` to the subscriber's `error` callback — see [Serving under a path prefix](/sdk#serving-under-a-path-prefix)), `.stream()` / `.liveQuery()` in a runtime with no global `fetch` and no `options.fetch` (see [Runtime support](/sdk#runtime-support)), and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and on a stream is reported as a retryable `SSE_AUTH_ERROR`. One more exception escapes an SDK call synchronously, though it is yours rather than ours: your own `status` handler throwing on the first `.subscribe()` or `.liveQuery()`, described under *If your own callback throws* below.

`code` and `retryable` are the server's own when its error body carries them — a failed ClickHouse query does, with codes like `clickhouse.rejected` and `clickhouse.unavailable` ([the full list](/api#clickhouse-errors-on-the-query-paths)). Otherwise `code` is `HTTP_<status>` and a `5xx` is retryable. One exception to the table below: a [pipe that writes](/pipes#pipes-that-write) answers every ClickHouse failure `retryable: false` with no `Retry-After`, `clickhouse.unavailable` and `clickhouse.unknown` included, so the SDK returns it on the first attempt.

| Status | Code | Retryable | Description |
|--------|------|-----------|-------------|
| 400 | `HTTP_400` | No | Bad request (validation, missing fields) |
| 401 | `HTTP_401` | No | On REST, a present-but-invalid or expired JWT that a gate then denied. **WaveHouse itself** never returns `401` for a *missing* token — that resolves to `default_role`, and a denial is `403`. On a stream it is always from something in front, since `/v1/stream` is ungated |
| 403 | `HTTP_403` | No | Insufficient permissions |
| 404 | `HTTP_404` | No | Table, pipe, or tenant not found |
| 400 | `clickhouse.rejected` / `clickhouse.limit_exceeded` | No | ClickHouse refused the query (bad SQL, an unknown column, a type mismatch) or it outran a limit — including the role's own caps |
| 403 | `clickhouse.access_denied` | No | ClickHouse's user lacks a grant the statement needs |
| 500 | `HTTP_500` | Yes | Server error (retried per `maxRetries`) |
| 500 / 502 | `clickhouse.unknown` | Yes | ClickHouse failed with no verdict (no exception code, no recognizable transport error); `502` on `wh.sql` |
| 502 | `clickhouse.misconfigured` | No | ClickHouse refused WaveHouse's own credentials or database, or the route to it is wrong (a redirect, or a `4xx` other than `408`/`413`/`429`, with no exception code) — an operator fix |
| 502 | `clickhouse.response_too_large` | No | A raw-SQL (`wh.sql`) response over the 64 MiB cap |
| 503 | `clickhouse.unavailable` | Yes | ClickHouse is down, unreachable or overloaded; `Retry-After: 5`, honored between attempts |
| 503 | `HTTP_503` | Yes | Service unavailable, a tenant whose settings folder was rejected, a schema not discovered yet, a tenant on no ClickHouse pool, a dedupe store that cannot answer (`dedupe store unavailable`, `Retry-After: 5`), a token sent while that tenant's JWKS has not been fetched yet (`token verifier not ready`, `Retry-After: 30`), or a record whose dedupe id another request is still publishing (`a request with the same dedupe id is in flight`, `Retry-After`: the server's dedupe lease, 30 s by default). REST calls auto-retry, honoring `Retry-After` when the response carries one — so each attempt on those last two causes waits that long; a stream re-dials on its own jittered backoff instead |
| 0 | `NETWORK_ERROR` | Yes | Network failure (retried with exponential backoff) |
| 0 | `ABORTED` | No | Request canceled via `AbortSignal` |
| 0 | `SSE_CONNECT_ERROR` | No | Stream could not be started (e.g. a non-absolute `baseURL`) |
| 0 | `SSE_AUTH_ERROR` | Yes | The `auth` callback threw while minting a token for an attempt |
| 0 | `SSE_NETWORK_ERROR` | Yes | Stream request failed to reach the server |
| 0 | `SSE_READ_ERROR` | Yes | Stream was interrupted mid-read |
| 0 | `SSE_PARSE_ERROR` | Yes (usually nothing to re-dial) | Unparseable frame — reported and skipped, the connection continues. The buffer cap reports here too, then ends the connection (see below) |
| *(response status)* | `SSE_NO_STREAM_BODY` | No | A configured `options.fetch` returned a response with no readable body |
| *(0 in a browser, the 3xx in Node)* | `SSE_REDIRECT` | No | The stream endpoint redirected; point `baseURL` at the final URL |
| *(response status)* | `SSE_BAD_CONTENT_TYPE` | No | A `200` that wasn't `text/event-stream` — usually a gateway's login page |
| *(response status)* | `HTTP_4xx` / `HTTP_5xx` | Per status | The stream request was rejected — same codes as REST |

The `SSE_*` codes arrive on the subscriber's `error` callback rather than in a `Result.error`, since a stream has no single result to carry them. Both transports act on `retryable`; what differs is when the error reaches you. All four combinations, with the two exceptions noted under the table:

| | Retryable | Not retryable |
|---|---|---|
| **REST** | retried up to `maxRetries`, then returned — the flag records what was already tried | returned on the first attempt, no backoff (every `4xx`) |
| **Stream** | reported *before* the transport re-dials, which it does indefinitely | reported once; the stream closes and stays closed |

On REST, `ABORTED` is the one error raised *by* a backoff rather than by an attempt: aborting during a retry sleep ends the call immediately with `ABORTED` instead of waiting the delay out. On a stream, `SSE_PARSE_ERROR` is flagged retryable, but an ordinary bad frame is skipped in place — no re-dial, no close. The one that *does* re-dial is the buffer-cap overflow described below.

On a stream, a retryable failure is re-dialed on a jittered exponential backoff (capped at 30s, and reset only once a connection has held for a few seconds — so a server that accepts and instantly closes still backs off), with the `status` callback moving `reconnecting` → `live`.

Rejected requests surface the real status and message rather than an opaque connection failure — in a browser going cross-origin, though, only when the rejection passes CORS and the gateway answered whatever preflight the request triggers — `Authorization`, configured `headers`, or `Last-Event-ID` once the stream resumes; a rejected preflight or a response without `Access-Control-Allow-Origin` reaches you as a retryable network error instead — indistinguishable from a drop, and retried. Any `4xx` ends the stream, since repeating the request won't usually talk whatever rejected it round — the exception being a `429` or `408` from a fronting rate limiter, which is transient even though the stream still ends, so catch it and open a new one after a delay ([#469](https://github.com/Wave-RF/WaveHouse/issues/469)). Note that **WaveHouse never rejects a stream for a bad token**: `/v1/stream` is ungated, so an expired or missing token resolves to `default_role` and you get a `200` with a filtered view, not a `401`. The one authentication-related refusal is a token sent while the tenant's [JWKS has not been fetched yet](/api#authentication): a retryable `503`. REST calls honor its `Retry-After: 30`; a stream re-dials on the usual jittered backoff rather than ending. The 4xx it raises itself are `400` for a missing or empty `table` or a malformed `X-Tenant-ID`, and the `404` of [tenant resolution](/deployment#multi-tenant-deployments), with or without that header, since a stream that sends none is tenant `0`'s and a [nested settings directory](/deployment#the-nested-settings-directory) may have no `0` folder — a nested directory can also answer a `503` for a tenant whose settings were rejected, like the pending-JWKS `503` above, retryable, so the stream re-dials rather than ending; any other `404` or `405` means the request never reached that route, most often a `baseURL` path prefix your proxy didn't strip. Any other 4xx comes from something in front — an auth gateway, a proxy. That silent-downgrade behavior is exactly why `auth` is re-read on every connection attempt, and [#239](https://github.com/Wave-RF/WaveHouse/issues/239) tracks enforcing expiry server-side. `SSE_CONNECT_ERROR` and `SSE_NO_STREAM_BODY` are configuration faults, so fix the cause and start a new stream.

`SSE_PARSE_ERROR` is the one code that isn't a connection outcome: it's reported and *skipped*, and the connection keeps reading — one bad frame shouldn't cost you the stream. For an ordinary bad frame its `retryable: true` is therefore vestigial — nothing is re-dialed. Two exceptions, one to each half of that reported-and-skipped rule. A frame the SDK can't turn into a row is skipped but never *reported* — `console.warn` and dropped, with no `error` callback: `data` that isn't valid JSON, a row arriving before any `event: schema` frame, a `row` that is valid JSON but not an array, or a row whose length disagrees with the announced column list. Every one of those is **bounded** — three per cause per connection, then one "further occurrences suppressed" line, with the malformed-schema frame below counted as its own cause. So against a server that never announces a schema you get a handful of lines rather than one per row, and a quiet console is **not** evidence the stream is healthy. A malformed schema frame is the one to watch, because it discards the list rather than keeping a stale one — so every row after it is dropped until the next announcement or a reconnect. The parser's 16 MiB buffer cap is reported but not *skipped*: an overflow terminates the parser, so the transport stops reading and reconnects rather than feeding it again.

**If your own callback throws.** For anything delivered *through the transport* — `next`, `status`, or `error` — a throw never ends the stream: it is isolated and logged to the console, and never routed to your `error` callback, so a handler that swallows its own failures fails silently.

**Wrap your handler bodies in your own `try`/`catch`.** That isolation is a backstop against one bad callback killing the connection, not a promise that throwing is harmless. Four paths sit outside it, all tracked in [#473](https://github.com/Wave-RF/WaveHouse/issues/473):

- **The first `status` call** — `.subscribe()`'s, and the one `liveQuery()` makes internally on your behalf. It fires synchronously with the current state before the transport is involved, and is unguarded, so the throw propagates back out of the call *you* made, unlogged. Out of `.subscribe()` that leaves your subscriber registered with no unsubscribe function returned. Out of `liveQuery()` it is worse: you get no handle at all, so there is nothing to `.close()`; the backfill never starts, so `initial()` is never called; and the stream opened a moment earlier keeps running — connected, reconnecting on its own — with nothing referencing it. Because the backfill never ran, the buffering phase also never ends — your `next()` is never called at all, and every event the stream receives is appended to a buffer you have no way to reach. Passing `opts.signal` is the only way to stop it. Since this call always happens, it is the *first* thing a throwing `status` handler does.
- **Delivery to other consumers stops at the one that threw** — later subscribers and any concurrent `for await`. For the iterator the event is *dropped*, not queued, because the fan-out runs before the event is handed to a waiter or buffered. And if the throw lands on the terminal `closed` status, the iterator is never marked done either, so a `for await` waits forever against a dead stream until something calls `.close()`. Order-dependent and invisible.
- **A throwing `status` handler subscribed before `.connected()`** makes that promise reject with its timeout error against a stream that is already `live`, because the throw aborts the fan-out before `connected()`'s internal watcher is reached.
- **Inside `liveQuery()`**, a throw from `initial()`, or from `next()` during the backfill flush, is absorbed by the backfill's own error path, with no log and no signal. The cost differs: `initial()` throws *before* the flush starts, so every buffered event is lost, while a `next()` throw mid-flush loses only the remainder. Either way, later live events reach `next()` as normal, with a further throw isolated and logged.

`SSE_AUTH_ERROR` is the one caller-side failure that isn't terminal. A rejecting `auth` callback propagates out of a REST call, but on a stream — where `auth` is invoked on every connection attempt rather than once per stream — a token endpoint having a bad minute is treated as transient and retried, rather than tearing down a stream that is otherwise healthy.

---

## Full API Tree

```text
createClient<DB>(config) → WaveHouseClient
├── .from(table) → TableRef (NOT thenable)
│   ├── .fetch(opts?) → Promise<Result<Row[]>>
│   ├── .select(...cols?) → QueryBuilder (PromiseLike)
│   │   ├── .select() .selectAll() .where() .count() .sum() .avg() .min() .max()
│   │   │   .countDistinct() .aggregate() .groupBy() .orderBy()
│   │   │   .limit() .timeRange() .cacheTTL()
│   │   ├── .fetch(opts?) → Promise<Result<Row[]>>
│   │   ├── .stream(opts?) → StreamController
│   │   └── .liveQuery(subscriber, opts?) → LiveQuery
│   ├── .selectAll() → QueryBuilder (PromiseLike)
│   ├── .insert(data) → Promise<Result<InsertResult>>
│   ├── .insertNDJSON(source) → Promise<Result<InsertResult>>
│   ├── .schema(opts?) → Promise<Result<TableSchema>>   (admin)
│   └── .stream(opts?) → StreamController
├── .pipe(name, params?) → PipeRef (PromiseLike)
│   ├── .fetch(opts?) → Promise<Result<Row[]>>   // { signal } only — no limit
│   └── .stream(opts?) → StreamController
├── .pipes (admin)
│   ├── .list(opts?) → Promise<Result<Pipe[]>>
│   └── .get(name, opts?) → Promise<Result<Pipe>>
├── .sql(query, opts?) → Promise<Result<Row[]>>   (admin)
├── .schema (admin)
│   ├── .list(opts?) → Promise<Result<Schemas>>
│   └── .refresh(opts?) → Promise<Result<void>>
├── .settings (admin)
│   └── .reload(opts?) → Promise<Result<SettingsReloadResult>>
├── .dlq (admin)
│   ├── .list(opts?) → Promise<Result<DLQStats>>
│   ├── .table(name, opts?) → Promise<Result<DLQStats>>
│   └── .stream() → StreamController  // not yet functional server-side — #197
└── .sys
    └── .health() → Promise<Result<void>>

StreamController (NOT thenable)
├── .subscribe({ next, status?, error? }) → unsubscribe()
├── .connected(timeoutMs?) → Promise<void>
├── .close()
├── .status → StreamStatus
└── [Symbol.asyncIterator]() → AsyncIterableIterator<StreamEvent>
```

## Codegen CLI

Generate TypeScript types from a running WaveHouse instance. The package ships a `wavehouse-codegen` bin, so after installing `@wavehouse/sdk` you can run it with `npx`:

```bash
npx wavehouse-codegen --url http://localhost:8080 --out ./src/db.d.ts

# Or, working inside this repo (clients/ts/):
pnpm codegen --url http://localhost:8080 --out ./src/db.d.ts
```

Codegen reads `/v1/ops/schema`, which is **admin-only**. Against a non-dev server of the four settings files, pass an admin-role token with `--auth <jwt>` or the request is denied with `403`. Over [a nested settings directory](/deployment#the-nested-settings-directory) its `/v1/ops/*` routes admit the [operator key](/api#authentication) alone (an admin-role token gets `403`), so pass it with `--operator-key <key>` and name the tenant with `--tenant <id>`; without `--tenant` the request reads tenant `0`'s schema on either shape:

```bash
npx wavehouse-codegen --url https://wh.example.com --operator-key "$WH_AUTH_OPERATOR_KEY" --tenant acme --out ./src/db.d.ts
```

**Options:**

| Flag | Description | Default |
|------|-------------|---------|
| `--url`, `-u` | WaveHouse base URL | `http://localhost:8080` |
| `--out`, `-o` | Output .d.ts file path | `./wavehouse.d.ts` |
| `--auth`, `-a` | Bearer token (if auth required) | — |
| `--operator-key`, `-k` | Operator key, sent as `X-Operator-Key` (the credential a nested directory's `/v1/ops/*` routes admit) | — |
| `--tenant`, `-t` | Tenant whose schema to read, sent as `?tenant=` | tenant `0` |

A request carrying `--auth` or `--operator-key` does not follow a redirect: across origins `fetch` drops the bearer token and sends the operator key on to the redirect's target. Codegen stops and names that target, so point `--url` at the final address.

The generated row type is the **read** shape — with one exception running the other way: an `EPHEMERAL` column declares a default, so codegen emits it too, yet no query can ever return it. There the type says readable where only the write is real. A `MATERIALIZED` or `ALIAS` column declares a default, so it is emitted as optional — but supplying one on `insert` is a `400` (`column "x" of table "t" is materialized and cannot be inserted`), and the type will not catch it. Omit computed columns; the server fills them in.

**Example output:**

```ts
// Auto-generated by @wavehouse/sdk codegen
export interface Database {
  clicks: ClicksRow;
  events: EventsRow;
}

export interface ClicksRow {
  event_id: string;
  page: string;
  user_id: string;
  duration_ms: number;
  received_timestamp: string;
}
```

**ClickHouse → TypeScript type mapping:**

| ClickHouse Type | TypeScript Type |
|----------------|-----------------|
| `String`, `FixedString`, `UUID`, `DateTime*`, `Date*`, `Enum*`, `IPv4/6` | `string` |
| `UInt*`, `Int*`, `Float*`, `Decimal*` | `number` |
| `Bool` | `boolean` |
| `Nullable(T)` | `T \| null` |
| `Array(T)` | `T[]` |
| `Map(K, V)` | `Record<K, V>` |
| `LowCardinality(T)` | same as `T` |

## E2E Testing

The SDK doubles as the E2E integration test harness. Tests in `tests/e2e/sdk/` exercise the full pipeline (ingest → ClickHouse → query) through the SDK, validating both the backend and the client library in one pass.

```bash
# Run all E2E tests: the orchestrator boots ClickHouse and Redis
# testcontainers + the wavehouse-cov binary, then runs the SDK suite
make test-e2e
```

Test files live in `tests/e2e/sdk/` (each `*.test.ts`): `admin`, `auth`, `batching`, `cache`, `dlq`, `ingest`, `ndjson`, `query`, `streaming`, `stress`, plus `helpers` — a stack-free unit test of the harness's own `waitForCondition` poll helper rather than a pipeline test.

See [Development Guide — E2E Tests via SDK](/development#e2e-tests-via-sdk) for architecture details and workflow tips.