@wavehouse/sdk 0.0.0-dev.h825a5d7fa405 → 0.0.0-dev.h9539004107f0

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,29 +35,71 @@ 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
- "Content-Type": "application/json",
79
+ "Content-Type": opts.contentType ?? "application/json",
43
80
  Accept: "application/json"
44
81
  };
82
+ const requestBody = opts.rawBody !== void 0 ? opts.rawBody : opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
45
83
  if (ctx.auth) {
46
84
  const token = await ctx.auth();
47
85
  if (token) {
48
86
  headers.Authorization = `Bearer ${token}`;
49
87
  }
50
88
  }
89
+ const finalHeaders = mergeHeaders(headers, ctx.options.headers);
51
90
  let lastError = null;
52
91
  const maxAttempts = ctx.options.maxRetries + 1;
53
92
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
54
93
  try {
55
- const res = await fetch(url, {
94
+ const init = {
95
+ ...ctx.options.fetchOptions,
56
96
  method: opts.method,
57
- headers,
58
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
97
+ headers: finalHeaders,
98
+ body: requestBody,
59
99
  signal: opts.signal
60
- });
100
+ };
101
+ const doFetch = ctx.options.fetch;
102
+ const res = doFetch ? await doFetch(url, init) : await fetch(url, init);
61
103
  if (res.ok) {
62
104
  const text = await res.text();
63
105
  const data = text ? JSON.parse(text) : void 0;
@@ -80,30 +122,24 @@ async function request(ctx, opts) {
80
122
  }
81
123
  return { data: null, error, headers: res.headers };
82
124
  } catch (e) {
83
- if (e instanceof DOMException && e.name === "AbortError") {
84
- return {
85
- data: null,
86
- error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
87
- headers: new Headers()
88
- };
125
+ if (opts.signal?.aborted) {
126
+ return abortedResult();
89
127
  }
90
128
  lastError = networkError(e);
91
129
  if (attempt < maxAttempts - 1) {
92
- 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
+ }
93
138
  }
94
139
  }
95
140
  }
96
141
  return { data: null, error: lastError, headers: new Headers() };
97
142
  }
98
- function buildURL(base, path, params) {
99
- const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
100
- if (params) {
101
- for (const [k, v] of Object.entries(params)) {
102
- url.searchParams.set(k, v);
103
- }
104
- }
105
- return url.toString();
106
- }
107
143
  function backoff(attempt) {
108
144
  return Math.min(1e3 * 2 ** attempt, 3e4);
109
145
  }
@@ -137,7 +173,7 @@ var DLQNamespace = class {
137
173
  async list(opts) {
138
174
  const { data, error } = await request(this._ctx, {
139
175
  method: "GET",
140
- path: "/v1/dlq/stats",
176
+ path: "/v1/ops/dlq/stats",
141
177
  signal: opts?.signal
142
178
  });
143
179
  if (error) return err(error);
@@ -147,7 +183,7 @@ var DLQNamespace = class {
147
183
  async table(name, opts) {
148
184
  const { data, error } = await request(this._ctx, {
149
185
  method: "GET",
150
- path: "/v1/dlq/stats",
186
+ path: "/v1/ops/dlq/stats",
151
187
  params: { table: name },
152
188
  signal: opts?.signal
153
189
  });
@@ -172,7 +208,14 @@ var PipeRef = class {
172
208
  this._params = params;
173
209
  this._createStream = createStream;
174
210
  }
175
- /** 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
+ */
176
219
  async fetch(opts) {
177
220
  const { data, error } = await request(this._ctx, {
178
221
  method: "POST",
@@ -200,7 +243,7 @@ var PipesNamespace = class {
200
243
  async list(opts) {
201
244
  const { data, error } = await request(this._ctx, {
202
245
  method: "GET",
203
- path: "/v1/admin/pipes",
246
+ path: "/v1/ops/pipes",
204
247
  signal: opts?.signal
205
248
  });
206
249
  if (error) return err(error);
@@ -210,7 +253,7 @@ var PipesNamespace = class {
210
253
  async get(name, opts) {
211
254
  const { data, error } = await request(this._ctx, {
212
255
  method: "GET",
213
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
256
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
214
257
  signal: opts?.signal
215
258
  });
216
259
  if (error) return err(error);
@@ -220,7 +263,7 @@ var PipesNamespace = class {
220
263
  async set(name, def, opts) {
221
264
  const { error } = await request(this._ctx, {
222
265
  method: "PUT",
223
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
266
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
224
267
  body: def,
225
268
  signal: opts?.signal
226
269
  });
@@ -231,7 +274,7 @@ var PipesNamespace = class {
231
274
  async delete(name, opts) {
232
275
  const { error } = await request(this._ctx, {
233
276
  method: "DELETE",
234
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
277
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
235
278
  signal: opts?.signal
236
279
  });
237
280
  if (error) return err(error);
@@ -249,7 +292,7 @@ var PolicyNamespace = class {
249
292
  async get(opts) {
250
293
  const { data, error } = await request(this._ctx, {
251
294
  method: "GET",
252
- path: "/v1/admin/policy",
295
+ path: "/v1/ops/policy",
253
296
  signal: opts?.signal
254
297
  });
255
298
  if (error) return err(error);
@@ -259,7 +302,7 @@ var PolicyNamespace = class {
259
302
  async set(policy, opts) {
260
303
  const { error } = await request(this._ctx, {
261
304
  method: "PUT",
262
- path: "/v1/admin/policy",
305
+ path: "/v1/ops/policy",
263
306
  body: policy,
264
307
  signal: opts?.signal
265
308
  });
@@ -270,7 +313,7 @@ var PolicyNamespace = class {
270
313
  async validate(policy, opts) {
271
314
  const { data, error } = await request(this._ctx, {
272
315
  method: "POST",
273
- path: "/v1/admin/policy/validate",
316
+ path: "/v1/ops/policy/validate",
274
317
  body: policy,
275
318
  signal: opts?.signal
276
319
  });
@@ -289,7 +332,7 @@ var SchemaNamespace = class {
289
332
  async list(opts) {
290
333
  const { data, error } = await request(this._ctx, {
291
334
  method: "GET",
292
- path: "/v1/schema",
335
+ path: "/v1/ops/schema",
293
336
  signal: opts?.signal
294
337
  });
295
338
  if (error) return err(error);
@@ -310,7 +353,7 @@ var SchemaNamespace = class {
310
353
  async refresh(opts) {
311
354
  const { error } = await request(this._ctx, {
312
355
  method: "POST",
313
- path: "/v1/schema/refresh",
356
+ path: "/v1/ops/schema/refresh",
314
357
  signal: opts?.signal
315
358
  });
316
359
  if (error) return err(error);
@@ -322,7 +365,7 @@ var SchemaNamespace = class {
322
365
  async function sql(ctx, query, opts) {
323
366
  const { data, error } = await request(ctx, {
324
367
  method: "POST",
325
- path: "/v1/admin/query",
368
+ path: "/v1/ops/query",
326
369
  body: { sql: query },
327
370
  signal: opts?.signal
328
371
  });
@@ -485,11 +528,50 @@ var StreamController = class {
485
528
  };
486
529
 
487
530
  // src/stream/sse.ts
531
+ import { createParser } from "eventsource-parser";
488
532
  var activeSSEConnections = 0;
489
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
+ }
490
546
  var SSETransport = class {
491
547
  _opts;
492
- _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;
493
575
  onEvent = null;
494
576
  onStatus = null;
495
577
  onError = null;
@@ -497,79 +579,332 @@ var SSETransport = class {
497
579
  this._opts = opts;
498
580
  }
499
581
  connect() {
500
- if (typeof EventSource === "undefined") {
582
+ if (this._closed || this._started) return;
583
+ this._started = true;
584
+ if (!this._opts.fetch && typeof fetch === "undefined") {
501
585
  throw new Error(
502
- "[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`."
587
+ );
588
+ }
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.`
503
594
  );
504
595
  }
505
- this._doConnect().catch((err2) => {
506
- this.onError?.({
596
+ this._run().catch((err2) => {
597
+ this._emitError({
507
598
  status: 0,
508
599
  code: "SSE_CONNECT_ERROR",
509
600
  message: err2 instanceof Error ? err2.message : String(err2),
510
- retryable: true
601
+ retryable: false
511
602
  });
603
+ this.disconnect();
512
604
  });
513
605
  }
514
606
  disconnect() {
515
- if (this._es) {
516
- this._es.close();
517
- this._es = null;
518
- 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++;
519
638
  }
520
- this.onStatus?.("closed");
521
639
  }
522
- async _doConnect() {
523
- 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
+ }
524
733
  url.searchParams.set("table", this._opts.table);
525
734
  if (this._opts.since) {
526
735
  url.searchParams.set("since", this._opts.since);
527
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" };
528
742
  if (this._opts.auth) {
529
743
  const token = await this._opts.auth();
530
744
  if (token) {
531
- url.searchParams.set("token", token);
745
+ base.Authorization = `Bearer ${token}`;
532
746
  }
533
747
  }
534
- activeSSEConnections++;
535
- if (activeSSEConnections > SSE_WARN_THRESHOLD) {
536
- console.warn(
537
- `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
538
- );
748
+ if (this._lastEventId) {
749
+ base["Last-Event-ID"] = this._lastEventId;
539
750
  }
540
- this._es = new EventSource(url.toString());
541
- this._es.onopen = () => {
542
- this.onStatus?.("live");
543
- };
544
- this._es.onmessage = (e) => {
545
- try {
546
- const msg = JSON.parse(e.data);
547
- const event = {
548
- table: msg.table_name,
549
- timestamp: msg.received_timestamp,
550
- data: msg.data
551
- };
552
- this.onEvent?.(event);
553
- } catch {
554
- console.warn("[wavehouse] SSE received malformed message:", e.data);
555
- }
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
556
792
  };
557
- this._es.onerror = () => {
558
- if (this._es?.readyState === EventSource.CONNECTING) {
559
- this.onStatus?.("reconnecting");
560
- } else if (this._es?.readyState === EventSource.CLOSED) {
561
- this.onStatus?.("closed");
562
- } else if (this._es?.readyState === EventSource.OPEN) {
563
- this.onStatus?.("live");
564
- } else {
565
- 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({
566
813
  status: 0,
567
- code: "SSE_ERROR",
568
- message: "SSE connection error",
814
+ code: "SSE_PARSE_ERROR",
815
+ message: err2.message,
569
816
  retryable: true
570
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;
571
829
  }
572
- };
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
+ });
573
908
  }
574
909
  };
575
910
 
@@ -690,6 +1025,15 @@ var QueryBuilder = class _QueryBuilder {
690
1025
  select(...columns) {
691
1026
  return this._clone({ columns: [...this._state.columns, ...columns] });
692
1027
  }
1028
+ /**
1029
+ * Select every column the caller's role is allowed to read (the all-columns
1030
+ * wildcard). Use this instead of `.select(...)` when you want a full-row read;
1031
+ * a bare `.fetch()` with no `.select()` does this implicitly. Mutually
1032
+ * exclusive with `.select(...)`.
1033
+ */
1034
+ selectAll() {
1035
+ return this._clone({ selectAll: true });
1036
+ }
693
1037
  where(column, op, value) {
694
1038
  const filter = { column, op: OP_MAP[op], value };
695
1039
  return this._clone({ filters: [...this._state.filters, filter] });
@@ -731,7 +1075,7 @@ var QueryBuilder = class _QueryBuilder {
731
1075
  return this._clone({ cacheTTL: seconds });
732
1076
  }
733
1077
  // --- Execution ---
734
- /** Default row limit when none is specified. Matches backend DefaultMaxRows. */
1078
+ /** Default row limit when none is specified — deliberately tighter than the backend's DefaultMaxRows (10000) safety cap. */
735
1079
  static DEFAULT_LIMIT = 1e3;
736
1080
  async fetch(opts) {
737
1081
  const effectiveLimit = opts?.limit ?? this._state.limit ?? _QueryBuilder.DEFAULT_LIMIT;
@@ -788,8 +1132,16 @@ var QueryBuilder = class _QueryBuilder {
788
1132
  }
789
1133
  _buildAST(effectiveLimit) {
790
1134
  const ast = {};
791
- if (this._state.columns.length > 0) ast.columns = this._state.columns;
792
- if (this._state.aggregations.length > 0) ast.aggregations = this._state.aggregations;
1135
+ const hasColumns = this._state.columns.length > 0;
1136
+ const hasAggs = this._state.aggregations.length > 0;
1137
+ if (this._state.selectAll) {
1138
+ ast.select_all = true;
1139
+ } else if (hasColumns) {
1140
+ ast.columns = this._state.columns;
1141
+ } else if (!hasAggs) {
1142
+ ast.select_all = true;
1143
+ }
1144
+ if (hasAggs) ast.aggregations = this._state.aggregations;
793
1145
  if (this._state.filters.length > 0) ast.filters = this._state.filters;
794
1146
  if (this._state.groupBy.length > 0) ast.group_by = this._state.groupBy;
795
1147
  if (this._state.orderBy.length > 0) ast.order_by = this._state.orderBy;
@@ -899,6 +1251,12 @@ function projectColumns(row, columns) {
899
1251
  }
900
1252
 
901
1253
  // src/table.ts
1254
+ var NDJSON_CONTENT_TYPE = "application/x-ndjson";
1255
+ async function ndjsonSourceToString(source) {
1256
+ if (typeof source === "string") return source;
1257
+ if (source instanceof Uint8Array) return new TextDecoder().decode(source);
1258
+ return new Response(source).text();
1259
+ }
902
1260
  var TableRef = class {
903
1261
  _ctx;
904
1262
  _table;
@@ -927,22 +1285,34 @@ var TableRef = class {
927
1285
  this._createStream
928
1286
  );
929
1287
  }
930
- /** Insert one or more rows into this table. */
1288
+ /**
1289
+ * Start a query that selects every column the caller's role is allowed to read
1290
+ * (the all-columns wildcard). Use instead of `.select(...)` for an explicit
1291
+ * full-row read; `.fetch()` does this implicitly.
1292
+ */
1293
+ selectAll() {
1294
+ return this.select().selectAll();
1295
+ }
1296
+ /**
1297
+ * Insert one or more rows into this table.
1298
+ *
1299
+ * A single object is sent as a JSON `POST /v1/ingest`. An **array** is
1300
+ * serialized to NDJSON (one record per line) and sent as a single
1301
+ * `application/x-ndjson` request: a bad record no longer fails or hides the
1302
+ * rest of the batch — per-record outcomes come back in the result
1303
+ * (`failed` / `results`), and `ok` is true only when every record succeeded.
1304
+ *
1305
+ * The array path sends one request regardless of size; bounded-concurrency
1306
+ * chunking of very large arrays is tracked separately (#196). For NDJSON you
1307
+ * already have (a file or stream), use {@link insertNDJSON}.
1308
+ */
931
1309
  async insert(data, opts) {
932
1310
  if (Array.isArray(data)) {
933
- const promises = data.map(
934
- (row) => request(this._ctx, {
935
- method: "POST",
936
- path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
937
- body: row,
938
- signal: opts?.signal
939
- })
940
- );
941
- const results = await Promise.all(promises);
942
- for (const res2 of results) {
943
- if (res2.error) return err(res2.error);
1311
+ if (data.length === 0) {
1312
+ return ok({ ok: true, total: 0, succeeded: 0, failed: 0, duplicates: 0, results: [] });
944
1313
  }
945
- return ok({ ok: true });
1314
+ const ndjson = data.map((row) => JSON.stringify(row)).join("\n");
1315
+ return this._sendNDJSON(ndjson, opts);
946
1316
  }
947
1317
  const { data: res, error } = await request(this._ctx, {
948
1318
  method: "POST",
@@ -955,11 +1325,51 @@ var TableRef = class {
955
1325
  if (res?.duplicate != null) result.duplicate = res.duplicate;
956
1326
  return ok(result);
957
1327
  }
1328
+ /**
1329
+ * Insert pre-formatted NDJSON (newline-delimited JSON, one record per line)
1330
+ * from a string, raw bytes, a Blob/File, or a byte stream — e.g. a `.ndjson`
1331
+ * file or a stream produced by another system. For in-memory rows, prefer
1332
+ * {@link insert}.
1333
+ *
1334
+ * Non-string sources are read fully into memory before sending (the server
1335
+ * streams the parse). Returns the same per-record batch summary as an array
1336
+ * `insert`.
1337
+ */
1338
+ async insertNDJSON(source, opts) {
1339
+ const ndjson = await ndjsonSourceToString(source);
1340
+ return this._sendNDJSON(ndjson, opts);
1341
+ }
1342
+ /**
1343
+ * @internal Send an NDJSON body and map the server's batch response onto an
1344
+ * {@link InsertResult}. A transport / whole-request failure surfaces as the
1345
+ * Result error arm; a processed batch (even with rejected records) surfaces
1346
+ * as the success arm with `ok = failed === 0`.
1347
+ */
1348
+ async _sendNDJSON(ndjson, opts) {
1349
+ const { data, error } = await request(this._ctx, {
1350
+ method: "POST",
1351
+ path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
1352
+ rawBody: ndjson,
1353
+ contentType: NDJSON_CONTENT_TYPE,
1354
+ signal: opts?.signal
1355
+ });
1356
+ if (error) return err(error);
1357
+ const r = data ?? { total: 0, succeeded: 0, failed: 0, duplicates: 0 };
1358
+ const result = {
1359
+ ok: r.failed === 0,
1360
+ total: r.total,
1361
+ succeeded: r.succeeded,
1362
+ failed: r.failed,
1363
+ duplicates: r.duplicates
1364
+ };
1365
+ if (r.results && r.results.length > 0) result.results = r.results;
1366
+ return ok(result);
1367
+ }
958
1368
  /** Fetch the schema for this table. */
959
1369
  async schema(opts) {
960
1370
  const { data, error } = await request(this._ctx, {
961
1371
  method: "GET",
962
- path: `/v1/schema?table=${encodeURIComponent(this._table)}`,
1372
+ path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
963
1373
  signal: opts?.signal
964
1374
  });
965
1375
  if (error) return err(error);
@@ -990,7 +1400,10 @@ var WaveHouseClient = class {
990
1400
  baseURL: config.baseURL.replace(/\/+$/, ""),
991
1401
  auth: config.auth,
992
1402
  options: {
993
- 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
994
1407
  }
995
1408
  };
996
1409
  this.schema = new SchemaNamespace(this._ctx);
@@ -1022,23 +1435,21 @@ var WaveHouseClient = class {
1022
1435
  sql(query, opts) {
1023
1436
  if (Array.isArray(opts)) {
1024
1437
  throw new Error(
1025
- "[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."
1026
1439
  );
1027
1440
  }
1028
1441
  return sql(this._ctx, query, opts);
1029
1442
  }
1030
1443
  /** @internal Create a stream for the given table. */
1031
1444
  _createStream(table, opts) {
1032
- if (typeof EventSource === "undefined") {
1033
- throw new Error(
1034
- "[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
1035
- );
1036
- }
1037
1445
  const transport = new SSETransport({
1038
1446
  baseURL: this._ctx.baseURL,
1039
1447
  table,
1040
1448
  since: opts?.since,
1041
- 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
1042
1453
  });
1043
1454
  const controller = new StreamController(transport);
1044
1455
  if (opts?.signal) controller.attachSignal(opts.signal);