@wavehouse/sdk 0.0.0-dev.hff4a2a48d0da → 0.1.0
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 +194 -9
- package/dist/index.d.ts +194 -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.cjs
CHANGED
|
@@ -72,9 +72,46 @@ 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 =
|
|
114
|
+
const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
|
|
78
115
|
const headers = {
|
|
79
116
|
"Content-Type": opts.contentType ?? "application/json",
|
|
80
117
|
Accept: "application/json"
|
|
@@ -86,16 +123,20 @@ async function request(ctx, opts) {
|
|
|
86
123
|
headers.Authorization = `Bearer ${token}`;
|
|
87
124
|
}
|
|
88
125
|
}
|
|
126
|
+
const finalHeaders = mergeHeaders(headers, ctx.options.headers);
|
|
89
127
|
let lastError = null;
|
|
90
128
|
const maxAttempts = ctx.options.maxRetries + 1;
|
|
91
129
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
92
130
|
try {
|
|
93
|
-
const
|
|
131
|
+
const init = {
|
|
132
|
+
...ctx.options.fetchOptions,
|
|
94
133
|
method: opts.method,
|
|
95
|
-
headers,
|
|
134
|
+
headers: finalHeaders,
|
|
96
135
|
body: requestBody,
|
|
97
136
|
signal: opts.signal
|
|
98
|
-
}
|
|
137
|
+
};
|
|
138
|
+
const doFetch = ctx.options.fetch;
|
|
139
|
+
const res = doFetch ? await doFetch(url, init) : await fetch(url, init);
|
|
99
140
|
if (res.ok) {
|
|
100
141
|
const text = await res.text();
|
|
101
142
|
const data = text ? JSON.parse(text) : void 0;
|
|
@@ -118,30 +159,24 @@ async function request(ctx, opts) {
|
|
|
118
159
|
}
|
|
119
160
|
return { data: null, error, headers: res.headers };
|
|
120
161
|
} catch (e) {
|
|
121
|
-
if (
|
|
122
|
-
return
|
|
123
|
-
data: null,
|
|
124
|
-
error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
|
|
125
|
-
headers: new Headers()
|
|
126
|
-
};
|
|
162
|
+
if (opts.signal?.aborted) {
|
|
163
|
+
return abortedResult();
|
|
127
164
|
}
|
|
128
165
|
lastError = networkError(e);
|
|
129
166
|
if (attempt < maxAttempts - 1) {
|
|
130
|
-
|
|
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
|
+
}
|
|
131
175
|
}
|
|
132
176
|
}
|
|
133
177
|
}
|
|
134
178
|
return { data: null, error: lastError, headers: new Headers() };
|
|
135
179
|
}
|
|
136
|
-
function buildURL(base, path, params) {
|
|
137
|
-
const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
|
|
138
|
-
if (params) {
|
|
139
|
-
for (const [k, v] of Object.entries(params)) {
|
|
140
|
-
url.searchParams.set(k, v);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return url.toString();
|
|
144
|
-
}
|
|
145
180
|
function backoff(attempt) {
|
|
146
181
|
return Math.min(1e3 * 2 ** attempt, 3e4);
|
|
147
182
|
}
|
|
@@ -175,7 +210,7 @@ var DLQNamespace = class {
|
|
|
175
210
|
async list(opts) {
|
|
176
211
|
const { data, error } = await request(this._ctx, {
|
|
177
212
|
method: "GET",
|
|
178
|
-
path: "/v1/dlq/stats",
|
|
213
|
+
path: "/v1/ops/dlq/stats",
|
|
179
214
|
signal: opts?.signal
|
|
180
215
|
});
|
|
181
216
|
if (error) return err(error);
|
|
@@ -185,7 +220,7 @@ var DLQNamespace = class {
|
|
|
185
220
|
async table(name, opts) {
|
|
186
221
|
const { data, error } = await request(this._ctx, {
|
|
187
222
|
method: "GET",
|
|
188
|
-
path: "/v1/dlq/stats",
|
|
223
|
+
path: "/v1/ops/dlq/stats",
|
|
189
224
|
params: { table: name },
|
|
190
225
|
signal: opts?.signal
|
|
191
226
|
});
|
|
@@ -210,7 +245,14 @@ var PipeRef = class {
|
|
|
210
245
|
this._params = params;
|
|
211
246
|
this._createStream = createStream;
|
|
212
247
|
}
|
|
213
|
-
/**
|
|
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
|
+
*/
|
|
214
256
|
async fetch(opts) {
|
|
215
257
|
const { data, error } = await request(this._ctx, {
|
|
216
258
|
method: "POST",
|
|
@@ -238,7 +280,7 @@ var PipesNamespace = class {
|
|
|
238
280
|
async list(opts) {
|
|
239
281
|
const { data, error } = await request(this._ctx, {
|
|
240
282
|
method: "GET",
|
|
241
|
-
path: "/v1/
|
|
283
|
+
path: "/v1/ops/pipes",
|
|
242
284
|
signal: opts?.signal
|
|
243
285
|
});
|
|
244
286
|
if (error) return err(error);
|
|
@@ -248,7 +290,7 @@ var PipesNamespace = class {
|
|
|
248
290
|
async get(name, opts) {
|
|
249
291
|
const { data, error } = await request(this._ctx, {
|
|
250
292
|
method: "GET",
|
|
251
|
-
path: `/v1/
|
|
293
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
252
294
|
signal: opts?.signal
|
|
253
295
|
});
|
|
254
296
|
if (error) return err(error);
|
|
@@ -258,7 +300,7 @@ var PipesNamespace = class {
|
|
|
258
300
|
async set(name, def, opts) {
|
|
259
301
|
const { error } = await request(this._ctx, {
|
|
260
302
|
method: "PUT",
|
|
261
|
-
path: `/v1/
|
|
303
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
262
304
|
body: def,
|
|
263
305
|
signal: opts?.signal
|
|
264
306
|
});
|
|
@@ -269,7 +311,7 @@ var PipesNamespace = class {
|
|
|
269
311
|
async delete(name, opts) {
|
|
270
312
|
const { error } = await request(this._ctx, {
|
|
271
313
|
method: "DELETE",
|
|
272
|
-
path: `/v1/
|
|
314
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
273
315
|
signal: opts?.signal
|
|
274
316
|
});
|
|
275
317
|
if (error) return err(error);
|
|
@@ -287,7 +329,7 @@ var PolicyNamespace = class {
|
|
|
287
329
|
async get(opts) {
|
|
288
330
|
const { data, error } = await request(this._ctx, {
|
|
289
331
|
method: "GET",
|
|
290
|
-
path: "/v1/
|
|
332
|
+
path: "/v1/ops/policy",
|
|
291
333
|
signal: opts?.signal
|
|
292
334
|
});
|
|
293
335
|
if (error) return err(error);
|
|
@@ -297,7 +339,7 @@ var PolicyNamespace = class {
|
|
|
297
339
|
async set(policy, opts) {
|
|
298
340
|
const { error } = await request(this._ctx, {
|
|
299
341
|
method: "PUT",
|
|
300
|
-
path: "/v1/
|
|
342
|
+
path: "/v1/ops/policy",
|
|
301
343
|
body: policy,
|
|
302
344
|
signal: opts?.signal
|
|
303
345
|
});
|
|
@@ -308,7 +350,7 @@ var PolicyNamespace = class {
|
|
|
308
350
|
async validate(policy, opts) {
|
|
309
351
|
const { data, error } = await request(this._ctx, {
|
|
310
352
|
method: "POST",
|
|
311
|
-
path: "/v1/
|
|
353
|
+
path: "/v1/ops/policy/validate",
|
|
312
354
|
body: policy,
|
|
313
355
|
signal: opts?.signal
|
|
314
356
|
});
|
|
@@ -327,7 +369,7 @@ var SchemaNamespace = class {
|
|
|
327
369
|
async list(opts) {
|
|
328
370
|
const { data, error } = await request(this._ctx, {
|
|
329
371
|
method: "GET",
|
|
330
|
-
path: "/v1/schema",
|
|
372
|
+
path: "/v1/ops/schema",
|
|
331
373
|
signal: opts?.signal
|
|
332
374
|
});
|
|
333
375
|
if (error) return err(error);
|
|
@@ -348,7 +390,7 @@ var SchemaNamespace = class {
|
|
|
348
390
|
async refresh(opts) {
|
|
349
391
|
const { error } = await request(this._ctx, {
|
|
350
392
|
method: "POST",
|
|
351
|
-
path: "/v1/schema/refresh",
|
|
393
|
+
path: "/v1/ops/schema/refresh",
|
|
352
394
|
signal: opts?.signal
|
|
353
395
|
});
|
|
354
396
|
if (error) return err(error);
|
|
@@ -360,7 +402,7 @@ var SchemaNamespace = class {
|
|
|
360
402
|
async function sql(ctx, query, opts) {
|
|
361
403
|
const { data, error } = await request(ctx, {
|
|
362
404
|
method: "POST",
|
|
363
|
-
path: "/v1/
|
|
405
|
+
path: "/v1/ops/query",
|
|
364
406
|
body: { sql: query },
|
|
365
407
|
signal: opts?.signal
|
|
366
408
|
});
|
|
@@ -523,11 +565,50 @@ var StreamController = class {
|
|
|
523
565
|
};
|
|
524
566
|
|
|
525
567
|
// src/stream/sse.ts
|
|
568
|
+
var import_eventsource_parser = require("eventsource-parser");
|
|
526
569
|
var activeSSEConnections = 0;
|
|
527
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
|
+
}
|
|
528
583
|
var SSETransport = class {
|
|
529
584
|
_opts;
|
|
530
|
-
|
|
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;
|
|
531
612
|
onEvent = null;
|
|
532
613
|
onStatus = null;
|
|
533
614
|
onError = null;
|
|
@@ -535,79 +616,332 @@ var SSETransport = class {
|
|
|
535
616
|
this._opts = opts;
|
|
536
617
|
}
|
|
537
618
|
connect() {
|
|
538
|
-
if (
|
|
619
|
+
if (this._closed || this._started) return;
|
|
620
|
+
this._started = true;
|
|
621
|
+
if (!this._opts.fetch && typeof fetch === "undefined") {
|
|
539
622
|
throw new Error(
|
|
540
|
-
"[wavehouse]
|
|
623
|
+
"[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
|
|
541
624
|
);
|
|
542
625
|
}
|
|
543
|
-
|
|
544
|
-
|
|
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.`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
this._run().catch((err2) => {
|
|
634
|
+
this._emitError({
|
|
545
635
|
status: 0,
|
|
546
636
|
code: "SSE_CONNECT_ERROR",
|
|
547
637
|
message: err2 instanceof Error ? err2.message : String(err2),
|
|
548
|
-
retryable:
|
|
638
|
+
retryable: false
|
|
549
639
|
});
|
|
640
|
+
this.disconnect();
|
|
550
641
|
});
|
|
551
642
|
}
|
|
552
643
|
disconnect() {
|
|
553
|
-
if (this.
|
|
554
|
-
this.
|
|
555
|
-
this.
|
|
556
|
-
|
|
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++;
|
|
557
675
|
}
|
|
558
|
-
this.onStatus?.("closed");
|
|
559
676
|
}
|
|
560
|
-
|
|
561
|
-
|
|
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
|
+
}
|
|
562
770
|
url.searchParams.set("table", this._opts.table);
|
|
563
771
|
if (this._opts.since) {
|
|
564
772
|
url.searchParams.set("since", this._opts.since);
|
|
565
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" };
|
|
566
779
|
if (this._opts.auth) {
|
|
567
780
|
const token = await this._opts.auth();
|
|
568
781
|
if (token) {
|
|
569
|
-
|
|
782
|
+
base.Authorization = `Bearer ${token}`;
|
|
570
783
|
}
|
|
571
784
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
console.warn(
|
|
575
|
-
`[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
|
|
576
|
-
);
|
|
785
|
+
if (this._lastEventId) {
|
|
786
|
+
base["Last-Event-ID"] = this._lastEventId;
|
|
577
787
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
this.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
594
829
|
};
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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({
|
|
604
850
|
status: 0,
|
|
605
|
-
code: "
|
|
606
|
-
message:
|
|
851
|
+
code: "SSE_PARSE_ERROR",
|
|
852
|
+
message: err2.message,
|
|
607
853
|
retryable: true
|
|
608
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;
|
|
609
866
|
}
|
|
610
|
-
}
|
|
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
|
+
});
|
|
611
945
|
}
|
|
612
946
|
};
|
|
613
947
|
|
|
@@ -1072,7 +1406,7 @@ var TableRef = class {
|
|
|
1072
1406
|
async schema(opts) {
|
|
1073
1407
|
const { data, error } = await request(this._ctx, {
|
|
1074
1408
|
method: "GET",
|
|
1075
|
-
path: `/v1/schema?table=${encodeURIComponent(this._table)}`,
|
|
1409
|
+
path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
|
|
1076
1410
|
signal: opts?.signal
|
|
1077
1411
|
});
|
|
1078
1412
|
if (error) return err(error);
|
|
@@ -1103,7 +1437,10 @@ var WaveHouseClient = class {
|
|
|
1103
1437
|
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
1104
1438
|
auth: config.auth,
|
|
1105
1439
|
options: {
|
|
1106
|
-
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
|
|
1107
1444
|
}
|
|
1108
1445
|
};
|
|
1109
1446
|
this.schema = new SchemaNamespace(this._ctx);
|
|
@@ -1135,23 +1472,21 @@ var WaveHouseClient = class {
|
|
|
1135
1472
|
sql(query, opts) {
|
|
1136
1473
|
if (Array.isArray(opts)) {
|
|
1137
1474
|
throw new Error(
|
|
1138
|
-
"[WaveHouse SDK] client.sql(sql, params) was removed. The /v1/
|
|
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."
|
|
1139
1476
|
);
|
|
1140
1477
|
}
|
|
1141
1478
|
return sql(this._ctx, query, opts);
|
|
1142
1479
|
}
|
|
1143
1480
|
/** @internal Create a stream for the given table. */
|
|
1144
1481
|
_createStream(table, opts) {
|
|
1145
|
-
if (typeof EventSource === "undefined") {
|
|
1146
|
-
throw new Error(
|
|
1147
|
-
"[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
|
|
1148
|
-
);
|
|
1149
|
-
}
|
|
1150
1482
|
const transport = new SSETransport({
|
|
1151
1483
|
baseURL: this._ctx.baseURL,
|
|
1152
1484
|
table,
|
|
1153
1485
|
since: opts?.since,
|
|
1154
|
-
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
|
|
1155
1490
|
});
|
|
1156
1491
|
const controller = new StreamController(transport);
|
|
1157
1492
|
if (opts?.signal) controller.attachSignal(opts.signal);
|