@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/README.md +47 -5
- package/dist/cli/codegen.js +202 -0
- package/dist/index.cjs +518 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +287 -11
- package/dist/index.d.ts +287 -11
- package/dist/index.global.js +12 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +518 -107
- package/dist/index.js.map +1 -1
- package/package.json +12 -4
package/dist/index.js
CHANGED
|
@@ -35,29 +35,71 @@ function err(error) {
|
|
|
35
35
|
return { ok: false, data: null, error };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// src/url.ts
|
|
39
|
+
function resolveURL(base, path, params) {
|
|
40
|
+
const root = new URL(base);
|
|
41
|
+
root.search = "";
|
|
42
|
+
root.hash = "";
|
|
43
|
+
if (!root.pathname.endsWith("/")) root.pathname += "/";
|
|
44
|
+
const url = new URL(path.replace(/^\/+/, ""), root);
|
|
45
|
+
if (params) {
|
|
46
|
+
for (const [k, v] of Object.entries(params)) {
|
|
47
|
+
url.searchParams.set(k, v);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return url;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
// src/http.ts
|
|
54
|
+
function mergeHeaders(base, extra) {
|
|
55
|
+
if (!extra) return base;
|
|
56
|
+
const sdkNames = new Set(Object.keys(base).map((k) => k.toLowerCase()));
|
|
57
|
+
const merged = { ...base };
|
|
58
|
+
const configured = /* @__PURE__ */ new Map();
|
|
59
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
60
|
+
const lower = name.toLowerCase();
|
|
61
|
+
if (sdkNames.has(lower)) continue;
|
|
62
|
+
const prior = configured.get(lower);
|
|
63
|
+
if (prior !== void 0) delete merged[prior];
|
|
64
|
+
merged[name] = value;
|
|
65
|
+
configured.set(lower, name);
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
function abortedResult() {
|
|
70
|
+
return {
|
|
71
|
+
data: null,
|
|
72
|
+
error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
|
|
73
|
+
headers: new Headers()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
39
76
|
async function request(ctx, opts) {
|
|
40
|
-
const url =
|
|
77
|
+
const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
|
|
41
78
|
const headers = {
|
|
42
|
-
"Content-Type": "application/json",
|
|
79
|
+
"Content-Type": opts.contentType ?? "application/json",
|
|
43
80
|
Accept: "application/json"
|
|
44
81
|
};
|
|
82
|
+
const requestBody = opts.rawBody !== void 0 ? opts.rawBody : opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
|
|
45
83
|
if (ctx.auth) {
|
|
46
84
|
const token = await ctx.auth();
|
|
47
85
|
if (token) {
|
|
48
86
|
headers.Authorization = `Bearer ${token}`;
|
|
49
87
|
}
|
|
50
88
|
}
|
|
89
|
+
const finalHeaders = mergeHeaders(headers, ctx.options.headers);
|
|
51
90
|
let lastError = null;
|
|
52
91
|
const maxAttempts = ctx.options.maxRetries + 1;
|
|
53
92
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
54
93
|
try {
|
|
55
|
-
const
|
|
94
|
+
const init = {
|
|
95
|
+
...ctx.options.fetchOptions,
|
|
56
96
|
method: opts.method,
|
|
57
|
-
headers,
|
|
58
|
-
body:
|
|
97
|
+
headers: finalHeaders,
|
|
98
|
+
body: requestBody,
|
|
59
99
|
signal: opts.signal
|
|
60
|
-
}
|
|
100
|
+
};
|
|
101
|
+
const doFetch = ctx.options.fetch;
|
|
102
|
+
const res = doFetch ? await doFetch(url, init) : await fetch(url, init);
|
|
61
103
|
if (res.ok) {
|
|
62
104
|
const text = await res.text();
|
|
63
105
|
const data = text ? JSON.parse(text) : void 0;
|
|
@@ -80,30 +122,24 @@ async function request(ctx, opts) {
|
|
|
80
122
|
}
|
|
81
123
|
return { data: null, error, headers: res.headers };
|
|
82
124
|
} catch (e) {
|
|
83
|
-
if (
|
|
84
|
-
return
|
|
85
|
-
data: null,
|
|
86
|
-
error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
|
|
87
|
-
headers: new Headers()
|
|
88
|
-
};
|
|
125
|
+
if (opts.signal?.aborted) {
|
|
126
|
+
return abortedResult();
|
|
89
127
|
}
|
|
90
128
|
lastError = networkError(e);
|
|
91
129
|
if (attempt < maxAttempts - 1) {
|
|
92
|
-
|
|
130
|
+
try {
|
|
131
|
+
await sleep(backoff(attempt), opts.signal);
|
|
132
|
+
} catch (abort) {
|
|
133
|
+
if (opts.signal?.aborted) {
|
|
134
|
+
return abortedResult();
|
|
135
|
+
}
|
|
136
|
+
throw abort;
|
|
137
|
+
}
|
|
93
138
|
}
|
|
94
139
|
}
|
|
95
140
|
}
|
|
96
141
|
return { data: null, error: lastError, headers: new Headers() };
|
|
97
142
|
}
|
|
98
|
-
function buildURL(base, path, params) {
|
|
99
|
-
const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
|
|
100
|
-
if (params) {
|
|
101
|
-
for (const [k, v] of Object.entries(params)) {
|
|
102
|
-
url.searchParams.set(k, v);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return url.toString();
|
|
106
|
-
}
|
|
107
143
|
function backoff(attempt) {
|
|
108
144
|
return Math.min(1e3 * 2 ** attempt, 3e4);
|
|
109
145
|
}
|
|
@@ -137,7 +173,7 @@ var DLQNamespace = class {
|
|
|
137
173
|
async list(opts) {
|
|
138
174
|
const { data, error } = await request(this._ctx, {
|
|
139
175
|
method: "GET",
|
|
140
|
-
path: "/v1/dlq/stats",
|
|
176
|
+
path: "/v1/ops/dlq/stats",
|
|
141
177
|
signal: opts?.signal
|
|
142
178
|
});
|
|
143
179
|
if (error) return err(error);
|
|
@@ -147,7 +183,7 @@ var DLQNamespace = class {
|
|
|
147
183
|
async table(name, opts) {
|
|
148
184
|
const { data, error } = await request(this._ctx, {
|
|
149
185
|
method: "GET",
|
|
150
|
-
path: "/v1/dlq/stats",
|
|
186
|
+
path: "/v1/ops/dlq/stats",
|
|
151
187
|
params: { table: name },
|
|
152
188
|
signal: opts?.signal
|
|
153
189
|
});
|
|
@@ -172,7 +208,14 @@ var PipeRef = class {
|
|
|
172
208
|
this._params = params;
|
|
173
209
|
this._createStream = createStream;
|
|
174
210
|
}
|
|
175
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Execute the pipe and return results.
|
|
213
|
+
*
|
|
214
|
+
* Takes only `signal` — deliberately narrower than the `RequestOptions` the
|
|
215
|
+
* query builder accepts. The pipes endpoint binds the body as the pipe's
|
|
216
|
+
* parameters, so there is no row cap to forward; give the pipe a `{{limit}}`
|
|
217
|
+
* parameter in its SQL and pass it via `wh.pipe(name, { limit })`.
|
|
218
|
+
*/
|
|
176
219
|
async fetch(opts) {
|
|
177
220
|
const { data, error } = await request(this._ctx, {
|
|
178
221
|
method: "POST",
|
|
@@ -200,7 +243,7 @@ var PipesNamespace = class {
|
|
|
200
243
|
async list(opts) {
|
|
201
244
|
const { data, error } = await request(this._ctx, {
|
|
202
245
|
method: "GET",
|
|
203
|
-
path: "/v1/
|
|
246
|
+
path: "/v1/ops/pipes",
|
|
204
247
|
signal: opts?.signal
|
|
205
248
|
});
|
|
206
249
|
if (error) return err(error);
|
|
@@ -210,7 +253,7 @@ var PipesNamespace = class {
|
|
|
210
253
|
async get(name, opts) {
|
|
211
254
|
const { data, error } = await request(this._ctx, {
|
|
212
255
|
method: "GET",
|
|
213
|
-
path: `/v1/
|
|
256
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
214
257
|
signal: opts?.signal
|
|
215
258
|
});
|
|
216
259
|
if (error) return err(error);
|
|
@@ -220,7 +263,7 @@ var PipesNamespace = class {
|
|
|
220
263
|
async set(name, def, opts) {
|
|
221
264
|
const { error } = await request(this._ctx, {
|
|
222
265
|
method: "PUT",
|
|
223
|
-
path: `/v1/
|
|
266
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
224
267
|
body: def,
|
|
225
268
|
signal: opts?.signal
|
|
226
269
|
});
|
|
@@ -231,7 +274,7 @@ var PipesNamespace = class {
|
|
|
231
274
|
async delete(name, opts) {
|
|
232
275
|
const { error } = await request(this._ctx, {
|
|
233
276
|
method: "DELETE",
|
|
234
|
-
path: `/v1/
|
|
277
|
+
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
235
278
|
signal: opts?.signal
|
|
236
279
|
});
|
|
237
280
|
if (error) return err(error);
|
|
@@ -249,7 +292,7 @@ var PolicyNamespace = class {
|
|
|
249
292
|
async get(opts) {
|
|
250
293
|
const { data, error } = await request(this._ctx, {
|
|
251
294
|
method: "GET",
|
|
252
|
-
path: "/v1/
|
|
295
|
+
path: "/v1/ops/policy",
|
|
253
296
|
signal: opts?.signal
|
|
254
297
|
});
|
|
255
298
|
if (error) return err(error);
|
|
@@ -259,7 +302,7 @@ var PolicyNamespace = class {
|
|
|
259
302
|
async set(policy, opts) {
|
|
260
303
|
const { error } = await request(this._ctx, {
|
|
261
304
|
method: "PUT",
|
|
262
|
-
path: "/v1/
|
|
305
|
+
path: "/v1/ops/policy",
|
|
263
306
|
body: policy,
|
|
264
307
|
signal: opts?.signal
|
|
265
308
|
});
|
|
@@ -270,7 +313,7 @@ var PolicyNamespace = class {
|
|
|
270
313
|
async validate(policy, opts) {
|
|
271
314
|
const { data, error } = await request(this._ctx, {
|
|
272
315
|
method: "POST",
|
|
273
|
-
path: "/v1/
|
|
316
|
+
path: "/v1/ops/policy/validate",
|
|
274
317
|
body: policy,
|
|
275
318
|
signal: opts?.signal
|
|
276
319
|
});
|
|
@@ -289,7 +332,7 @@ var SchemaNamespace = class {
|
|
|
289
332
|
async list(opts) {
|
|
290
333
|
const { data, error } = await request(this._ctx, {
|
|
291
334
|
method: "GET",
|
|
292
|
-
path: "/v1/schema",
|
|
335
|
+
path: "/v1/ops/schema",
|
|
293
336
|
signal: opts?.signal
|
|
294
337
|
});
|
|
295
338
|
if (error) return err(error);
|
|
@@ -310,7 +353,7 @@ var SchemaNamespace = class {
|
|
|
310
353
|
async refresh(opts) {
|
|
311
354
|
const { error } = await request(this._ctx, {
|
|
312
355
|
method: "POST",
|
|
313
|
-
path: "/v1/schema/refresh",
|
|
356
|
+
path: "/v1/ops/schema/refresh",
|
|
314
357
|
signal: opts?.signal
|
|
315
358
|
});
|
|
316
359
|
if (error) return err(error);
|
|
@@ -322,7 +365,7 @@ var SchemaNamespace = class {
|
|
|
322
365
|
async function sql(ctx, query, opts) {
|
|
323
366
|
const { data, error } = await request(ctx, {
|
|
324
367
|
method: "POST",
|
|
325
|
-
path: "/v1/
|
|
368
|
+
path: "/v1/ops/query",
|
|
326
369
|
body: { sql: query },
|
|
327
370
|
signal: opts?.signal
|
|
328
371
|
});
|
|
@@ -485,11 +528,50 @@ var StreamController = class {
|
|
|
485
528
|
};
|
|
486
529
|
|
|
487
530
|
// src/stream/sse.ts
|
|
531
|
+
import { createParser } from "eventsource-parser";
|
|
488
532
|
var activeSSEConnections = 0;
|
|
489
533
|
var SSE_WARN_THRESHOLD = 5;
|
|
534
|
+
var MAX_BUFFER_CHARS = 16 * 1024 * 1024;
|
|
535
|
+
var MAX_BACKOFF_MS = 3e4;
|
|
536
|
+
var STABLE_CONNECTION_MS = 3e3;
|
|
537
|
+
function backoff2(attempt, retryFloorMs) {
|
|
538
|
+
const nominal = Math.max(retryFloorMs, Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS));
|
|
539
|
+
const low = Math.max(retryFloorMs, nominal / 2);
|
|
540
|
+
return low + Math.random() * (nominal - low);
|
|
541
|
+
}
|
|
542
|
+
function releaseBody(res) {
|
|
543
|
+
if (res.body && !res.body.locked) res.body.cancel().catch(() => {
|
|
544
|
+
});
|
|
545
|
+
}
|
|
490
546
|
var SSETransport = class {
|
|
491
547
|
_opts;
|
|
492
|
-
|
|
548
|
+
_abort = null;
|
|
549
|
+
/**
|
|
550
|
+
* Set by `disconnect()` before it calls `abort()` — both synchronously — so
|
|
551
|
+
* a rejection caused by our own abort always observes this as `true`. That
|
|
552
|
+
* makes it a complete and precise test for "did we cause this?", which is
|
|
553
|
+
* why the catch blocks read it directly instead of matching on the error
|
|
554
|
+
* type: an `AbortError` we *didn't* raise (a custom `fetch` enforcing a
|
|
555
|
+
* per-attempt deadline, an `auth()` call cancelled by its own controller) is
|
|
556
|
+
* a transient failure to re-dial, exactly as on the REST path. Treating one
|
|
557
|
+
* as a teardown killed the stream permanently, and silently.
|
|
558
|
+
*/
|
|
559
|
+
_closed = false;
|
|
560
|
+
_started = false;
|
|
561
|
+
_counted = false;
|
|
562
|
+
/**
|
|
563
|
+
* Most recent non-empty event id, replayed as `Last-Event-ID` on reconnect.
|
|
564
|
+
*
|
|
565
|
+
* Deliberately ignores empty ids. The server emits a blank `id:` line for
|
|
566
|
+
* passthrough payloads, and per the SSE spec an empty `id` field *clears* the
|
|
567
|
+
* last-event-id — so tracking it faithfully would silently discard the
|
|
568
|
+
* resumption point mid-stream and re-open from the beginning.
|
|
569
|
+
*/
|
|
570
|
+
_lastEventId = null;
|
|
571
|
+
/** Reconnect floor most recently requested by the server via `retry:`. */
|
|
572
|
+
_retryFloorMs = 0;
|
|
573
|
+
/** Cuts a reconnect gap short when set — see `_sleep`. */
|
|
574
|
+
_wake = null;
|
|
493
575
|
onEvent = null;
|
|
494
576
|
onStatus = null;
|
|
495
577
|
onError = null;
|
|
@@ -497,79 +579,332 @@ var SSETransport = class {
|
|
|
497
579
|
this._opts = opts;
|
|
498
580
|
}
|
|
499
581
|
connect() {
|
|
500
|
-
if (
|
|
582
|
+
if (this._closed || this._started) return;
|
|
583
|
+
this._started = true;
|
|
584
|
+
if (!this._opts.fetch && typeof fetch === "undefined") {
|
|
501
585
|
throw new Error(
|
|
502
|
-
"[wavehouse]
|
|
586
|
+
"[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
activeSSEConnections++;
|
|
590
|
+
this._counted = true;
|
|
591
|
+
if (activeSSEConnections > SSE_WARN_THRESHOLD) {
|
|
592
|
+
console.warn(
|
|
593
|
+
`[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
|
|
503
594
|
);
|
|
504
595
|
}
|
|
505
|
-
this.
|
|
506
|
-
this.
|
|
596
|
+
this._run().catch((err2) => {
|
|
597
|
+
this._emitError({
|
|
507
598
|
status: 0,
|
|
508
599
|
code: "SSE_CONNECT_ERROR",
|
|
509
600
|
message: err2 instanceof Error ? err2.message : String(err2),
|
|
510
|
-
retryable:
|
|
601
|
+
retryable: false
|
|
511
602
|
});
|
|
603
|
+
this.disconnect();
|
|
512
604
|
});
|
|
513
605
|
}
|
|
514
606
|
disconnect() {
|
|
515
|
-
if (this.
|
|
516
|
-
this.
|
|
517
|
-
this.
|
|
518
|
-
|
|
607
|
+
if (!this._closed) {
|
|
608
|
+
this._closed = true;
|
|
609
|
+
this._abort?.abort();
|
|
610
|
+
this._abort = null;
|
|
611
|
+
this._wake?.();
|
|
612
|
+
if (this._counted) {
|
|
613
|
+
activeSSEConnections = Math.max(0, activeSSEConnections - 1);
|
|
614
|
+
this._counted = false;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
this.onStatus?.("closed");
|
|
618
|
+
} catch (e) {
|
|
619
|
+
console.error("[wavehouse] SSE status handler threw:", e);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/** Connect/read/reconnect until the stream is closed or hits a terminal error. */
|
|
624
|
+
async _run() {
|
|
625
|
+
await Promise.resolve();
|
|
626
|
+
let attempt = 0;
|
|
627
|
+
while (!this._closed) {
|
|
628
|
+
const { liveMs, terminal } = await this._attempt();
|
|
629
|
+
if (this._closed) return;
|
|
630
|
+
if (terminal) {
|
|
631
|
+
this.disconnect();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (liveMs >= STABLE_CONNECTION_MS) attempt = 0;
|
|
635
|
+
this._emitStatus("reconnecting");
|
|
636
|
+
await this._sleep(backoff2(attempt, this._retryFloorMs));
|
|
637
|
+
attempt++;
|
|
519
638
|
}
|
|
520
|
-
this.onStatus?.("closed");
|
|
521
639
|
}
|
|
522
|
-
|
|
523
|
-
|
|
640
|
+
/** One connection attempt: request, validate, then pump frames until it ends. */
|
|
641
|
+
async _attempt() {
|
|
642
|
+
const ac = new AbortController();
|
|
643
|
+
this._abort = ac;
|
|
644
|
+
let target;
|
|
645
|
+
try {
|
|
646
|
+
target = this._url();
|
|
647
|
+
} catch (e) {
|
|
648
|
+
this._emitError({
|
|
649
|
+
status: 0,
|
|
650
|
+
code: "SSE_CONNECT_ERROR",
|
|
651
|
+
message: e instanceof Error ? e.message : String(e),
|
|
652
|
+
retryable: false
|
|
653
|
+
});
|
|
654
|
+
return { liveMs: 0, terminal: true };
|
|
655
|
+
}
|
|
656
|
+
let init;
|
|
657
|
+
try {
|
|
658
|
+
init = await this._init(ac.signal);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
if (this._closed) return { liveMs: 0, terminal: true };
|
|
661
|
+
this._emitError({
|
|
662
|
+
status: 0,
|
|
663
|
+
code: "SSE_AUTH_ERROR",
|
|
664
|
+
message: e instanceof Error ? e.message : String(e),
|
|
665
|
+
retryable: true
|
|
666
|
+
});
|
|
667
|
+
return { liveMs: 0, terminal: false };
|
|
668
|
+
}
|
|
669
|
+
let res;
|
|
670
|
+
try {
|
|
671
|
+
const doFetch = this._opts.fetch;
|
|
672
|
+
res = doFetch ? await doFetch(target, init) : await fetch(target, init);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
if (this._closed) return { liveMs: 0, terminal: true };
|
|
675
|
+
this._emitError({
|
|
676
|
+
status: 0,
|
|
677
|
+
code: "SSE_NETWORK_ERROR",
|
|
678
|
+
message: e instanceof Error ? e.message : String(e),
|
|
679
|
+
retryable: true
|
|
680
|
+
});
|
|
681
|
+
return { liveMs: 0, terminal: false };
|
|
682
|
+
}
|
|
683
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
684
|
+
this._emitError({
|
|
685
|
+
status: res.status,
|
|
686
|
+
code: "SSE_REDIRECT",
|
|
687
|
+
message: "The stream endpoint redirected and the SDK did not follow it. Redirects are refused when the request carries a credential \u2014 an `auth` token or configured `headers` \u2014 because a cross-origin hop strips the Authorization header, and this endpoint answers an unauthenticated caller with a reduced view instead of an error, while forwarding any configured headers to wherever the redirect points. If this request carries neither, `fetch` was left to follow the redirect itself, so arriving here means a response it will not follow (a 304, or a 3xx with no Location header) or an `options.fetch` that returned the redirect verbatim. Either way, set `baseURL` to the final URL; most often this is an http\u2192https upgrade or a canonical-host redirect at a proxy. To follow a credentialed redirect anyway, supply an `options.fetch` that overrides `redirect`.",
|
|
688
|
+
retryable: false
|
|
689
|
+
});
|
|
690
|
+
releaseBody(res);
|
|
691
|
+
return { liveMs: 0, terminal: true };
|
|
692
|
+
}
|
|
693
|
+
if (!res.ok) {
|
|
694
|
+
const error = await parseErrorResponse(res);
|
|
695
|
+
this._emitError(error);
|
|
696
|
+
return { liveMs: 0, terminal: !error.retryable };
|
|
697
|
+
}
|
|
698
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
699
|
+
if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
|
|
700
|
+
this._emitError({
|
|
701
|
+
status: res.status,
|
|
702
|
+
code: "SSE_BAD_CONTENT_TYPE",
|
|
703
|
+
message: `Expected \`text/event-stream\`, got \`${contentType || "(none)"}\`. Something between the client and WaveHouse answered this request \u2014 an auth gateway's login page is the usual cause.`,
|
|
704
|
+
retryable: false
|
|
705
|
+
});
|
|
706
|
+
releaseBody(res);
|
|
707
|
+
return { liveMs: 0, terminal: true };
|
|
708
|
+
}
|
|
709
|
+
const body = res.body;
|
|
710
|
+
if (!body || typeof body.getReader !== "function" || body.locked || res.bodyUsed) {
|
|
711
|
+
this._emitError({
|
|
712
|
+
status: res.status,
|
|
713
|
+
code: "SSE_NO_STREAM_BODY",
|
|
714
|
+
message: "Streaming requires a response with a readable body. The configured `options.fetch` returned one whose body is absent or already read \u2014 an implementation that buffers or consumes the response cannot be used for `.stream()` or `.liveQuery()`.",
|
|
715
|
+
retryable: false
|
|
716
|
+
});
|
|
717
|
+
releaseBody(res);
|
|
718
|
+
return { liveMs: 0, terminal: true };
|
|
719
|
+
}
|
|
720
|
+
this._emitStatus("live");
|
|
721
|
+
const openedAt = Date.now();
|
|
722
|
+
await this._pump(body);
|
|
723
|
+
return { liveMs: Date.now() - openedAt, terminal: false };
|
|
724
|
+
}
|
|
725
|
+
/** Resolve the stream URL. Throws on a `baseURL` that isn't a usable http(s) origin. */
|
|
726
|
+
_url() {
|
|
727
|
+
const url = resolveURL(this._opts.baseURL, "/v1/stream");
|
|
728
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`baseURL must use http or https, got "${url.protocol}". Streaming is plain HTTP \u2014 there is no WebSocket endpoint.`
|
|
731
|
+
);
|
|
732
|
+
}
|
|
524
733
|
url.searchParams.set("table", this._opts.table);
|
|
525
734
|
if (this._opts.since) {
|
|
526
735
|
url.searchParams.set("since", this._opts.since);
|
|
527
736
|
}
|
|
737
|
+
return url.toString();
|
|
738
|
+
}
|
|
739
|
+
/** Build the request init, minting a token for this attempt. */
|
|
740
|
+
async _init(signal) {
|
|
741
|
+
const base = { Accept: "text/event-stream" };
|
|
528
742
|
if (this._opts.auth) {
|
|
529
743
|
const token = await this._opts.auth();
|
|
530
744
|
if (token) {
|
|
531
|
-
|
|
745
|
+
base.Authorization = `Bearer ${token}`;
|
|
532
746
|
}
|
|
533
747
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
console.warn(
|
|
537
|
-
`[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
|
|
538
|
-
);
|
|
748
|
+
if (this._lastEventId) {
|
|
749
|
+
base["Last-Event-ID"] = this._lastEventId;
|
|
539
750
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
this.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
751
|
+
const credentialed = base.Authorization !== void 0 || Object.keys(this._opts.headers ?? {}).length > 0;
|
|
752
|
+
const init = {
|
|
753
|
+
...this._opts.fetchOptions,
|
|
754
|
+
method: "GET",
|
|
755
|
+
headers: mergeHeaders(base, this._opts.headers),
|
|
756
|
+
signal,
|
|
757
|
+
// Owned by the SDK rather than left to `fetchOptions`, unlike the REST
|
|
758
|
+
// path, because on a stream these are correctness rather than preference:
|
|
759
|
+
//
|
|
760
|
+
// cache — as an init field, not a `Cache-Control` header. The header
|
|
761
|
+
// form is not in the server's Access-Control-Allow-Headers,
|
|
762
|
+
// so it would fail every cross-origin preflight.
|
|
763
|
+
// redirect — followed only when this request carries no credential.
|
|
764
|
+
// Platforms strip `Authorization` on a cross-origin redirect
|
|
765
|
+
// (whatwg/fetch#1544) and forward other headers intact, so a
|
|
766
|
+
// credentialed hop either silently downgrades the stream to
|
|
767
|
+
// the default role — this endpoint answers an unauthenticated
|
|
768
|
+
// caller rather than rejecting them — or delivers a configured
|
|
769
|
+
// secret to whatever the redirect names. Neither is worth a
|
|
770
|
+
// convenience. Without either, redirects are followed, so CDN
|
|
771
|
+
// canonicalization, geo/LB indirection, and an http→https
|
|
772
|
+
// upgrade all just work. The test is deliberately that
|
|
773
|
+
// concrete pair and not "is this authenticated": cookies do
|
|
774
|
+
// not appear in it. A cookie is re-derived per
|
|
775
|
+
// destination rather than carried, so under the default
|
|
776
|
+
// same-origin credentials mode it survives a redirect only
|
|
777
|
+
// while the hop stays inside both the request's origin and
|
|
778
|
+
// the cookie's Path; outside either the stream is
|
|
779
|
+
// silently unauthed. See #478 and the SDK guide under
|
|
780
|
+
// "Supplying your own fetch".
|
|
781
|
+
// `manual` over `error` for the credentialed case so the
|
|
782
|
+
// outcome stays inspectable: `error` rejects with a bare
|
|
783
|
+
// TypeError indistinguishable from a connection failure, which
|
|
784
|
+
// the loop above would retry forever against a redirect that
|
|
785
|
+
// is never going to stop happening.
|
|
786
|
+
cache: "no-store",
|
|
787
|
+
redirect: credentialed ? "manual" : "follow",
|
|
788
|
+
// Neutralized like the REST path does, and for a sharper reason here: a
|
|
789
|
+
// body on a GET makes `fetch` throw, which reads as a network failure and
|
|
790
|
+
// would be retried forever against a request that can never be built.
|
|
791
|
+
body: void 0
|
|
556
792
|
};
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
793
|
+
if (!("window" in globalThis)) {
|
|
794
|
+
delete init.credentials;
|
|
795
|
+
}
|
|
796
|
+
return init;
|
|
797
|
+
}
|
|
798
|
+
/** Decode and parse frames until the stream ends, aborts, or errors. */
|
|
799
|
+
async _pump(body) {
|
|
800
|
+
let overflowed = false;
|
|
801
|
+
const parser = createParser({
|
|
802
|
+
onEvent: (msg) => {
|
|
803
|
+
if (msg.id) this._lastEventId = msg.id;
|
|
804
|
+
if (!msg.data) return;
|
|
805
|
+
this._dispatch(msg.data);
|
|
806
|
+
},
|
|
807
|
+
onRetry: (ms) => {
|
|
808
|
+
this._retryFloorMs = Math.min(Math.max(ms, 0), MAX_BACKOFF_MS);
|
|
809
|
+
},
|
|
810
|
+
onError: (err2) => {
|
|
811
|
+
if (err2.type === "max-buffer-size-exceeded") overflowed = true;
|
|
812
|
+
this._emitError({
|
|
566
813
|
status: 0,
|
|
567
|
-
code: "
|
|
568
|
-
message:
|
|
814
|
+
code: "SSE_PARSE_ERROR",
|
|
815
|
+
message: err2.message,
|
|
569
816
|
retryable: true
|
|
570
817
|
});
|
|
818
|
+
},
|
|
819
|
+
maxBufferSize: MAX_BUFFER_CHARS
|
|
820
|
+
});
|
|
821
|
+
const reader = body.getReader();
|
|
822
|
+
const decoder = new TextDecoder();
|
|
823
|
+
try {
|
|
824
|
+
for (; ; ) {
|
|
825
|
+
const { done, value } = await reader.read();
|
|
826
|
+
if (done) return;
|
|
827
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
828
|
+
if (overflowed) return;
|
|
571
829
|
}
|
|
572
|
-
}
|
|
830
|
+
} catch (e) {
|
|
831
|
+
if (this._closed) return;
|
|
832
|
+
this._emitError({
|
|
833
|
+
status: 0,
|
|
834
|
+
code: "SSE_READ_ERROR",
|
|
835
|
+
message: e instanceof Error ? e.message : String(e),
|
|
836
|
+
retryable: true
|
|
837
|
+
});
|
|
838
|
+
} finally {
|
|
839
|
+
reader.cancel().catch(() => {
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Turn one frame's `data` into a StreamEvent.
|
|
845
|
+
*
|
|
846
|
+
* Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
|
|
847
|
+
* every complete frame in a chunk synchronously, so a subscriber that closes
|
|
848
|
+
* from inside its own `next` — "read until I see my event, then stop" —
|
|
849
|
+
* would otherwise keep receiving the rest of that chunk. `EventSource` ended
|
|
850
|
+
* delivery on `close()`, and gap-fill replay routinely lands many frames in
|
|
851
|
+
* one chunk.
|
|
852
|
+
*/
|
|
853
|
+
_dispatch(data) {
|
|
854
|
+
if (this._closed) return;
|
|
855
|
+
let event;
|
|
856
|
+
try {
|
|
857
|
+
const msg = JSON.parse(data);
|
|
858
|
+
event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
|
|
859
|
+
} catch {
|
|
860
|
+
console.warn("[wavehouse] SSE received malformed message:", data);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
this.onEvent?.(event);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
console.error("[wavehouse] SSE event handler threw:", e);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/** Suppress callbacks that race a `disconnect()`. */
|
|
870
|
+
_emitError(error) {
|
|
871
|
+
if (this._closed) return;
|
|
872
|
+
try {
|
|
873
|
+
this.onError?.(error);
|
|
874
|
+
} catch (e) {
|
|
875
|
+
console.error("[wavehouse] SSE error handler threw:", e);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Same guard for status. Aborting does not retract an already-resolved
|
|
880
|
+
* `Response`, so a `disconnect()` landing between the fetch settling and the
|
|
881
|
+
* "live" below would otherwise flip a closed controller back to live — and it
|
|
882
|
+
* stays there, since the loop then exits without emitting anything further.
|
|
883
|
+
* `disconnect()` calls `onStatus` directly; that terminal "closed" is the one
|
|
884
|
+
* status that must always get through.
|
|
885
|
+
*/
|
|
886
|
+
_emitStatus(status) {
|
|
887
|
+
if (this._closed) return;
|
|
888
|
+
try {
|
|
889
|
+
this.onStatus?.(status);
|
|
890
|
+
} catch (e) {
|
|
891
|
+
console.error("[wavehouse] SSE status handler threw:", e);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/** Wait out a reconnect gap, cut short by `disconnect()`. */
|
|
895
|
+
_sleep(ms) {
|
|
896
|
+
if (this._closed) return Promise.resolve();
|
|
897
|
+
return new Promise((resolve) => {
|
|
898
|
+
const timer = setTimeout(() => {
|
|
899
|
+
this._wake = null;
|
|
900
|
+
resolve();
|
|
901
|
+
}, ms);
|
|
902
|
+
this._wake = () => {
|
|
903
|
+
clearTimeout(timer);
|
|
904
|
+
this._wake = null;
|
|
905
|
+
resolve();
|
|
906
|
+
};
|
|
907
|
+
});
|
|
573
908
|
}
|
|
574
909
|
};
|
|
575
910
|
|
|
@@ -690,6 +1025,15 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
690
1025
|
select(...columns) {
|
|
691
1026
|
return this._clone({ columns: [...this._state.columns, ...columns] });
|
|
692
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Select every column the caller's role is allowed to read (the all-columns
|
|
1030
|
+
* wildcard). Use this instead of `.select(...)` when you want a full-row read;
|
|
1031
|
+
* a bare `.fetch()` with no `.select()` does this implicitly. Mutually
|
|
1032
|
+
* exclusive with `.select(...)`.
|
|
1033
|
+
*/
|
|
1034
|
+
selectAll() {
|
|
1035
|
+
return this._clone({ selectAll: true });
|
|
1036
|
+
}
|
|
693
1037
|
where(column, op, value) {
|
|
694
1038
|
const filter = { column, op: OP_MAP[op], value };
|
|
695
1039
|
return this._clone({ filters: [...this._state.filters, filter] });
|
|
@@ -731,7 +1075,7 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
731
1075
|
return this._clone({ cacheTTL: seconds });
|
|
732
1076
|
}
|
|
733
1077
|
// --- Execution ---
|
|
734
|
-
/** Default row limit when none is specified
|
|
1078
|
+
/** Default row limit when none is specified — deliberately tighter than the backend's DefaultMaxRows (10000) safety cap. */
|
|
735
1079
|
static DEFAULT_LIMIT = 1e3;
|
|
736
1080
|
async fetch(opts) {
|
|
737
1081
|
const effectiveLimit = opts?.limit ?? this._state.limit ?? _QueryBuilder.DEFAULT_LIMIT;
|
|
@@ -788,8 +1132,16 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
788
1132
|
}
|
|
789
1133
|
_buildAST(effectiveLimit) {
|
|
790
1134
|
const ast = {};
|
|
791
|
-
|
|
792
|
-
|
|
1135
|
+
const hasColumns = this._state.columns.length > 0;
|
|
1136
|
+
const hasAggs = this._state.aggregations.length > 0;
|
|
1137
|
+
if (this._state.selectAll) {
|
|
1138
|
+
ast.select_all = true;
|
|
1139
|
+
} else if (hasColumns) {
|
|
1140
|
+
ast.columns = this._state.columns;
|
|
1141
|
+
} else if (!hasAggs) {
|
|
1142
|
+
ast.select_all = true;
|
|
1143
|
+
}
|
|
1144
|
+
if (hasAggs) ast.aggregations = this._state.aggregations;
|
|
793
1145
|
if (this._state.filters.length > 0) ast.filters = this._state.filters;
|
|
794
1146
|
if (this._state.groupBy.length > 0) ast.group_by = this._state.groupBy;
|
|
795
1147
|
if (this._state.orderBy.length > 0) ast.order_by = this._state.orderBy;
|
|
@@ -899,6 +1251,12 @@ function projectColumns(row, columns) {
|
|
|
899
1251
|
}
|
|
900
1252
|
|
|
901
1253
|
// src/table.ts
|
|
1254
|
+
var NDJSON_CONTENT_TYPE = "application/x-ndjson";
|
|
1255
|
+
async function ndjsonSourceToString(source) {
|
|
1256
|
+
if (typeof source === "string") return source;
|
|
1257
|
+
if (source instanceof Uint8Array) return new TextDecoder().decode(source);
|
|
1258
|
+
return new Response(source).text();
|
|
1259
|
+
}
|
|
902
1260
|
var TableRef = class {
|
|
903
1261
|
_ctx;
|
|
904
1262
|
_table;
|
|
@@ -927,22 +1285,34 @@ var TableRef = class {
|
|
|
927
1285
|
this._createStream
|
|
928
1286
|
);
|
|
929
1287
|
}
|
|
930
|
-
/**
|
|
1288
|
+
/**
|
|
1289
|
+
* Start a query that selects every column the caller's role is allowed to read
|
|
1290
|
+
* (the all-columns wildcard). Use instead of `.select(...)` for an explicit
|
|
1291
|
+
* full-row read; `.fetch()` does this implicitly.
|
|
1292
|
+
*/
|
|
1293
|
+
selectAll() {
|
|
1294
|
+
return this.select().selectAll();
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Insert one or more rows into this table.
|
|
1298
|
+
*
|
|
1299
|
+
* A single object is sent as a JSON `POST /v1/ingest`. An **array** is
|
|
1300
|
+
* serialized to NDJSON (one record per line) and sent as a single
|
|
1301
|
+
* `application/x-ndjson` request: a bad record no longer fails or hides the
|
|
1302
|
+
* rest of the batch — per-record outcomes come back in the result
|
|
1303
|
+
* (`failed` / `results`), and `ok` is true only when every record succeeded.
|
|
1304
|
+
*
|
|
1305
|
+
* The array path sends one request regardless of size; bounded-concurrency
|
|
1306
|
+
* chunking of very large arrays is tracked separately (#196). For NDJSON you
|
|
1307
|
+
* already have (a file or stream), use {@link insertNDJSON}.
|
|
1308
|
+
*/
|
|
931
1309
|
async insert(data, opts) {
|
|
932
1310
|
if (Array.isArray(data)) {
|
|
933
|
-
|
|
934
|
-
(
|
|
935
|
-
method: "POST",
|
|
936
|
-
path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
|
|
937
|
-
body: row,
|
|
938
|
-
signal: opts?.signal
|
|
939
|
-
})
|
|
940
|
-
);
|
|
941
|
-
const results = await Promise.all(promises);
|
|
942
|
-
for (const res2 of results) {
|
|
943
|
-
if (res2.error) return err(res2.error);
|
|
1311
|
+
if (data.length === 0) {
|
|
1312
|
+
return ok({ ok: true, total: 0, succeeded: 0, failed: 0, duplicates: 0, results: [] });
|
|
944
1313
|
}
|
|
945
|
-
|
|
1314
|
+
const ndjson = data.map((row) => JSON.stringify(row)).join("\n");
|
|
1315
|
+
return this._sendNDJSON(ndjson, opts);
|
|
946
1316
|
}
|
|
947
1317
|
const { data: res, error } = await request(this._ctx, {
|
|
948
1318
|
method: "POST",
|
|
@@ -955,11 +1325,51 @@ var TableRef = class {
|
|
|
955
1325
|
if (res?.duplicate != null) result.duplicate = res.duplicate;
|
|
956
1326
|
return ok(result);
|
|
957
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Insert pre-formatted NDJSON (newline-delimited JSON, one record per line)
|
|
1330
|
+
* from a string, raw bytes, a Blob/File, or a byte stream — e.g. a `.ndjson`
|
|
1331
|
+
* file or a stream produced by another system. For in-memory rows, prefer
|
|
1332
|
+
* {@link insert}.
|
|
1333
|
+
*
|
|
1334
|
+
* Non-string sources are read fully into memory before sending (the server
|
|
1335
|
+
* streams the parse). Returns the same per-record batch summary as an array
|
|
1336
|
+
* `insert`.
|
|
1337
|
+
*/
|
|
1338
|
+
async insertNDJSON(source, opts) {
|
|
1339
|
+
const ndjson = await ndjsonSourceToString(source);
|
|
1340
|
+
return this._sendNDJSON(ndjson, opts);
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* @internal Send an NDJSON body and map the server's batch response onto an
|
|
1344
|
+
* {@link InsertResult}. A transport / whole-request failure surfaces as the
|
|
1345
|
+
* Result error arm; a processed batch (even with rejected records) surfaces
|
|
1346
|
+
* as the success arm with `ok = failed === 0`.
|
|
1347
|
+
*/
|
|
1348
|
+
async _sendNDJSON(ndjson, opts) {
|
|
1349
|
+
const { data, error } = await request(this._ctx, {
|
|
1350
|
+
method: "POST",
|
|
1351
|
+
path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
|
|
1352
|
+
rawBody: ndjson,
|
|
1353
|
+
contentType: NDJSON_CONTENT_TYPE,
|
|
1354
|
+
signal: opts?.signal
|
|
1355
|
+
});
|
|
1356
|
+
if (error) return err(error);
|
|
1357
|
+
const r = data ?? { total: 0, succeeded: 0, failed: 0, duplicates: 0 };
|
|
1358
|
+
const result = {
|
|
1359
|
+
ok: r.failed === 0,
|
|
1360
|
+
total: r.total,
|
|
1361
|
+
succeeded: r.succeeded,
|
|
1362
|
+
failed: r.failed,
|
|
1363
|
+
duplicates: r.duplicates
|
|
1364
|
+
};
|
|
1365
|
+
if (r.results && r.results.length > 0) result.results = r.results;
|
|
1366
|
+
return ok(result);
|
|
1367
|
+
}
|
|
958
1368
|
/** Fetch the schema for this table. */
|
|
959
1369
|
async schema(opts) {
|
|
960
1370
|
const { data, error } = await request(this._ctx, {
|
|
961
1371
|
method: "GET",
|
|
962
|
-
path: `/v1/schema?table=${encodeURIComponent(this._table)}`,
|
|
1372
|
+
path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
|
|
963
1373
|
signal: opts?.signal
|
|
964
1374
|
});
|
|
965
1375
|
if (error) return err(error);
|
|
@@ -990,7 +1400,10 @@ var WaveHouseClient = class {
|
|
|
990
1400
|
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
991
1401
|
auth: config.auth,
|
|
992
1402
|
options: {
|
|
993
|
-
maxRetries: config.options?.maxRetries ?? 2
|
|
1403
|
+
maxRetries: config.options?.maxRetries ?? 2,
|
|
1404
|
+
fetch: config.options?.fetch,
|
|
1405
|
+
headers: config.options?.headers,
|
|
1406
|
+
fetchOptions: config.options?.fetchOptions
|
|
994
1407
|
}
|
|
995
1408
|
};
|
|
996
1409
|
this.schema = new SchemaNamespace(this._ctx);
|
|
@@ -1022,23 +1435,21 @@ var WaveHouseClient = class {
|
|
|
1022
1435
|
sql(query, opts) {
|
|
1023
1436
|
if (Array.isArray(opts)) {
|
|
1024
1437
|
throw new Error(
|
|
1025
|
-
"[WaveHouse SDK] client.sql(sql, params) was removed. The /v1/
|
|
1438
|
+
"[WaveHouse SDK] client.sql(sql, params) was removed. The /v1/ops/query endpoint does not accept positional `?` params. Inline literals into the SQL, or use the structured query builder (wh.from(table)\u2026) for safe binding from user input."
|
|
1026
1439
|
);
|
|
1027
1440
|
}
|
|
1028
1441
|
return sql(this._ctx, query, opts);
|
|
1029
1442
|
}
|
|
1030
1443
|
/** @internal Create a stream for the given table. */
|
|
1031
1444
|
_createStream(table, opts) {
|
|
1032
|
-
if (typeof EventSource === "undefined") {
|
|
1033
|
-
throw new Error(
|
|
1034
|
-
"[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
1445
|
const transport = new SSETransport({
|
|
1038
1446
|
baseURL: this._ctx.baseURL,
|
|
1039
1447
|
table,
|
|
1040
1448
|
since: opts?.since,
|
|
1041
|
-
auth: this._ctx.auth
|
|
1449
|
+
auth: this._ctx.auth,
|
|
1450
|
+
fetch: this._ctx.options.fetch,
|
|
1451
|
+
headers: this._ctx.options.headers,
|
|
1452
|
+
fetchOptions: this._ctx.options.fetchOptions
|
|
1042
1453
|
});
|
|
1043
1454
|
const controller = new StreamController(transport);
|
|
1044
1455
|
if (opts?.signal) controller.attachSignal(opts.signal);
|