@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/README.md +47 -5
- package/dist/cli/codegen.js +202 -0
- package/dist/index.cjs +424 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +195 -9
- package/dist/index.d.ts +195 -9
- package/dist/index.global.js +12 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +424 -89
- package/dist/index.js.map +1 -1
- package/package.json +12 -4
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 =
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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 (
|
|
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]
|
|
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
|
-
|
|
507
|
-
|
|
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:
|
|
601
|
+
retryable: false
|
|
512
602
|
});
|
|
603
|
+
this.disconnect();
|
|
513
604
|
});
|
|
514
605
|
}
|
|
515
606
|
disconnect() {
|
|
516
|
-
if (this.
|
|
517
|
-
this.
|
|
518
|
-
this.
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
745
|
+
base.Authorization = `Bearer ${token}`;
|
|
533
746
|
}
|
|
534
747
|
}
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
this.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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: "
|
|
569
|
-
message:
|
|
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/
|
|
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);
|