@wavehouse/sdk 0.0.0-dev.hff4a2a48d0da → 0.0.1-dev.20260820163943.hf5c59240fbcf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -35,9 +35,46 @@ function err(error) {
35
35
  return { ok: false, data: null, error };
36
36
  }
37
37
 
38
+ // src/url.ts
39
+ function resolveURL(base, path, params) {
40
+ const root = new URL(base);
41
+ root.search = "";
42
+ root.hash = "";
43
+ if (!root.pathname.endsWith("/")) root.pathname += "/";
44
+ const url = new URL(path.replace(/^\/+/, ""), root);
45
+ if (params) {
46
+ for (const [k, v] of Object.entries(params)) {
47
+ url.searchParams.set(k, v);
48
+ }
49
+ }
50
+ return url;
51
+ }
52
+
38
53
  // src/http.ts
54
+ function mergeHeaders(base, extra) {
55
+ if (!extra) return base;
56
+ const sdkNames = new Set(Object.keys(base).map((k) => k.toLowerCase()));
57
+ const merged = { ...base };
58
+ const configured = /* @__PURE__ */ new Map();
59
+ for (const [name, value] of Object.entries(extra)) {
60
+ const lower = name.toLowerCase();
61
+ if (sdkNames.has(lower)) continue;
62
+ const prior = configured.get(lower);
63
+ if (prior !== void 0) delete merged[prior];
64
+ merged[name] = value;
65
+ configured.set(lower, name);
66
+ }
67
+ return merged;
68
+ }
69
+ function abortedResult() {
70
+ return {
71
+ data: null,
72
+ error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
73
+ headers: new Headers()
74
+ };
75
+ }
39
76
  async function request(ctx, opts) {
40
- const url = buildURL(ctx.baseURL, opts.path, opts.params);
77
+ const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
41
78
  const headers = {
42
79
  "Content-Type": opts.contentType ?? "application/json",
43
80
  Accept: "application/json"
@@ -49,16 +86,20 @@ async function request(ctx, opts) {
49
86
  headers.Authorization = `Bearer ${token}`;
50
87
  }
51
88
  }
89
+ const finalHeaders = mergeHeaders(headers, ctx.options.headers);
52
90
  let lastError = null;
53
91
  const maxAttempts = ctx.options.maxRetries + 1;
54
92
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
55
93
  try {
56
- const res = await fetch(url, {
94
+ const init = {
95
+ ...ctx.options.fetchOptions,
57
96
  method: opts.method,
58
- headers,
97
+ headers: finalHeaders,
59
98
  body: requestBody,
60
99
  signal: opts.signal
61
- });
100
+ };
101
+ const doFetch = ctx.options.fetch;
102
+ const res = doFetch ? await doFetch(url, init) : await fetch(url, init);
62
103
  if (res.ok) {
63
104
  const text = await res.text();
64
105
  const data = text ? JSON.parse(text) : void 0;
@@ -81,30 +122,24 @@ async function request(ctx, opts) {
81
122
  }
82
123
  return { data: null, error, headers: res.headers };
83
124
  } catch (e) {
84
- if (e instanceof DOMException && e.name === "AbortError") {
85
- return {
86
- data: null,
87
- error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
88
- headers: new Headers()
89
- };
125
+ if (opts.signal?.aborted) {
126
+ return abortedResult();
90
127
  }
91
128
  lastError = networkError(e);
92
129
  if (attempt < maxAttempts - 1) {
93
- await sleep(backoff(attempt), opts.signal);
130
+ try {
131
+ await sleep(backoff(attempt), opts.signal);
132
+ } catch (abort) {
133
+ if (opts.signal?.aborted) {
134
+ return abortedResult();
135
+ }
136
+ throw abort;
137
+ }
94
138
  }
95
139
  }
96
140
  }
97
141
  return { data: null, error: lastError, headers: new Headers() };
98
142
  }
99
- function buildURL(base, path, params) {
100
- const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
101
- if (params) {
102
- for (const [k, v] of Object.entries(params)) {
103
- url.searchParams.set(k, v);
104
- }
105
- }
106
- return url.toString();
107
- }
108
143
  function backoff(attempt) {
109
144
  return Math.min(1e3 * 2 ** attempt, 3e4);
110
145
  }
@@ -138,7 +173,7 @@ var DLQNamespace = class {
138
173
  async list(opts) {
139
174
  const { data, error } = await request(this._ctx, {
140
175
  method: "GET",
141
- path: "/v1/dlq/stats",
176
+ path: "/v1/ops/dlq/stats",
142
177
  signal: opts?.signal
143
178
  });
144
179
  if (error) return err(error);
@@ -148,7 +183,7 @@ var DLQNamespace = class {
148
183
  async table(name, opts) {
149
184
  const { data, error } = await request(this._ctx, {
150
185
  method: "GET",
151
- path: "/v1/dlq/stats",
186
+ path: "/v1/ops/dlq/stats",
152
187
  params: { table: name },
153
188
  signal: opts?.signal
154
189
  });
@@ -173,7 +208,14 @@ var PipeRef = class {
173
208
  this._params = params;
174
209
  this._createStream = createStream;
175
210
  }
176
- /** Execute the pipe and return results. */
211
+ /**
212
+ * Execute the pipe and return results.
213
+ *
214
+ * Takes only `signal` — deliberately narrower than the `RequestOptions` the
215
+ * query builder accepts. The pipes endpoint binds the body as the pipe's
216
+ * parameters, so there is no row cap to forward; give the pipe a `{{limit}}`
217
+ * parameter in its SQL and pass it via `wh.pipe(name, { limit })`.
218
+ */
177
219
  async fetch(opts) {
178
220
  const { data, error } = await request(this._ctx, {
179
221
  method: "POST",
@@ -201,7 +243,7 @@ var PipesNamespace = class {
201
243
  async list(opts) {
202
244
  const { data, error } = await request(this._ctx, {
203
245
  method: "GET",
204
- path: "/v1/admin/pipes",
246
+ path: "/v1/ops/pipes",
205
247
  signal: opts?.signal
206
248
  });
207
249
  if (error) return err(error);
@@ -211,7 +253,7 @@ var PipesNamespace = class {
211
253
  async get(name, opts) {
212
254
  const { data, error } = await request(this._ctx, {
213
255
  method: "GET",
214
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
256
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
215
257
  signal: opts?.signal
216
258
  });
217
259
  if (error) return err(error);
@@ -221,7 +263,7 @@ var PipesNamespace = class {
221
263
  async set(name, def, opts) {
222
264
  const { error } = await request(this._ctx, {
223
265
  method: "PUT",
224
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
266
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
225
267
  body: def,
226
268
  signal: opts?.signal
227
269
  });
@@ -232,7 +274,7 @@ var PipesNamespace = class {
232
274
  async delete(name, opts) {
233
275
  const { error } = await request(this._ctx, {
234
276
  method: "DELETE",
235
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
277
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
236
278
  signal: opts?.signal
237
279
  });
238
280
  if (error) return err(error);
@@ -250,7 +292,7 @@ var PolicyNamespace = class {
250
292
  async get(opts) {
251
293
  const { data, error } = await request(this._ctx, {
252
294
  method: "GET",
253
- path: "/v1/admin/policy",
295
+ path: "/v1/ops/policy",
254
296
  signal: opts?.signal
255
297
  });
256
298
  if (error) return err(error);
@@ -260,7 +302,7 @@ var PolicyNamespace = class {
260
302
  async set(policy, opts) {
261
303
  const { error } = await request(this._ctx, {
262
304
  method: "PUT",
263
- path: "/v1/admin/policy",
305
+ path: "/v1/ops/policy",
264
306
  body: policy,
265
307
  signal: opts?.signal
266
308
  });
@@ -271,7 +313,7 @@ var PolicyNamespace = class {
271
313
  async validate(policy, opts) {
272
314
  const { data, error } = await request(this._ctx, {
273
315
  method: "POST",
274
- path: "/v1/admin/policy/validate",
316
+ path: "/v1/ops/policy/validate",
275
317
  body: policy,
276
318
  signal: opts?.signal
277
319
  });
@@ -290,7 +332,7 @@ var SchemaNamespace = class {
290
332
  async list(opts) {
291
333
  const { data, error } = await request(this._ctx, {
292
334
  method: "GET",
293
- path: "/v1/schema",
335
+ path: "/v1/ops/schema",
294
336
  signal: opts?.signal
295
337
  });
296
338
  if (error) return err(error);
@@ -311,7 +353,7 @@ var SchemaNamespace = class {
311
353
  async refresh(opts) {
312
354
  const { error } = await request(this._ctx, {
313
355
  method: "POST",
314
- path: "/v1/schema/refresh",
356
+ path: "/v1/ops/schema/refresh",
315
357
  signal: opts?.signal
316
358
  });
317
359
  if (error) return err(error);
@@ -323,7 +365,7 @@ var SchemaNamespace = class {
323
365
  async function sql(ctx, query, opts) {
324
366
  const { data, error } = await request(ctx, {
325
367
  method: "POST",
326
- path: "/v1/admin/query",
368
+ path: "/v1/ops/query",
327
369
  body: { sql: query },
328
370
  signal: opts?.signal
329
371
  });
@@ -486,11 +528,50 @@ var StreamController = class {
486
528
  };
487
529
 
488
530
  // src/stream/sse.ts
531
+ import { createParser } from "eventsource-parser";
489
532
  var activeSSEConnections = 0;
490
533
  var SSE_WARN_THRESHOLD = 5;
534
+ var MAX_BUFFER_CHARS = 16 * 1024 * 1024;
535
+ var MAX_BACKOFF_MS = 3e4;
536
+ var STABLE_CONNECTION_MS = 3e3;
537
+ function backoff2(attempt, retryFloorMs) {
538
+ const nominal = Math.max(retryFloorMs, Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS));
539
+ const low = Math.max(retryFloorMs, nominal / 2);
540
+ return low + Math.random() * (nominal - low);
541
+ }
542
+ function releaseBody(res) {
543
+ if (res.body && !res.body.locked) res.body.cancel().catch(() => {
544
+ });
545
+ }
491
546
  var SSETransport = class {
492
547
  _opts;
493
- _es = null;
548
+ _abort = null;
549
+ /**
550
+ * Set by `disconnect()` before it calls `abort()` — both synchronously — so
551
+ * a rejection caused by our own abort always observes this as `true`. That
552
+ * makes it a complete and precise test for "did we cause this?", which is
553
+ * why the catch blocks read it directly instead of matching on the error
554
+ * type: an `AbortError` we *didn't* raise (a custom `fetch` enforcing a
555
+ * per-attempt deadline, an `auth()` call cancelled by its own controller) is
556
+ * a transient failure to re-dial, exactly as on the REST path. Treating one
557
+ * as a teardown killed the stream permanently, and silently.
558
+ */
559
+ _closed = false;
560
+ _started = false;
561
+ _counted = false;
562
+ /**
563
+ * Most recent non-empty event id, replayed as `Last-Event-ID` on reconnect.
564
+ *
565
+ * Deliberately ignores empty ids. The server emits a blank `id:` line for
566
+ * passthrough payloads, and per the SSE spec an empty `id` field *clears* the
567
+ * last-event-id — so tracking it faithfully would silently discard the
568
+ * resumption point mid-stream and re-open from the beginning.
569
+ */
570
+ _lastEventId = null;
571
+ /** Reconnect floor most recently requested by the server via `retry:`. */
572
+ _retryFloorMs = 0;
573
+ /** Cuts a reconnect gap short when set — see `_sleep`. */
574
+ _wake = null;
494
575
  onEvent = null;
495
576
  onStatus = null;
496
577
  onError = null;
@@ -498,79 +579,332 @@ var SSETransport = class {
498
579
  this._opts = opts;
499
580
  }
500
581
  connect() {
501
- if (typeof EventSource === "undefined") {
582
+ if (this._closed || this._started) return;
583
+ this._started = true;
584
+ if (!this._opts.fetch && typeof fetch === "undefined") {
502
585
  throw new Error(
503
- "[wavehouse] EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
586
+ "[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
504
587
  );
505
588
  }
506
- this._doConnect().catch((err2) => {
507
- this.onError?.({
589
+ activeSSEConnections++;
590
+ this._counted = true;
591
+ if (activeSSEConnections > SSE_WARN_THRESHOLD) {
592
+ console.warn(
593
+ `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
594
+ );
595
+ }
596
+ this._run().catch((err2) => {
597
+ this._emitError({
508
598
  status: 0,
509
599
  code: "SSE_CONNECT_ERROR",
510
600
  message: err2 instanceof Error ? err2.message : String(err2),
511
- retryable: true
601
+ retryable: false
512
602
  });
603
+ this.disconnect();
513
604
  });
514
605
  }
515
606
  disconnect() {
516
- if (this._es) {
517
- this._es.close();
518
- this._es = null;
519
- activeSSEConnections = Math.max(0, activeSSEConnections - 1);
607
+ if (!this._closed) {
608
+ this._closed = true;
609
+ this._abort?.abort();
610
+ this._abort = null;
611
+ this._wake?.();
612
+ if (this._counted) {
613
+ activeSSEConnections = Math.max(0, activeSSEConnections - 1);
614
+ this._counted = false;
615
+ }
616
+ try {
617
+ this.onStatus?.("closed");
618
+ } catch (e) {
619
+ console.error("[wavehouse] SSE status handler threw:", e);
620
+ }
621
+ }
622
+ }
623
+ /** Connect/read/reconnect until the stream is closed or hits a terminal error. */
624
+ async _run() {
625
+ await Promise.resolve();
626
+ let attempt = 0;
627
+ while (!this._closed) {
628
+ const { liveMs, terminal } = await this._attempt();
629
+ if (this._closed) return;
630
+ if (terminal) {
631
+ this.disconnect();
632
+ return;
633
+ }
634
+ if (liveMs >= STABLE_CONNECTION_MS) attempt = 0;
635
+ this._emitStatus("reconnecting");
636
+ await this._sleep(backoff2(attempt, this._retryFloorMs));
637
+ attempt++;
520
638
  }
521
- this.onStatus?.("closed");
522
639
  }
523
- async _doConnect() {
524
- const url = new URL("/v1/stream", this._opts.baseURL);
640
+ /** One connection attempt: request, validate, then pump frames until it ends. */
641
+ async _attempt() {
642
+ const ac = new AbortController();
643
+ this._abort = ac;
644
+ let target;
645
+ try {
646
+ target = this._url();
647
+ } catch (e) {
648
+ this._emitError({
649
+ status: 0,
650
+ code: "SSE_CONNECT_ERROR",
651
+ message: e instanceof Error ? e.message : String(e),
652
+ retryable: false
653
+ });
654
+ return { liveMs: 0, terminal: true };
655
+ }
656
+ let init;
657
+ try {
658
+ init = await this._init(ac.signal);
659
+ } catch (e) {
660
+ if (this._closed) return { liveMs: 0, terminal: true };
661
+ this._emitError({
662
+ status: 0,
663
+ code: "SSE_AUTH_ERROR",
664
+ message: e instanceof Error ? e.message : String(e),
665
+ retryable: true
666
+ });
667
+ return { liveMs: 0, terminal: false };
668
+ }
669
+ let res;
670
+ try {
671
+ const doFetch = this._opts.fetch;
672
+ res = doFetch ? await doFetch(target, init) : await fetch(target, init);
673
+ } catch (e) {
674
+ if (this._closed) return { liveMs: 0, terminal: true };
675
+ this._emitError({
676
+ status: 0,
677
+ code: "SSE_NETWORK_ERROR",
678
+ message: e instanceof Error ? e.message : String(e),
679
+ retryable: true
680
+ });
681
+ return { liveMs: 0, terminal: false };
682
+ }
683
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
684
+ this._emitError({
685
+ status: res.status,
686
+ code: "SSE_REDIRECT",
687
+ message: "The stream endpoint redirected and the SDK did not follow it. Redirects are refused when the request carries a credential \u2014 an `auth` token or configured `headers` \u2014 because a cross-origin hop strips the Authorization header, and this endpoint answers an unauthenticated caller with a reduced view instead of an error, while forwarding any configured headers to wherever the redirect points. If this request carries neither, `fetch` was left to follow the redirect itself, so arriving here means a response it will not follow (a 304, or a 3xx with no Location header) or an `options.fetch` that returned the redirect verbatim. Either way, set `baseURL` to the final URL; most often this is an http\u2192https upgrade or a canonical-host redirect at a proxy. To follow a credentialed redirect anyway, supply an `options.fetch` that overrides `redirect`.",
688
+ retryable: false
689
+ });
690
+ releaseBody(res);
691
+ return { liveMs: 0, terminal: true };
692
+ }
693
+ if (!res.ok) {
694
+ const error = await parseErrorResponse(res);
695
+ this._emitError(error);
696
+ return { liveMs: 0, terminal: !error.retryable };
697
+ }
698
+ const contentType = res.headers.get("content-type") ?? "";
699
+ if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
700
+ this._emitError({
701
+ status: res.status,
702
+ code: "SSE_BAD_CONTENT_TYPE",
703
+ message: `Expected \`text/event-stream\`, got \`${contentType || "(none)"}\`. Something between the client and WaveHouse answered this request \u2014 an auth gateway's login page is the usual cause.`,
704
+ retryable: false
705
+ });
706
+ releaseBody(res);
707
+ return { liveMs: 0, terminal: true };
708
+ }
709
+ const body = res.body;
710
+ if (!body || typeof body.getReader !== "function" || body.locked || res.bodyUsed) {
711
+ this._emitError({
712
+ status: res.status,
713
+ code: "SSE_NO_STREAM_BODY",
714
+ message: "Streaming requires a response with a readable body. The configured `options.fetch` returned one whose body is absent or already read \u2014 an implementation that buffers or consumes the response cannot be used for `.stream()` or `.liveQuery()`.",
715
+ retryable: false
716
+ });
717
+ releaseBody(res);
718
+ return { liveMs: 0, terminal: true };
719
+ }
720
+ this._emitStatus("live");
721
+ const openedAt = Date.now();
722
+ await this._pump(body);
723
+ return { liveMs: Date.now() - openedAt, terminal: false };
724
+ }
725
+ /** Resolve the stream URL. Throws on a `baseURL` that isn't a usable http(s) origin. */
726
+ _url() {
727
+ const url = resolveURL(this._opts.baseURL, "/v1/stream");
728
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
729
+ throw new Error(
730
+ `baseURL must use http or https, got "${url.protocol}". Streaming is plain HTTP \u2014 there is no WebSocket endpoint.`
731
+ );
732
+ }
525
733
  url.searchParams.set("table", this._opts.table);
526
734
  if (this._opts.since) {
527
735
  url.searchParams.set("since", this._opts.since);
528
736
  }
737
+ return url.toString();
738
+ }
739
+ /** Build the request init, minting a token for this attempt. */
740
+ async _init(signal) {
741
+ const base = { Accept: "text/event-stream" };
529
742
  if (this._opts.auth) {
530
743
  const token = await this._opts.auth();
531
744
  if (token) {
532
- url.searchParams.set("token", token);
745
+ base.Authorization = `Bearer ${token}`;
533
746
  }
534
747
  }
535
- activeSSEConnections++;
536
- if (activeSSEConnections > SSE_WARN_THRESHOLD) {
537
- console.warn(
538
- `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
539
- );
748
+ if (this._lastEventId) {
749
+ base["Last-Event-ID"] = this._lastEventId;
540
750
  }
541
- this._es = new EventSource(url.toString());
542
- this._es.onopen = () => {
543
- this.onStatus?.("live");
544
- };
545
- this._es.onmessage = (e) => {
546
- try {
547
- const msg = JSON.parse(e.data);
548
- const event = {
549
- table: msg.table_name,
550
- timestamp: msg.received_timestamp,
551
- data: msg.data
552
- };
553
- this.onEvent?.(event);
554
- } catch {
555
- console.warn("[wavehouse] SSE received malformed message:", e.data);
556
- }
751
+ const credentialed = base.Authorization !== void 0 || Object.keys(this._opts.headers ?? {}).length > 0;
752
+ const init = {
753
+ ...this._opts.fetchOptions,
754
+ method: "GET",
755
+ headers: mergeHeaders(base, this._opts.headers),
756
+ signal,
757
+ // Owned by the SDK rather than left to `fetchOptions`, unlike the REST
758
+ // path, because on a stream these are correctness rather than preference:
759
+ //
760
+ // cache — as an init field, not a `Cache-Control` header. The header
761
+ // form is not in the server's Access-Control-Allow-Headers,
762
+ // so it would fail every cross-origin preflight.
763
+ // redirect — followed only when this request carries no credential.
764
+ // Platforms strip `Authorization` on a cross-origin redirect
765
+ // (whatwg/fetch#1544) and forward other headers intact, so a
766
+ // credentialed hop either silently downgrades the stream to
767
+ // the default role — this endpoint answers an unauthenticated
768
+ // caller rather than rejecting them — or delivers a configured
769
+ // secret to whatever the redirect names. Neither is worth a
770
+ // convenience. Without either, redirects are followed, so CDN
771
+ // canonicalization, geo/LB indirection, and an http→https
772
+ // upgrade all just work. The test is deliberately that
773
+ // concrete pair and not "is this authenticated": cookies do
774
+ // not appear in it. A cookie is re-derived per
775
+ // destination rather than carried, so under the default
776
+ // same-origin credentials mode it survives a redirect only
777
+ // while the hop stays inside both the request's origin and
778
+ // the cookie's Path; outside either the stream is
779
+ // silently unauthed. See #478 and the SDK guide under
780
+ // "Supplying your own fetch".
781
+ // `manual` over `error` for the credentialed case so the
782
+ // outcome stays inspectable: `error` rejects with a bare
783
+ // TypeError indistinguishable from a connection failure, which
784
+ // the loop above would retry forever against a redirect that
785
+ // is never going to stop happening.
786
+ cache: "no-store",
787
+ redirect: credentialed ? "manual" : "follow",
788
+ // Neutralized like the REST path does, and for a sharper reason here: a
789
+ // body on a GET makes `fetch` throw, which reads as a network failure and
790
+ // would be retried forever against a request that can never be built.
791
+ body: void 0
557
792
  };
558
- this._es.onerror = () => {
559
- if (this._es?.readyState === EventSource.CONNECTING) {
560
- this.onStatus?.("reconnecting");
561
- } else if (this._es?.readyState === EventSource.CLOSED) {
562
- this.onStatus?.("closed");
563
- } else if (this._es?.readyState === EventSource.OPEN) {
564
- this.onStatus?.("live");
565
- } else {
566
- this.onError?.({
793
+ if (!("window" in globalThis)) {
794
+ delete init.credentials;
795
+ }
796
+ return init;
797
+ }
798
+ /** Decode and parse frames until the stream ends, aborts, or errors. */
799
+ async _pump(body) {
800
+ let overflowed = false;
801
+ const parser = createParser({
802
+ onEvent: (msg) => {
803
+ if (msg.id) this._lastEventId = msg.id;
804
+ if (!msg.data) return;
805
+ this._dispatch(msg.data);
806
+ },
807
+ onRetry: (ms) => {
808
+ this._retryFloorMs = Math.min(Math.max(ms, 0), MAX_BACKOFF_MS);
809
+ },
810
+ onError: (err2) => {
811
+ if (err2.type === "max-buffer-size-exceeded") overflowed = true;
812
+ this._emitError({
567
813
  status: 0,
568
- code: "SSE_ERROR",
569
- message: "SSE connection error",
814
+ code: "SSE_PARSE_ERROR",
815
+ message: err2.message,
570
816
  retryable: true
571
817
  });
818
+ },
819
+ maxBufferSize: MAX_BUFFER_CHARS
820
+ });
821
+ const reader = body.getReader();
822
+ const decoder = new TextDecoder();
823
+ try {
824
+ for (; ; ) {
825
+ const { done, value } = await reader.read();
826
+ if (done) return;
827
+ parser.feed(decoder.decode(value, { stream: true }));
828
+ if (overflowed) return;
572
829
  }
573
- };
830
+ } catch (e) {
831
+ if (this._closed) return;
832
+ this._emitError({
833
+ status: 0,
834
+ code: "SSE_READ_ERROR",
835
+ message: e instanceof Error ? e.message : String(e),
836
+ retryable: true
837
+ });
838
+ } finally {
839
+ reader.cancel().catch(() => {
840
+ });
841
+ }
842
+ }
843
+ /**
844
+ * Turn one frame's `data` into a StreamEvent.
845
+ *
846
+ * Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
847
+ * every complete frame in a chunk synchronously, so a subscriber that closes
848
+ * from inside its own `next` — "read until I see my event, then stop" —
849
+ * would otherwise keep receiving the rest of that chunk. `EventSource` ended
850
+ * delivery on `close()`, and gap-fill replay routinely lands many frames in
851
+ * one chunk.
852
+ */
853
+ _dispatch(data) {
854
+ if (this._closed) return;
855
+ let event;
856
+ try {
857
+ const msg = JSON.parse(data);
858
+ event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
859
+ } catch {
860
+ console.warn("[wavehouse] SSE received malformed message:", data);
861
+ return;
862
+ }
863
+ try {
864
+ this.onEvent?.(event);
865
+ } catch (e) {
866
+ console.error("[wavehouse] SSE event handler threw:", e);
867
+ }
868
+ }
869
+ /** Suppress callbacks that race a `disconnect()`. */
870
+ _emitError(error) {
871
+ if (this._closed) return;
872
+ try {
873
+ this.onError?.(error);
874
+ } catch (e) {
875
+ console.error("[wavehouse] SSE error handler threw:", e);
876
+ }
877
+ }
878
+ /**
879
+ * Same guard for status. Aborting does not retract an already-resolved
880
+ * `Response`, so a `disconnect()` landing between the fetch settling and the
881
+ * "live" below would otherwise flip a closed controller back to live — and it
882
+ * stays there, since the loop then exits without emitting anything further.
883
+ * `disconnect()` calls `onStatus` directly; that terminal "closed" is the one
884
+ * status that must always get through.
885
+ */
886
+ _emitStatus(status) {
887
+ if (this._closed) return;
888
+ try {
889
+ this.onStatus?.(status);
890
+ } catch (e) {
891
+ console.error("[wavehouse] SSE status handler threw:", e);
892
+ }
893
+ }
894
+ /** Wait out a reconnect gap, cut short by `disconnect()`. */
895
+ _sleep(ms) {
896
+ if (this._closed) return Promise.resolve();
897
+ return new Promise((resolve) => {
898
+ const timer = setTimeout(() => {
899
+ this._wake = null;
900
+ resolve();
901
+ }, ms);
902
+ this._wake = () => {
903
+ clearTimeout(timer);
904
+ this._wake = null;
905
+ resolve();
906
+ };
907
+ });
574
908
  }
575
909
  };
576
910
 
@@ -1035,7 +1369,7 @@ var TableRef = class {
1035
1369
  async schema(opts) {
1036
1370
  const { data, error } = await request(this._ctx, {
1037
1371
  method: "GET",
1038
- path: `/v1/schema?table=${encodeURIComponent(this._table)}`,
1372
+ path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
1039
1373
  signal: opts?.signal
1040
1374
  });
1041
1375
  if (error) return err(error);
@@ -1066,7 +1400,10 @@ var WaveHouseClient = class {
1066
1400
  baseURL: config.baseURL.replace(/\/+$/, ""),
1067
1401
  auth: config.auth,
1068
1402
  options: {
1069
- maxRetries: config.options?.maxRetries ?? 2
1403
+ maxRetries: config.options?.maxRetries ?? 2,
1404
+ fetch: config.options?.fetch,
1405
+ headers: config.options?.headers,
1406
+ fetchOptions: config.options?.fetchOptions
1070
1407
  }
1071
1408
  };
1072
1409
  this.schema = new SchemaNamespace(this._ctx);
@@ -1098,23 +1435,21 @@ var WaveHouseClient = class {
1098
1435
  sql(query, opts) {
1099
1436
  if (Array.isArray(opts)) {
1100
1437
  throw new Error(
1101
- "[WaveHouse SDK] client.sql(sql, params) was removed. The /v1/admin/query endpoint does not accept positional `?` params. Inline literals into the SQL, or use the structured query builder (wh.from(table)\u2026) for safe binding from user input."
1438
+ "[WaveHouse SDK] client.sql(sql, params) was removed. The /v1/ops/query endpoint does not accept positional `?` params. Inline literals into the SQL, or use the structured query builder (wh.from(table)\u2026) for safe binding from user input."
1102
1439
  );
1103
1440
  }
1104
1441
  return sql(this._ctx, query, opts);
1105
1442
  }
1106
1443
  /** @internal Create a stream for the given table. */
1107
1444
  _createStream(table, opts) {
1108
- if (typeof EventSource === "undefined") {
1109
- throw new Error(
1110
- "[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
1111
- );
1112
- }
1113
1445
  const transport = new SSETransport({
1114
1446
  baseURL: this._ctx.baseURL,
1115
1447
  table,
1116
1448
  since: opts?.since,
1117
- auth: this._ctx.auth
1449
+ auth: this._ctx.auth,
1450
+ fetch: this._ctx.options.fetch,
1451
+ headers: this._ctx.options.headers,
1452
+ fetchOptions: this._ctx.options.fetchOptions
1118
1453
  });
1119
1454
  const controller = new StreamController(transport);
1120
1455
  if (opts?.signal) controller.attachSignal(opts.signal);