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