Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion clients/ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const wh = createClient({
});
```

`baseURL` may include a path prefix (`https://app.example.com/api/warehouse`) when WaveHouse is served under one — see [Serving under a path prefix](https://wavehouse.dev/sdk#serving-under-a-path-prefix).

### Query Data

```ts
Expand Down Expand Up @@ -125,7 +127,9 @@ const { data } = await wh.from('clicks').select('page').limit(10);

## Error Handling

All operations return `{ data, error }` tuples — the SDK never throws.
Async request methods return a `Result<T>` object — destructure it as `{ data, error }` — and never throw for anything the server returns. `.stream()` and `.liveQuery()` return controllers instead, reporting failures through the subscriber's `error` callback.

The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call.
Comment on lines +130 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate REST exceptions from stream error delivery.

Line [132] says that the SDK throws for the listed errors, but the stream case reports SSE_CONNECT_ERROR through a callback instead of throwing. The callback is optional, so the failure can be silent when no callback is supplied. Document the REST and stream behaviors in separate sentences.

As per coding guidelines, clients/ts/README.md must stay in sync with SDK-facing changes. Based on learnings, StreamSubscriber.error is optional and a failed stream connection can be silent when that callback is absent.

Proposed wording
- The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call.
+ REST calls throw on caller and environment errors such as a non-absolute `baseURL` or a rejecting `auth` callback. `.stream()` / `.liveQuery()` throw when `EventSource` is unavailable. A stream with a non-absolute `baseURL` reports `SSE_CONNECT_ERROR` through the optional subscriber `error` callback and does not retry automatically.

Sources: Coding guidelines, Learnings


```ts
const { data, error } = await wh.from('clicks').fetch();
Expand Down
4 changes: 3 additions & 1 deletion clients/ts/src/cli/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* npm run codegen -- --url http://localhost:8080 --out ./db.d.ts
*/

import { resolveURL } from "../url.js";

// ── Arg parsing (zero deps) ────────────────────────────────────────────────

interface CliArgs {
Expand Down Expand Up @@ -163,7 +165,7 @@ async function fetchSchemas(url: string, auth?: string): Promise<Schemas> {
const headers: Record<string, string> = {};
if (auth) headers.Authorization = `Bearer ${auth}`;

const res = await fetch(`${url.replace(/\/+$/, "")}/v1/schema`, { headers });
const res = await fetch(resolveURL(url, "/v1/schema").toString(), { headers });
if (!res.ok) {
const text = await res.text();
throw new Error(`Schema fetch failed (${res.status}): ${text}`);
Expand Down
13 changes: 13 additions & 0 deletions clients/ts/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ describe("createClient", () => {
expect(client._ctx.baseURL).toBe("http://localhost:8080");
});

it("keeps a path prefix on baseURL, minus trailing slashes", () => {
const client = createClient({ baseURL: "https://app.example.com/api/warehouse/" });
expect(client._ctx.baseURL).toBe("https://app.example.com/api/warehouse");
});

it("sends requests under the baseURL path prefix", async () => {
const client = createClient({ baseURL: "https://app.example.com/api/warehouse" });
await client.from("clicks").select("page").limit(1);

const [url] = fetchSpy.mock.calls[0];
expect(new URL(url as string).pathname).toBe("/api/warehouse/v1/query");
});

it("defaults maxRetries to 2", () => {
const client = createClient({ baseURL: "http://localhost:8080" });
expect(client._ctx.options.maxRetries).toBe(2);
Expand Down
25 changes: 25 additions & 0 deletions clients/ts/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,31 @@ describe("request", () => {
expect(url).toContain("table=clicks");
});

it("keeps a base path prefix on the request URL", async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));

await request(makeCtx({ baseURL: "https://app.example.com/api/warehouse" }), {
method: "POST",
path: "/v1/query?table=clicks",
});

const [url] = fetchSpy.mock.calls[0];
expect(url).toBe("https://app.example.com/api/warehouse/v1/query?table=clicks");
});

it("keeps a base path prefix when appending params", async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));

await request(makeCtx({ baseURL: "https://app.example.com/api/warehouse/" }), {
method: "GET",
path: "/v1/dlq/stats",
params: { table: "clicks" },
});

const [url] = fetchSpy.mock.calls[0];
expect(url).toBe("https://app.example.com/api/warehouse/v1/dlq/stats?table=clicks");
});

it("handles empty response body", async () => {
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));

Expand Down
13 changes: 2 additions & 11 deletions clients/ts/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { networkError, parseErrorResponse } from "./errors.js";
import type { HttpContext, WaveHouseError } from "./types.js";
import { resolveURL } from "./url.js";

interface RequestOptions {
method: string;
Expand Down Expand Up @@ -29,7 +30,7 @@ export interface HttpResult<T> {
* @internal
*/
export async function request<T>(ctx: HttpContext, opts: RequestOptions): Promise<HttpResult<T>> {
const url = buildURL(ctx.baseURL, opts.path, opts.params);
const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
const headers: Record<string, string> = {
"Content-Type": opts.contentType ?? "application/json",
Accept: "application/json",
Expand Down Expand Up @@ -109,16 +110,6 @@ export async function request<T>(ctx: HttpContext, opts: RequestOptions): Promis
return { data: null, error: lastError!, headers: new Headers() };
}

function buildURL(base: string, path: string, params?: Record<string, string>): string {
const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
return url.toString();
}

function backoff(attempt: number): number {
return Math.min(1000 * 2 ** attempt, 30_000);
}
Expand Down
98 changes: 98 additions & 0 deletions clients/ts/src/stream/sse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SSETransport } from "./sse.js";

/** URLs handed to `new EventSource(...)`, newest last. */
let opened: string[] = [];

class FakeEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;

readyState = FakeEventSource.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;

constructor(url: string) {
opened.push(url);
}

close(): void {
this.readyState = FakeEventSource.CLOSED;
}
}

/** Let `connect()`'s async `_doConnect()` reach `new EventSource(...)`. */
const flush = () => new Promise((r) => setTimeout(r, 0));

describe("SSETransport URL construction", () => {
beforeEach(() => {
opened = [];
vi.stubGlobal("EventSource", FakeEventSource);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("connects at the origin root for a root-hosted base", async () => {
new SSETransport({ baseURL: "http://localhost:8080", table: "clicks" }).connect();
await flush();

expect(opened).toHaveLength(1);
const url = new URL(opened[0]);
expect(url.pathname).toBe("/v1/stream");
expect(url.searchParams.get("table")).toBe("clicks");
});

it("preserves a base path prefix", async () => {
new SSETransport({
baseURL: "https://app.example.com/api/warehouse",
table: "clicks",
}).connect();
await flush();

const url = new URL(opened[0]);
expect(url.pathname).toBe("/api/warehouse/v1/stream");
expect(url.searchParams.get("table")).toBe("clicks");
});

it("preserves the prefix alongside since and token params", async () => {
new SSETransport({
baseURL: "https://app.example.com/api/warehouse/",
table: "clicks",
since: "2024-01-01T00:00:00Z",
auth: async () => "my-token",
}).connect();
await flush();

const url = new URL(opened[0]);
expect(url.pathname).toBe("/api/warehouse/v1/stream");
expect(url.searchParams.get("since")).toBe("2024-01-01T00:00:00Z");
expect(url.searchParams.get("token")).toBe("my-token");
});

it("omits the token param when auth resolves empty", async () => {
new SSETransport({
baseURL: "https://app.example.com/api/warehouse",
table: "clicks",
auth: async () => "",
}).connect();
await flush();

expect(new URL(opened[0]).searchParams.has("token")).toBe(false);
});

it("reports a bad baseURL through onError instead of throwing", async () => {
const onError = vi.fn();
const t = new SSETransport({ baseURL: "not-a-url", table: "clicks" });
t.onError = onError;
t.connect();
await flush();

expect(opened).toHaveLength(0);
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0].code).toBe("SSE_CONNECT_ERROR");
});
});
3 changes: 2 additions & 1 deletion clients/ts/src/stream/sse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StreamEvent, StreamStatus, WaveHouseError } from "../types.js";
import { resolveURL } from "../url.js";
import type { StreamTransport } from "./controller.js";

export interface SSEOptions {
Expand Down Expand Up @@ -53,7 +54,7 @@ export class SSETransport<T = Record<string, unknown>> implements StreamTranspor
}

private async _doConnect(): Promise<void> {
const url = new URL("/v1/stream", this._opts.baseURL);
const url = resolveURL(this._opts.baseURL, "/v1/stream");
url.searchParams.set("table", this._opts.table);
if (this._opts.since) {
url.searchParams.set("since", this._opts.since);
Expand Down
18 changes: 16 additions & 2 deletions clients/ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type Database = Record<string, Record<string, unknown>>;
// --- Result types ---

/**
* Discriminated union for all async SDK operations. Never throws.
* Discriminated union for all async SDK operations. Never throws for anything
* the server returns — caller and environment errors (a non-absolute `baseURL`,
* a missing `EventSource`, a rejecting `auth` callback) do throw.
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the Result<T> JSDoc to request methods.

The JSDoc describes a discriminated union for “all async SDK operations.” .stream() and .liveQuery() return controllers, not Result<T>. Replace “all async SDK operations” with “async request operations” to keep this contract accurate.

Proposed wording
- * Discriminated union for all async SDK operations. Never throws for anything
+ * Discriminated union for async request operations. Never throws for anything

*
* Discriminated on `ok`: `if (result.ok)` narrows to the success arm (and tells
* the compiler `data` is present), while `error` is always available for
Expand Down Expand Up @@ -58,7 +60,19 @@ export interface StreamSubscriber<T = Record<string, unknown>> {
// --- Client config ---

export interface ClientConfig<_DB extends Database = Database> {
/** Base URL of the WaveHouse server (e.g. "http://localhost:8080"). */
/**
* Base URL of the WaveHouse server (e.g. "http://localhost:8080"). May include
* a path prefix ("https://app.example.com/api/warehouse") for a WaveHouse
* behind a backend-for-frontend (BFF), app-server route, or path-routed
* ingress; request paths are appended to it. The proxy in front must strip
* the prefix before forwarding.
*
* Must be **absolute** — scheme and host included. The transports report a
* relative value such as "/api/warehouse" differently: a REST call rejects
* with a `TypeError` instead of returning a `Result`, while a stream reports
* `SSE_CONNECT_ERROR` to the subscriber's `error` callback. Build an absolute
* one: `` `${location.origin}/api/warehouse` ``.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
baseURL: string;
/** Auth token provider. Omit for public/unauthenticated access. */
auth?: () => Promise<string> | string;
Expand Down
78 changes: 78 additions & 0 deletions clients/ts/src/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { resolveURL } from "./url.js";

describe("resolveURL", () => {
it("resolves against a root-hosted base", () => {
expect(resolveURL("http://localhost:8080", "/v1/query").toString()).toBe(
"http://localhost:8080/v1/query",
);
});

it("preserves a base path prefix", () => {
expect(resolveURL("https://app.example.com/api/warehouse", "/v1/query").toString()).toBe(
"https://app.example.com/api/warehouse/v1/query",
);
});

it("preserves a prefix whether or not the base ends in a slash", () => {
const withSlash = resolveURL("https://app.example.com/api/warehouse/", "/v1/query").toString();
const without = resolveURL("https://app.example.com/api/warehouse", "/v1/query").toString();
expect(withSlash).toBe(without);
expect(without).toBe("https://app.example.com/api/warehouse/v1/query");
});

it("preserves a multi-segment prefix", () => {
expect(resolveURL("https://example.com/a/b/c", "/v1/admin/pipes").toString()).toBe(
"https://example.com/a/b/c/v1/admin/pipes",
);
});

it("accepts a path with or without a leading slash", () => {
expect(resolveURL("https://example.com/api", "v1/schema").toString()).toBe(
resolveURL("https://example.com/api", "/v1/schema").toString(),
);
});

it("keeps the query string embedded in a path", () => {
expect(resolveURL("https://example.com/api", "/v1/ingest?table=clicks").toString()).toBe(
"https://example.com/api/v1/ingest?table=clicks",
);
});

it("appends params after the prefix", () => {
const url = resolveURL("https://example.com/api", "/v1/dlq/stats", { table: "clicks" });
expect(url.toString()).toBe("https://example.com/api/v1/dlq/stats?table=clicks");
});

it("merges params with a query string already on the path", () => {
const url = resolveURL("https://example.com/api", "/v1/query?table=clicks", { limit: "10" });
expect(url.pathname).toBe("/api/v1/query");
expect(url.searchParams.get("table")).toBe("clicks");
expect(url.searchParams.get("limit")).toBe("10");
});

it("percent-encodes param values", () => {
const url = resolveURL("https://example.com", "/v1/schema", { table: "a b&c" });
expect(url.searchParams.get("table")).toBe("a b&c");
expect(url.toString()).toContain("table=a+b%26c");
});

it("drops a query or fragment on the base rather than letting it eat the prefix", () => {
expect(resolveURL("https://example.com/api?stale=1", "/v1/query").toString()).toBe(
"https://example.com/api/v1/query",
);
expect(resolveURL("https://example.com/api#frag", "/v1/query").toString()).toBe(
"https://example.com/api/v1/query",
);
});

it("preserves a non-default port", () => {
expect(resolveURL("https://example.com:8443/api", "/v1/query").toString()).toBe(
"https://example.com:8443/api/v1/query",
);
});

it("throws on a base with no scheme", () => {
expect(() => resolveURL("example.com/api", "/v1/query")).toThrow();
});
});
28 changes: 28 additions & 0 deletions clients/ts/src/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Resolve an SDK request path against the configured `baseURL`.
*
* The single URL-construction rule for every transport (REST in `http.ts`, SSE
* in `stream/sse.ts`, the codegen CLI). Request paths are joined **onto** the
* base rather than resolved as absolute ones, so a base carrying a path prefix
* — `https://app.example.com/api/warehouse`, a WaveHouse behind a BFF,
* app-server route, or path-routed ingress — keeps that prefix. Resolving
* `/v1/query` against such a base would silently discard it (#428).
*
* @internal
*/
export function resolveURL(base: string, path: string, params?: Record<string, string>): URL {
const root = new URL(base);
// Normalize the base to a directory: a bare last segment (or a query/fragment
// shadowing one) makes relative resolution *replace* the prefix, not extend it.
root.search = "";
root.hash = "";
if (!root.pathname.endsWith("/")) root.pathname += "/";

const url = new URL(path.replace(/^\/+/, ""), root);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
return url;
}
2 changes: 1 addition & 1 deletion docs/src/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ For SSE connections where custom headers are not possible, you can pass the toke
GET /v1/stream?token=<jwt>
```

The `Authorization` header takes precedence when both are provided: the `?token=` query parameter is only a fallback for clients that can't set headers (browser `EventSource`), so a token in the more log-leakable URL never overrides an explicit header credential. The `token` query parameter is stripped from the URL after extraction so it can't leak into logs.
The `Authorization` header takes precedence when both are provided: the `?token=` query parameter is only a fallback for clients that can't set headers (browser `EventSource`), so a token in the more log-leakable URL never overrides an explicit header credential. A `?token=` is stripped from the URL after extraction whichever credential wins, so it stays out of WaveHouse's own logs — but it has already crossed the wire in the request URI, so redact query strings at any proxy, CDN, or load balancer in front.

**Authentication is decoupled from authorization.** A request with **no token**, or an **invalid/expired/malformed** one, is *not* rejected outright — it falls back to an empty role that resolves to the policy `default_role`, and authorization is decided downstream. Because the bad-token reason is remembered, a request that is then denied for lacking permission fails loud (`401` "invalid/expired token") instead of a bare `403`. Elevated access requires a valid token whose role is granted (or equals the `admin_role`). A `403` body has two forms: a request that resolves to **no role at all** (no token and no `default_role` configured) returns `{"error":"forbidden: request has no role and no public default_role is configured"}`, while a request carrying a concrete-but-unauthorized role returns the bare `{"error":"forbidden"}` shown in the tables below.

Expand Down
Loading