@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.cjs CHANGED
@@ -72,29 +72,71 @@ function err(error) {
72
72
  return { ok: false, data: null, error };
73
73
  }
74
74
 
75
+ // src/url.ts
76
+ function resolveURL(base, path, params) {
77
+ const root = new URL(base);
78
+ root.search = "";
79
+ root.hash = "";
80
+ if (!root.pathname.endsWith("/")) root.pathname += "/";
81
+ const url = new URL(path.replace(/^\/+/, ""), root);
82
+ if (params) {
83
+ for (const [k, v] of Object.entries(params)) {
84
+ url.searchParams.set(k, v);
85
+ }
86
+ }
87
+ return url;
88
+ }
89
+
75
90
  // src/http.ts
91
+ function mergeHeaders(base, extra) {
92
+ if (!extra) return base;
93
+ const sdkNames = new Set(Object.keys(base).map((k) => k.toLowerCase()));
94
+ const merged = { ...base };
95
+ const configured = /* @__PURE__ */ new Map();
96
+ for (const [name, value] of Object.entries(extra)) {
97
+ const lower = name.toLowerCase();
98
+ if (sdkNames.has(lower)) continue;
99
+ const prior = configured.get(lower);
100
+ if (prior !== void 0) delete merged[prior];
101
+ merged[name] = value;
102
+ configured.set(lower, name);
103
+ }
104
+ return merged;
105
+ }
106
+ function abortedResult() {
107
+ return {
108
+ data: null,
109
+ error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
110
+ headers: new Headers()
111
+ };
112
+ }
76
113
  async function request(ctx, opts) {
77
- const url = buildURL(ctx.baseURL, opts.path, opts.params);
114
+ const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
78
115
  const headers = {
79
- "Content-Type": "application/json",
116
+ "Content-Type": opts.contentType ?? "application/json",
80
117
  Accept: "application/json"
81
118
  };
119
+ const requestBody = opts.rawBody !== void 0 ? opts.rawBody : opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
82
120
  if (ctx.auth) {
83
121
  const token = await ctx.auth();
84
122
  if (token) {
85
123
  headers.Authorization = `Bearer ${token}`;
86
124
  }
87
125
  }
126
+ const finalHeaders = mergeHeaders(headers, ctx.options.headers);
88
127
  let lastError = null;
89
128
  const maxAttempts = ctx.options.maxRetries + 1;
90
129
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
91
130
  try {
92
- const res = await fetch(url, {
131
+ const init = {
132
+ ...ctx.options.fetchOptions,
93
133
  method: opts.method,
94
- headers,
95
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
134
+ headers: finalHeaders,
135
+ body: requestBody,
96
136
  signal: opts.signal
97
- });
137
+ };
138
+ const doFetch = ctx.options.fetch;
139
+ const res = doFetch ? await doFetch(url, init) : await fetch(url, init);
98
140
  if (res.ok) {
99
141
  const text = await res.text();
100
142
  const data = text ? JSON.parse(text) : void 0;
@@ -117,30 +159,24 @@ async function request(ctx, opts) {
117
159
  }
118
160
  return { data: null, error, headers: res.headers };
119
161
  } catch (e) {
120
- if (e instanceof DOMException && e.name === "AbortError") {
121
- return {
122
- data: null,
123
- error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
124
- headers: new Headers()
125
- };
162
+ if (opts.signal?.aborted) {
163
+ return abortedResult();
126
164
  }
127
165
  lastError = networkError(e);
128
166
  if (attempt < maxAttempts - 1) {
129
- await sleep(backoff(attempt), opts.signal);
167
+ try {
168
+ await sleep(backoff(attempt), opts.signal);
169
+ } catch (abort) {
170
+ if (opts.signal?.aborted) {
171
+ return abortedResult();
172
+ }
173
+ throw abort;
174
+ }
130
175
  }
131
176
  }
132
177
  }
133
178
  return { data: null, error: lastError, headers: new Headers() };
134
179
  }
135
- function buildURL(base, path, params) {
136
- const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
137
- if (params) {
138
- for (const [k, v] of Object.entries(params)) {
139
- url.searchParams.set(k, v);
140
- }
141
- }
142
- return url.toString();
143
- }
144
180
  function backoff(attempt) {
145
181
  return Math.min(1e3 * 2 ** attempt, 3e4);
146
182
  }
@@ -174,7 +210,7 @@ var DLQNamespace = class {
174
210
  async list(opts) {
175
211
  const { data, error } = await request(this._ctx, {
176
212
  method: "GET",
177
- path: "/v1/dlq/stats",
213
+ path: "/v1/ops/dlq/stats",
178
214
  signal: opts?.signal
179
215
  });
180
216
  if (error) return err(error);
@@ -184,7 +220,7 @@ var DLQNamespace = class {
184
220
  async table(name, opts) {
185
221
  const { data, error } = await request(this._ctx, {
186
222
  method: "GET",
187
- path: "/v1/dlq/stats",
223
+ path: "/v1/ops/dlq/stats",
188
224
  params: { table: name },
189
225
  signal: opts?.signal
190
226
  });
@@ -209,7 +245,14 @@ var PipeRef = class {
209
245
  this._params = params;
210
246
  this._createStream = createStream;
211
247
  }
212
- /** Execute the pipe and return results. */
248
+ /**
249
+ * Execute the pipe and return results.
250
+ *
251
+ * Takes only `signal` — deliberately narrower than the `RequestOptions` the
252
+ * query builder accepts. The pipes endpoint binds the body as the pipe's
253
+ * parameters, so there is no row cap to forward; give the pipe a `{{limit}}`
254
+ * parameter in its SQL and pass it via `wh.pipe(name, { limit })`.
255
+ */
213
256
  async fetch(opts) {
214
257
  const { data, error } = await request(this._ctx, {
215
258
  method: "POST",
@@ -237,7 +280,7 @@ var PipesNamespace = class {
237
280
  async list(opts) {
238
281
  const { data, error } = await request(this._ctx, {
239
282
  method: "GET",
240
- path: "/v1/admin/pipes",
283
+ path: "/v1/ops/pipes",
241
284
  signal: opts?.signal
242
285
  });
243
286
  if (error) return err(error);
@@ -247,7 +290,7 @@ var PipesNamespace = class {
247
290
  async get(name, opts) {
248
291
  const { data, error } = await request(this._ctx, {
249
292
  method: "GET",
250
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
293
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
251
294
  signal: opts?.signal
252
295
  });
253
296
  if (error) return err(error);
@@ -257,7 +300,7 @@ var PipesNamespace = class {
257
300
  async set(name, def, opts) {
258
301
  const { error } = await request(this._ctx, {
259
302
  method: "PUT",
260
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
303
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
261
304
  body: def,
262
305
  signal: opts?.signal
263
306
  });
@@ -268,7 +311,7 @@ var PipesNamespace = class {
268
311
  async delete(name, opts) {
269
312
  const { error } = await request(this._ctx, {
270
313
  method: "DELETE",
271
- path: `/v1/admin/pipes/${encodeURIComponent(name)}`,
314
+ path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
272
315
  signal: opts?.signal
273
316
  });
274
317
  if (error) return err(error);
@@ -286,7 +329,7 @@ var PolicyNamespace = class {
286
329
  async get(opts) {
287
330
  const { data, error } = await request(this._ctx, {
288
331
  method: "GET",
289
- path: "/v1/admin/policy",
332
+ path: "/v1/ops/policy",
290
333
  signal: opts?.signal
291
334
  });
292
335
  if (error) return err(error);
@@ -296,7 +339,7 @@ var PolicyNamespace = class {
296
339
  async set(policy, opts) {
297
340
  const { error } = await request(this._ctx, {
298
341
  method: "PUT",
299
- path: "/v1/admin/policy",
342
+ path: "/v1/ops/policy",
300
343
  body: policy,
301
344
  signal: opts?.signal
302
345
  });
@@ -307,7 +350,7 @@ var PolicyNamespace = class {
307
350
  async validate(policy, opts) {
308
351
  const { data, error } = await request(this._ctx, {
309
352
  method: "POST",
310
- path: "/v1/admin/policy/validate",
353
+ path: "/v1/ops/policy/validate",
311
354
  body: policy,
312
355
  signal: opts?.signal
313
356
  });
@@ -326,7 +369,7 @@ var SchemaNamespace = class {
326
369
  async list(opts) {
327
370
  const { data, error } = await request(this._ctx, {
328
371
  method: "GET",
329
- path: "/v1/schema",
372
+ path: "/v1/ops/schema",
330
373
  signal: opts?.signal
331
374
  });
332
375
  if (error) return err(error);
@@ -347,7 +390,7 @@ var SchemaNamespace = class {
347
390
  async refresh(opts) {
348
391
  const { error } = await request(this._ctx, {
349
392
  method: "POST",
350
- path: "/v1/schema/refresh",
393
+ path: "/v1/ops/schema/refresh",
351
394
  signal: opts?.signal
352
395
  });
353
396
  if (error) return err(error);
@@ -359,7 +402,7 @@ var SchemaNamespace = class {
359
402
  async function sql(ctx, query, opts) {
360
403
  const { data, error } = await request(ctx, {
361
404
  method: "POST",
362
- path: "/v1/admin/query",
405
+ path: "/v1/ops/query",
363
406
  body: { sql: query },
364
407
  signal: opts?.signal
365
408
  });
@@ -522,11 +565,50 @@ var StreamController = class {
522
565
  };
523
566
 
524
567
  // src/stream/sse.ts
568
+ var import_eventsource_parser = require("eventsource-parser");
525
569
  var activeSSEConnections = 0;
526
570
  var SSE_WARN_THRESHOLD = 5;
571
+ var MAX_BUFFER_CHARS = 16 * 1024 * 1024;
572
+ var MAX_BACKOFF_MS = 3e4;
573
+ var STABLE_CONNECTION_MS = 3e3;
574
+ function backoff2(attempt, retryFloorMs) {
575
+ const nominal = Math.max(retryFloorMs, Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS));
576
+ const low = Math.max(retryFloorMs, nominal / 2);
577
+ return low + Math.random() * (nominal - low);
578
+ }
579
+ function releaseBody(res) {
580
+ if (res.body && !res.body.locked) res.body.cancel().catch(() => {
581
+ });
582
+ }
527
583
  var SSETransport = class {
528
584
  _opts;
529
- _es = null;
585
+ _abort = null;
586
+ /**
587
+ * Set by `disconnect()` before it calls `abort()` — both synchronously — so
588
+ * a rejection caused by our own abort always observes this as `true`. That
589
+ * makes it a complete and precise test for "did we cause this?", which is
590
+ * why the catch blocks read it directly instead of matching on the error
591
+ * type: an `AbortError` we *didn't* raise (a custom `fetch` enforcing a
592
+ * per-attempt deadline, an `auth()` call cancelled by its own controller) is
593
+ * a transient failure to re-dial, exactly as on the REST path. Treating one
594
+ * as a teardown killed the stream permanently, and silently.
595
+ */
596
+ _closed = false;
597
+ _started = false;
598
+ _counted = false;
599
+ /**
600
+ * Most recent non-empty event id, replayed as `Last-Event-ID` on reconnect.
601
+ *
602
+ * Deliberately ignores empty ids. The server emits a blank `id:` line for
603
+ * passthrough payloads, and per the SSE spec an empty `id` field *clears* the
604
+ * last-event-id — so tracking it faithfully would silently discard the
605
+ * resumption point mid-stream and re-open from the beginning.
606
+ */
607
+ _lastEventId = null;
608
+ /** Reconnect floor most recently requested by the server via `retry:`. */
609
+ _retryFloorMs = 0;
610
+ /** Cuts a reconnect gap short when set — see `_sleep`. */
611
+ _wake = null;
530
612
  onEvent = null;
531
613
  onStatus = null;
532
614
  onError = null;
@@ -534,79 +616,332 @@ var SSETransport = class {
534
616
  this._opts = opts;
535
617
  }
536
618
  connect() {
537
- if (typeof EventSource === "undefined") {
619
+ if (this._closed || this._started) return;
620
+ this._started = true;
621
+ if (!this._opts.fetch && typeof fetch === "undefined") {
538
622
  throw new Error(
539
- "[wavehouse] EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
623
+ "[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
624
+ );
625
+ }
626
+ activeSSEConnections++;
627
+ this._counted = true;
628
+ if (activeSSEConnections > SSE_WARN_THRESHOLD) {
629
+ console.warn(
630
+ `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
540
631
  );
541
632
  }
542
- this._doConnect().catch((err2) => {
543
- this.onError?.({
633
+ this._run().catch((err2) => {
634
+ this._emitError({
544
635
  status: 0,
545
636
  code: "SSE_CONNECT_ERROR",
546
637
  message: err2 instanceof Error ? err2.message : String(err2),
547
- retryable: true
638
+ retryable: false
548
639
  });
640
+ this.disconnect();
549
641
  });
550
642
  }
551
643
  disconnect() {
552
- if (this._es) {
553
- this._es.close();
554
- this._es = null;
555
- activeSSEConnections = Math.max(0, activeSSEConnections - 1);
644
+ if (!this._closed) {
645
+ this._closed = true;
646
+ this._abort?.abort();
647
+ this._abort = null;
648
+ this._wake?.();
649
+ if (this._counted) {
650
+ activeSSEConnections = Math.max(0, activeSSEConnections - 1);
651
+ this._counted = false;
652
+ }
653
+ try {
654
+ this.onStatus?.("closed");
655
+ } catch (e) {
656
+ console.error("[wavehouse] SSE status handler threw:", e);
657
+ }
658
+ }
659
+ }
660
+ /** Connect/read/reconnect until the stream is closed or hits a terminal error. */
661
+ async _run() {
662
+ await Promise.resolve();
663
+ let attempt = 0;
664
+ while (!this._closed) {
665
+ const { liveMs, terminal } = await this._attempt();
666
+ if (this._closed) return;
667
+ if (terminal) {
668
+ this.disconnect();
669
+ return;
670
+ }
671
+ if (liveMs >= STABLE_CONNECTION_MS) attempt = 0;
672
+ this._emitStatus("reconnecting");
673
+ await this._sleep(backoff2(attempt, this._retryFloorMs));
674
+ attempt++;
556
675
  }
557
- this.onStatus?.("closed");
558
676
  }
559
- async _doConnect() {
560
- const url = new URL("/v1/stream", this._opts.baseURL);
677
+ /** One connection attempt: request, validate, then pump frames until it ends. */
678
+ async _attempt() {
679
+ const ac = new AbortController();
680
+ this._abort = ac;
681
+ let target;
682
+ try {
683
+ target = this._url();
684
+ } catch (e) {
685
+ this._emitError({
686
+ status: 0,
687
+ code: "SSE_CONNECT_ERROR",
688
+ message: e instanceof Error ? e.message : String(e),
689
+ retryable: false
690
+ });
691
+ return { liveMs: 0, terminal: true };
692
+ }
693
+ let init;
694
+ try {
695
+ init = await this._init(ac.signal);
696
+ } catch (e) {
697
+ if (this._closed) return { liveMs: 0, terminal: true };
698
+ this._emitError({
699
+ status: 0,
700
+ code: "SSE_AUTH_ERROR",
701
+ message: e instanceof Error ? e.message : String(e),
702
+ retryable: true
703
+ });
704
+ return { liveMs: 0, terminal: false };
705
+ }
706
+ let res;
707
+ try {
708
+ const doFetch = this._opts.fetch;
709
+ res = doFetch ? await doFetch(target, init) : await fetch(target, init);
710
+ } catch (e) {
711
+ if (this._closed) return { liveMs: 0, terminal: true };
712
+ this._emitError({
713
+ status: 0,
714
+ code: "SSE_NETWORK_ERROR",
715
+ message: e instanceof Error ? e.message : String(e),
716
+ retryable: true
717
+ });
718
+ return { liveMs: 0, terminal: false };
719
+ }
720
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
721
+ this._emitError({
722
+ status: res.status,
723
+ code: "SSE_REDIRECT",
724
+ 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`.",
725
+ retryable: false
726
+ });
727
+ releaseBody(res);
728
+ return { liveMs: 0, terminal: true };
729
+ }
730
+ if (!res.ok) {
731
+ const error = await parseErrorResponse(res);
732
+ this._emitError(error);
733
+ return { liveMs: 0, terminal: !error.retryable };
734
+ }
735
+ const contentType = res.headers.get("content-type") ?? "";
736
+ if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
737
+ this._emitError({
738
+ status: res.status,
739
+ code: "SSE_BAD_CONTENT_TYPE",
740
+ 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.`,
741
+ retryable: false
742
+ });
743
+ releaseBody(res);
744
+ return { liveMs: 0, terminal: true };
745
+ }
746
+ const body = res.body;
747
+ if (!body || typeof body.getReader !== "function" || body.locked || res.bodyUsed) {
748
+ this._emitError({
749
+ status: res.status,
750
+ code: "SSE_NO_STREAM_BODY",
751
+ 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()`.",
752
+ retryable: false
753
+ });
754
+ releaseBody(res);
755
+ return { liveMs: 0, terminal: true };
756
+ }
757
+ this._emitStatus("live");
758
+ const openedAt = Date.now();
759
+ await this._pump(body);
760
+ return { liveMs: Date.now() - openedAt, terminal: false };
761
+ }
762
+ /** Resolve the stream URL. Throws on a `baseURL` that isn't a usable http(s) origin. */
763
+ _url() {
764
+ const url = resolveURL(this._opts.baseURL, "/v1/stream");
765
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
766
+ throw new Error(
767
+ `baseURL must use http or https, got "${url.protocol}". Streaming is plain HTTP \u2014 there is no WebSocket endpoint.`
768
+ );
769
+ }
561
770
  url.searchParams.set("table", this._opts.table);
562
771
  if (this._opts.since) {
563
772
  url.searchParams.set("since", this._opts.since);
564
773
  }
774
+ return url.toString();
775
+ }
776
+ /** Build the request init, minting a token for this attempt. */
777
+ async _init(signal) {
778
+ const base = { Accept: "text/event-stream" };
565
779
  if (this._opts.auth) {
566
780
  const token = await this._opts.auth();
567
781
  if (token) {
568
- url.searchParams.set("token", token);
782
+ base.Authorization = `Bearer ${token}`;
569
783
  }
570
784
  }
571
- activeSSEConnections++;
572
- if (activeSSEConnections > SSE_WARN_THRESHOLD) {
573
- console.warn(
574
- `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
575
- );
785
+ if (this._lastEventId) {
786
+ base["Last-Event-ID"] = this._lastEventId;
576
787
  }
577
- this._es = new EventSource(url.toString());
578
- this._es.onopen = () => {
579
- this.onStatus?.("live");
580
- };
581
- this._es.onmessage = (e) => {
582
- try {
583
- const msg = JSON.parse(e.data);
584
- const event = {
585
- table: msg.table_name,
586
- timestamp: msg.received_timestamp,
587
- data: msg.data
588
- };
589
- this.onEvent?.(event);
590
- } catch {
591
- console.warn("[wavehouse] SSE received malformed message:", e.data);
592
- }
788
+ const credentialed = base.Authorization !== void 0 || Object.keys(this._opts.headers ?? {}).length > 0;
789
+ const init = {
790
+ ...this._opts.fetchOptions,
791
+ method: "GET",
792
+ headers: mergeHeaders(base, this._opts.headers),
793
+ signal,
794
+ // Owned by the SDK rather than left to `fetchOptions`, unlike the REST
795
+ // path, because on a stream these are correctness rather than preference:
796
+ //
797
+ // cache — as an init field, not a `Cache-Control` header. The header
798
+ // form is not in the server's Access-Control-Allow-Headers,
799
+ // so it would fail every cross-origin preflight.
800
+ // redirect — followed only when this request carries no credential.
801
+ // Platforms strip `Authorization` on a cross-origin redirect
802
+ // (whatwg/fetch#1544) and forward other headers intact, so a
803
+ // credentialed hop either silently downgrades the stream to
804
+ // the default role — this endpoint answers an unauthenticated
805
+ // caller rather than rejecting them — or delivers a configured
806
+ // secret to whatever the redirect names. Neither is worth a
807
+ // convenience. Without either, redirects are followed, so CDN
808
+ // canonicalization, geo/LB indirection, and an http→https
809
+ // upgrade all just work. The test is deliberately that
810
+ // concrete pair and not "is this authenticated": cookies do
811
+ // not appear in it. A cookie is re-derived per
812
+ // destination rather than carried, so under the default
813
+ // same-origin credentials mode it survives a redirect only
814
+ // while the hop stays inside both the request's origin and
815
+ // the cookie's Path; outside either the stream is
816
+ // silently unauthed. See #478 and the SDK guide under
817
+ // "Supplying your own fetch".
818
+ // `manual` over `error` for the credentialed case so the
819
+ // outcome stays inspectable: `error` rejects with a bare
820
+ // TypeError indistinguishable from a connection failure, which
821
+ // the loop above would retry forever against a redirect that
822
+ // is never going to stop happening.
823
+ cache: "no-store",
824
+ redirect: credentialed ? "manual" : "follow",
825
+ // Neutralized like the REST path does, and for a sharper reason here: a
826
+ // body on a GET makes `fetch` throw, which reads as a network failure and
827
+ // would be retried forever against a request that can never be built.
828
+ body: void 0
593
829
  };
594
- this._es.onerror = () => {
595
- if (this._es?.readyState === EventSource.CONNECTING) {
596
- this.onStatus?.("reconnecting");
597
- } else if (this._es?.readyState === EventSource.CLOSED) {
598
- this.onStatus?.("closed");
599
- } else if (this._es?.readyState === EventSource.OPEN) {
600
- this.onStatus?.("live");
601
- } else {
602
- this.onError?.({
830
+ if (!("window" in globalThis)) {
831
+ delete init.credentials;
832
+ }
833
+ return init;
834
+ }
835
+ /** Decode and parse frames until the stream ends, aborts, or errors. */
836
+ async _pump(body) {
837
+ let overflowed = false;
838
+ const parser = (0, import_eventsource_parser.createParser)({
839
+ onEvent: (msg) => {
840
+ if (msg.id) this._lastEventId = msg.id;
841
+ if (!msg.data) return;
842
+ this._dispatch(msg.data);
843
+ },
844
+ onRetry: (ms) => {
845
+ this._retryFloorMs = Math.min(Math.max(ms, 0), MAX_BACKOFF_MS);
846
+ },
847
+ onError: (err2) => {
848
+ if (err2.type === "max-buffer-size-exceeded") overflowed = true;
849
+ this._emitError({
603
850
  status: 0,
604
- code: "SSE_ERROR",
605
- message: "SSE connection error",
851
+ code: "SSE_PARSE_ERROR",
852
+ message: err2.message,
606
853
  retryable: true
607
854
  });
855
+ },
856
+ maxBufferSize: MAX_BUFFER_CHARS
857
+ });
858
+ const reader = body.getReader();
859
+ const decoder = new TextDecoder();
860
+ try {
861
+ for (; ; ) {
862
+ const { done, value } = await reader.read();
863
+ if (done) return;
864
+ parser.feed(decoder.decode(value, { stream: true }));
865
+ if (overflowed) return;
608
866
  }
609
- };
867
+ } catch (e) {
868
+ if (this._closed) return;
869
+ this._emitError({
870
+ status: 0,
871
+ code: "SSE_READ_ERROR",
872
+ message: e instanceof Error ? e.message : String(e),
873
+ retryable: true
874
+ });
875
+ } finally {
876
+ reader.cancel().catch(() => {
877
+ });
878
+ }
879
+ }
880
+ /**
881
+ * Turn one frame's `data` into a StreamEvent.
882
+ *
883
+ * Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
884
+ * every complete frame in a chunk synchronously, so a subscriber that closes
885
+ * from inside its own `next` — "read until I see my event, then stop" —
886
+ * would otherwise keep receiving the rest of that chunk. `EventSource` ended
887
+ * delivery on `close()`, and gap-fill replay routinely lands many frames in
888
+ * one chunk.
889
+ */
890
+ _dispatch(data) {
891
+ if (this._closed) return;
892
+ let event;
893
+ try {
894
+ const msg = JSON.parse(data);
895
+ event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
896
+ } catch {
897
+ console.warn("[wavehouse] SSE received malformed message:", data);
898
+ return;
899
+ }
900
+ try {
901
+ this.onEvent?.(event);
902
+ } catch (e) {
903
+ console.error("[wavehouse] SSE event handler threw:", e);
904
+ }
905
+ }
906
+ /** Suppress callbacks that race a `disconnect()`. */
907
+ _emitError(error) {
908
+ if (this._closed) return;
909
+ try {
910
+ this.onError?.(error);
911
+ } catch (e) {
912
+ console.error("[wavehouse] SSE error handler threw:", e);
913
+ }
914
+ }
915
+ /**
916
+ * Same guard for status. Aborting does not retract an already-resolved
917
+ * `Response`, so a `disconnect()` landing between the fetch settling and the
918
+ * "live" below would otherwise flip a closed controller back to live — and it
919
+ * stays there, since the loop then exits without emitting anything further.
920
+ * `disconnect()` calls `onStatus` directly; that terminal "closed" is the one
921
+ * status that must always get through.
922
+ */
923
+ _emitStatus(status) {
924
+ if (this._closed) return;
925
+ try {
926
+ this.onStatus?.(status);
927
+ } catch (e) {
928
+ console.error("[wavehouse] SSE status handler threw:", e);
929
+ }
930
+ }
931
+ /** Wait out a reconnect gap, cut short by `disconnect()`. */
932
+ _sleep(ms) {
933
+ if (this._closed) return Promise.resolve();
934
+ return new Promise((resolve) => {
935
+ const timer = setTimeout(() => {
936
+ this._wake = null;
937
+ resolve();
938
+ }, ms);
939
+ this._wake = () => {
940
+ clearTimeout(timer);
941
+ this._wake = null;
942
+ resolve();
943
+ };
944
+ });
610
945
  }
611
946
  };
612
947
 
@@ -727,6 +1062,15 @@ var QueryBuilder = class _QueryBuilder {
727
1062
  select(...columns) {
728
1063
  return this._clone({ columns: [...this._state.columns, ...columns] });
729
1064
  }
1065
+ /**
1066
+ * Select every column the caller's role is allowed to read (the all-columns
1067
+ * wildcard). Use this instead of `.select(...)` when you want a full-row read;
1068
+ * a bare `.fetch()` with no `.select()` does this implicitly. Mutually
1069
+ * exclusive with `.select(...)`.
1070
+ */
1071
+ selectAll() {
1072
+ return this._clone({ selectAll: true });
1073
+ }
730
1074
  where(column, op, value) {
731
1075
  const filter = { column, op: OP_MAP[op], value };
732
1076
  return this._clone({ filters: [...this._state.filters, filter] });
@@ -768,7 +1112,7 @@ var QueryBuilder = class _QueryBuilder {
768
1112
  return this._clone({ cacheTTL: seconds });
769
1113
  }
770
1114
  // --- Execution ---
771
- /** Default row limit when none is specified. Matches backend DefaultMaxRows. */
1115
+ /** Default row limit when none is specified — deliberately tighter than the backend's DefaultMaxRows (10000) safety cap. */
772
1116
  static DEFAULT_LIMIT = 1e3;
773
1117
  async fetch(opts) {
774
1118
  const effectiveLimit = opts?.limit ?? this._state.limit ?? _QueryBuilder.DEFAULT_LIMIT;
@@ -825,8 +1169,16 @@ var QueryBuilder = class _QueryBuilder {
825
1169
  }
826
1170
  _buildAST(effectiveLimit) {
827
1171
  const ast = {};
828
- if (this._state.columns.length > 0) ast.columns = this._state.columns;
829
- if (this._state.aggregations.length > 0) ast.aggregations = this._state.aggregations;
1172
+ const hasColumns = this._state.columns.length > 0;
1173
+ const hasAggs = this._state.aggregations.length > 0;
1174
+ if (this._state.selectAll) {
1175
+ ast.select_all = true;
1176
+ } else if (hasColumns) {
1177
+ ast.columns = this._state.columns;
1178
+ } else if (!hasAggs) {
1179
+ ast.select_all = true;
1180
+ }
1181
+ if (hasAggs) ast.aggregations = this._state.aggregations;
830
1182
  if (this._state.filters.length > 0) ast.filters = this._state.filters;
831
1183
  if (this._state.groupBy.length > 0) ast.group_by = this._state.groupBy;
832
1184
  if (this._state.orderBy.length > 0) ast.order_by = this._state.orderBy;
@@ -936,6 +1288,12 @@ function projectColumns(row, columns) {
936
1288
  }
937
1289
 
938
1290
  // src/table.ts
1291
+ var NDJSON_CONTENT_TYPE = "application/x-ndjson";
1292
+ async function ndjsonSourceToString(source) {
1293
+ if (typeof source === "string") return source;
1294
+ if (source instanceof Uint8Array) return new TextDecoder().decode(source);
1295
+ return new Response(source).text();
1296
+ }
939
1297
  var TableRef = class {
940
1298
  _ctx;
941
1299
  _table;
@@ -964,22 +1322,34 @@ var TableRef = class {
964
1322
  this._createStream
965
1323
  );
966
1324
  }
967
- /** Insert one or more rows into this table. */
1325
+ /**
1326
+ * Start a query that selects every column the caller's role is allowed to read
1327
+ * (the all-columns wildcard). Use instead of `.select(...)` for an explicit
1328
+ * full-row read; `.fetch()` does this implicitly.
1329
+ */
1330
+ selectAll() {
1331
+ return this.select().selectAll();
1332
+ }
1333
+ /**
1334
+ * Insert one or more rows into this table.
1335
+ *
1336
+ * A single object is sent as a JSON `POST /v1/ingest`. An **array** is
1337
+ * serialized to NDJSON (one record per line) and sent as a single
1338
+ * `application/x-ndjson` request: a bad record no longer fails or hides the
1339
+ * rest of the batch — per-record outcomes come back in the result
1340
+ * (`failed` / `results`), and `ok` is true only when every record succeeded.
1341
+ *
1342
+ * The array path sends one request regardless of size; bounded-concurrency
1343
+ * chunking of very large arrays is tracked separately (#196). For NDJSON you
1344
+ * already have (a file or stream), use {@link insertNDJSON}.
1345
+ */
968
1346
  async insert(data, opts) {
969
1347
  if (Array.isArray(data)) {
970
- const promises = data.map(
971
- (row) => request(this._ctx, {
972
- method: "POST",
973
- path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
974
- body: row,
975
- signal: opts?.signal
976
- })
977
- );
978
- const results = await Promise.all(promises);
979
- for (const res2 of results) {
980
- if (res2.error) return err(res2.error);
1348
+ if (data.length === 0) {
1349
+ return ok({ ok: true, total: 0, succeeded: 0, failed: 0, duplicates: 0, results: [] });
981
1350
  }
982
- return ok({ ok: true });
1351
+ const ndjson = data.map((row) => JSON.stringify(row)).join("\n");
1352
+ return this._sendNDJSON(ndjson, opts);
983
1353
  }
984
1354
  const { data: res, error } = await request(this._ctx, {
985
1355
  method: "POST",
@@ -992,11 +1362,51 @@ var TableRef = class {
992
1362
  if (res?.duplicate != null) result.duplicate = res.duplicate;
993
1363
  return ok(result);
994
1364
  }
1365
+ /**
1366
+ * Insert pre-formatted NDJSON (newline-delimited JSON, one record per line)
1367
+ * from a string, raw bytes, a Blob/File, or a byte stream — e.g. a `.ndjson`
1368
+ * file or a stream produced by another system. For in-memory rows, prefer
1369
+ * {@link insert}.
1370
+ *
1371
+ * Non-string sources are read fully into memory before sending (the server
1372
+ * streams the parse). Returns the same per-record batch summary as an array
1373
+ * `insert`.
1374
+ */
1375
+ async insertNDJSON(source, opts) {
1376
+ const ndjson = await ndjsonSourceToString(source);
1377
+ return this._sendNDJSON(ndjson, opts);
1378
+ }
1379
+ /**
1380
+ * @internal Send an NDJSON body and map the server's batch response onto an
1381
+ * {@link InsertResult}. A transport / whole-request failure surfaces as the
1382
+ * Result error arm; a processed batch (even with rejected records) surfaces
1383
+ * as the success arm with `ok = failed === 0`.
1384
+ */
1385
+ async _sendNDJSON(ndjson, opts) {
1386
+ const { data, error } = await request(this._ctx, {
1387
+ method: "POST",
1388
+ path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
1389
+ rawBody: ndjson,
1390
+ contentType: NDJSON_CONTENT_TYPE,
1391
+ signal: opts?.signal
1392
+ });
1393
+ if (error) return err(error);
1394
+ const r = data ?? { total: 0, succeeded: 0, failed: 0, duplicates: 0 };
1395
+ const result = {
1396
+ ok: r.failed === 0,
1397
+ total: r.total,
1398
+ succeeded: r.succeeded,
1399
+ failed: r.failed,
1400
+ duplicates: r.duplicates
1401
+ };
1402
+ if (r.results && r.results.length > 0) result.results = r.results;
1403
+ return ok(result);
1404
+ }
995
1405
  /** Fetch the schema for this table. */
996
1406
  async schema(opts) {
997
1407
  const { data, error } = await request(this._ctx, {
998
1408
  method: "GET",
999
- path: `/v1/schema?table=${encodeURIComponent(this._table)}`,
1409
+ path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
1000
1410
  signal: opts?.signal
1001
1411
  });
1002
1412
  if (error) return err(error);
@@ -1027,7 +1437,10 @@ var WaveHouseClient = class {
1027
1437
  baseURL: config.baseURL.replace(/\/+$/, ""),
1028
1438
  auth: config.auth,
1029
1439
  options: {
1030
- maxRetries: config.options?.maxRetries ?? 2
1440
+ maxRetries: config.options?.maxRetries ?? 2,
1441
+ fetch: config.options?.fetch,
1442
+ headers: config.options?.headers,
1443
+ fetchOptions: config.options?.fetchOptions
1031
1444
  }
1032
1445
  };
1033
1446
  this.schema = new SchemaNamespace(this._ctx);
@@ -1059,23 +1472,21 @@ var WaveHouseClient = class {
1059
1472
  sql(query, opts) {
1060
1473
  if (Array.isArray(opts)) {
1061
1474
  throw new Error(
1062
- "[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."
1475
+ "[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."
1063
1476
  );
1064
1477
  }
1065
1478
  return sql(this._ctx, query, opts);
1066
1479
  }
1067
1480
  /** @internal Create a stream for the given table. */
1068
1481
  _createStream(table, opts) {
1069
- if (typeof EventSource === "undefined") {
1070
- throw new Error(
1071
- "[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
1072
- );
1073
- }
1074
1482
  const transport = new SSETransport({
1075
1483
  baseURL: this._ctx.baseURL,
1076
1484
  table,
1077
1485
  since: opts?.since,
1078
- auth: this._ctx.auth
1486
+ auth: this._ctx.auth,
1487
+ fetch: this._ctx.options.fetch,
1488
+ headers: this._ctx.options.headers,
1489
+ fetchOptions: this._ctx.options.fetchOptions
1079
1490
  });
1080
1491
  const controller = new StreamController(transport);
1081
1492
  if (opts?.signal) controller.attachSignal(opts.signal);