@wavehouse/sdk 0.1.0 → 0.2.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 +8 -4
- package/dist/cli/codegen.js +63 -12
- package/dist/index.cjs +183 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -72
- package/dist/index.d.ts +147 -72
- package/dist/index.global.js +11 -11
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +182 -85
- package/dist/index.js.map +1 -1
- package/package.json +12 -6
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npm install @wavehouse/sdk
|
|
|
10
10
|
|
|
11
11
|
Requires Node 22 or newer — the only line this SDK is tested against; Node 18 and 20 are past upstream end-of-life. Neither browsers nor Node need a polyfill: streaming runs on `fetch`, like the rest of the SDK — see [Runtime support](https://wavehouse.dev/sdk/#runtime-support).
|
|
12
12
|
|
|
13
|
-
This works in any framework that uses a bundler — React, Vue, Svelte, Angular, Astro, SolidJS, or plain Vite — with `import { createClient } from '@wavehouse/sdk'`.
|
|
13
|
+
This works in any framework that uses a bundler — React, Vue, Svelte, Angular, Astro, SolidJS, or plain Vite — with `import { createClient } from '@wavehouse/sdk'`. The SDK and the server share the SSE wire protocol, so move them as a **pair** — a skewed pair delivers no usable rows and raises no error ([details](https://wavehouse.dev/sdk/streaming)).
|
|
14
14
|
|
|
15
15
|
The package is published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) — npm shows a verified provenance badge on the [package page](https://www.npmjs.com/package/@wavehouse/sdk), and `npm audit signatures` checks the attestation against this repository's signed build.
|
|
16
16
|
|
|
@@ -30,7 +30,7 @@ No bundler, no `npm`, no framework required — drop the SDK straight into an HT
|
|
|
30
30
|
</script>
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.
|
|
33
|
+
Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.2.0`). jsDelivr (`https://cdn.jsdelivr.net/npm/@wavehouse/sdk/+esm`) and unpkg (`https://unpkg.com/@wavehouse/sdk?module`) serve the same ES module. The SDK and the server share the SSE wire protocol, so pin them as a **pair** — a skewed pair delivers no usable rows and raises no error.
|
|
34
34
|
|
|
35
35
|
**Classic global (`<script src>`).** For pages that can't use ES modules, the bundled IIFE build attaches everything to a `WaveHouse` global:
|
|
36
36
|
|
|
@@ -42,7 +42,7 @@ Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.1.0`). jsDelivr (
|
|
|
42
42
|
</script>
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
**Versioning.** A bare CDN URL serves the latest published **release**; pin for production (`@wavehouse/sdk@0.
|
|
45
|
+
**Versioning.** A bare CDN URL serves the latest published **release**; pin for production (`@wavehouse/sdk@0.2.0`) or float on a range (`@0` for the newest 0.x, `@0.2` for 0.2.x). Builds from `main` are published under the `dev` tag — `@wavehouse/sdk@dev` — for trying unreleased changes. Whichever you choose, move the SDK and the server together: they share the SSE wire protocol, and a skewed pair delivers no usable rows and raises no error — a newer SDK drops every row, an older one hands you an event per frame with `data: undefined`.
|
|
46
46
|
|
|
47
47
|
Streaming (`.stream()`) runs on `fetch`, so it works in both forms with no polyfill.
|
|
48
48
|
|
|
@@ -150,7 +150,7 @@ Generate TypeScript types from your live WaveHouse schema. The package ships a `
|
|
|
150
150
|
npx wavehouse-codegen --url http://localhost:8080 --out ./src/db.d.ts
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
This introspects `/v1/ops/schema`, maps ClickHouse column types to TypeScript, and outputs a `Database` interface you can pass to `createClient<Database>()`. `/v1/ops/schema` is **admin-only** — pass an admin-role token with `--auth <jwt>` (or `-a`) against any non-dev policy.
|
|
153
|
+
This introspects `/v1/ops/schema`, maps ClickHouse column types to TypeScript, and outputs a `Database` interface you can pass to `createClient<Database>()`. `/v1/ops/schema` is **admin-only** — pass an admin-role token with `--auth <jwt>` (or `-a`) against any non-dev policy. Over a nested settings directory the route admits the operator key alone: pass it with `--operator-key <key>` (or `-k`) and name the tenant with `--tenant <id>` (or `-t`); without `--tenant` it reads tenant `0`'s schema.
|
|
154
154
|
|
|
155
155
|
## Development & Testing
|
|
156
156
|
|
|
@@ -164,6 +164,10 @@ npm run test:watch # watch mode
|
|
|
164
164
|
npm run test:coverage # coverage report
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
+
### Built Package Smoke Check
|
|
168
|
+
|
|
169
|
+
`make smoke-ts-dist` (run by `make build-ts`) imports and `require()`s the built package by name (through its `exports` map) and runs the IIFE bundle as a classic script, at the oldest Node that `engines.node` admits, and checks that all three export the same names. It also type-checks a CommonJS and an ESM consumer of the package with `tsc --module node16`, so a CommonJS project (`"type": "commonjs"`) gets its own `.d.cts` declarations rather than TS1479. It downloads that Node once into `.bin/`. `pnpm run smoke:dist` runs the same check on the Node you are running.
|
|
170
|
+
|
|
167
171
|
### E2E Integration Tests
|
|
168
172
|
|
|
169
173
|
E2E tests live in `tests/e2e/sdk/` (repo root) and exercise the full pipeline through the SDK. See `make test-e2e` in the [Development Guide](https://wavehouse.dev/development#e2e-tests-via-sdk).
|
package/dist/cli/codegen.js
CHANGED
|
@@ -20,31 +20,61 @@ function resolveURL(base, path, params) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// src/cli/codegen.ts
|
|
23
|
+
var FLAGS = /* @__PURE__ */ new Set([
|
|
24
|
+
"--url",
|
|
25
|
+
"-u",
|
|
26
|
+
"--out",
|
|
27
|
+
"-o",
|
|
28
|
+
"--auth",
|
|
29
|
+
"-a",
|
|
30
|
+
"--tenant",
|
|
31
|
+
"-t",
|
|
32
|
+
"--operator-key",
|
|
33
|
+
"-k",
|
|
34
|
+
"--help",
|
|
35
|
+
"-h"
|
|
36
|
+
]);
|
|
37
|
+
function operand(value, usage) {
|
|
38
|
+
if (!value || FLAGS.has(value)) throw new Error(usage);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
23
41
|
function parseArgs(argv) {
|
|
24
42
|
const args = { url: "http://localhost:8080", out: "./wavehouse.d.ts" };
|
|
25
43
|
for (let i = 2; i < argv.length; i++) {
|
|
26
44
|
switch (argv[i]) {
|
|
27
45
|
case "--url":
|
|
28
46
|
case "-u":
|
|
29
|
-
args.url = argv[++i];
|
|
47
|
+
args.url = operand(argv[++i], "--url needs a URL");
|
|
30
48
|
break;
|
|
31
49
|
case "--out":
|
|
32
50
|
case "-o":
|
|
33
|
-
args.out = argv[++i];
|
|
51
|
+
args.out = operand(argv[++i], "--out needs a file path");
|
|
34
52
|
break;
|
|
35
53
|
case "--auth":
|
|
36
54
|
case "-a":
|
|
37
|
-
args.auth = argv[++i];
|
|
55
|
+
args.auth = operand(argv[++i], "--auth needs a token");
|
|
56
|
+
break;
|
|
57
|
+
case "--tenant":
|
|
58
|
+
case "-t":
|
|
59
|
+
args.tenant = operand(argv[++i], "--tenant needs a tenant id");
|
|
60
|
+
break;
|
|
61
|
+
case "--operator-key":
|
|
62
|
+
case "-k":
|
|
63
|
+
args.operatorKey = operand(argv[++i], "--operator-key needs a key");
|
|
38
64
|
break;
|
|
39
65
|
case "--help":
|
|
40
66
|
case "-h":
|
|
41
67
|
console.log(`wavehouse codegen \u2014 Generate TypeScript types from WaveHouse schema
|
|
42
68
|
|
|
43
69
|
Options:
|
|
44
|
-
--url, -u
|
|
45
|
-
--out, -o
|
|
46
|
-
--auth, -a
|
|
47
|
-
--
|
|
70
|
+
--url, -u WaveHouse base URL (default: http://localhost:8080)
|
|
71
|
+
--out, -o Output file path (default: ./wavehouse.d.ts)
|
|
72
|
+
--auth, -a Bearer token for authenticated endpoints
|
|
73
|
+
--operator-key, -k Operator key, sent as X-Operator-Key (the credential a
|
|
74
|
+
nested settings directory's /v1/ops/* routes admit)
|
|
75
|
+
--tenant, -t Tenant whose schema to read, sent as ?tenant=
|
|
76
|
+
(default: tenant 0)
|
|
77
|
+
--help, -h Show this help`);
|
|
48
78
|
process.exit(0);
|
|
49
79
|
}
|
|
50
80
|
}
|
|
@@ -121,10 +151,26 @@ function isTableSchema(value) {
|
|
|
121
151
|
const table = value;
|
|
122
152
|
return typeof table.name === "string" && Array.isArray(table.columns) && table.columns.every(isColumn);
|
|
123
153
|
}
|
|
124
|
-
async function fetchSchemas(url,
|
|
154
|
+
async function fetchSchemas(url, opts = {}) {
|
|
125
155
|
const headers = {};
|
|
126
|
-
if (auth) headers.Authorization = `Bearer ${auth}`;
|
|
127
|
-
|
|
156
|
+
if (opts.auth) headers.Authorization = `Bearer ${opts.auth}`;
|
|
157
|
+
if (opts.operatorKey) headers["X-Operator-Key"] = opts.operatorKey;
|
|
158
|
+
const target = resolveURL(
|
|
159
|
+
url,
|
|
160
|
+
"/v1/ops/schema",
|
|
161
|
+
opts.tenant ? { tenant: opts.tenant } : void 0
|
|
162
|
+
);
|
|
163
|
+
const credentialed = Boolean(opts.auth || opts.operatorKey);
|
|
164
|
+
const res = await fetch(target.toString(), {
|
|
165
|
+
headers,
|
|
166
|
+
redirect: credentialed ? "manual" : "follow"
|
|
167
|
+
});
|
|
168
|
+
if (credentialed && res.status >= 300 && res.status < 400) {
|
|
169
|
+
const location = res.headers.get("location") ?? "an unnamed location";
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Schema fetch was redirected (${res.status}) to ${location}, and a request carrying a credential does not follow a redirect: point --url at the final address`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
128
174
|
if (!res.ok) {
|
|
129
175
|
const text = await res.text();
|
|
130
176
|
throw new Error(`Schema fetch failed (${res.status}): ${text}`);
|
|
@@ -168,7 +214,11 @@ function pascalCase(s) {
|
|
|
168
214
|
async function main() {
|
|
169
215
|
const args = parseArgs(process.argv);
|
|
170
216
|
console.log(`Fetching schema from ${args.url}...`);
|
|
171
|
-
const schemas = await fetchSchemas(args.url,
|
|
217
|
+
const schemas = await fetchSchemas(args.url, {
|
|
218
|
+
auth: args.auth,
|
|
219
|
+
operatorKey: args.operatorKey,
|
|
220
|
+
tenant: args.tenant
|
|
221
|
+
});
|
|
172
222
|
if (schemas.length === 0) {
|
|
173
223
|
console.warn("No tables found. Is WaveHouse running with tables in ClickHouse?");
|
|
174
224
|
process.exit(1);
|
|
@@ -198,5 +248,6 @@ if (isDirectInvocation()) {
|
|
|
198
248
|
export {
|
|
199
249
|
chTypeToTS,
|
|
200
250
|
fetchSchemas,
|
|
201
|
-
generateTypes
|
|
251
|
+
generateTypes,
|
|
252
|
+
parseArgs
|
|
202
253
|
};
|
package/dist/index.cjs
CHANGED
|
@@ -24,9 +24,9 @@ __export(src_exports, {
|
|
|
24
24
|
LiveQuery: () => LiveQuery,
|
|
25
25
|
PipeRef: () => PipeRef,
|
|
26
26
|
PipesNamespace: () => PipesNamespace,
|
|
27
|
-
PolicyNamespace: () => PolicyNamespace,
|
|
28
27
|
QueryBuilder: () => QueryBuilder,
|
|
29
28
|
SchemaNamespace: () => SchemaNamespace,
|
|
29
|
+
SettingsNamespace: () => SettingsNamespace,
|
|
30
30
|
StreamController: () => StreamController,
|
|
31
31
|
SysNamespace: () => SysNamespace,
|
|
32
32
|
TableRef: () => TableRef,
|
|
@@ -44,10 +44,11 @@ async function parseErrorResponse(res) {
|
|
|
44
44
|
body = void 0;
|
|
45
45
|
}
|
|
46
46
|
const message = typeof body?.error === "string" ? body.error : typeof body?.message === "string" ? body.message : res.statusText;
|
|
47
|
-
const
|
|
47
|
+
const code = typeof body?.code === "string" && body.code !== "" ? body.code : `HTTP_${res.status}`;
|
|
48
|
+
const retryable = typeof body?.retryable === "boolean" ? body.retryable : res.status >= 500;
|
|
48
49
|
return {
|
|
49
50
|
status: res.status,
|
|
50
|
-
code
|
|
51
|
+
code,
|
|
51
52
|
message,
|
|
52
53
|
details: body,
|
|
53
54
|
retryable
|
|
@@ -103,6 +104,9 @@ function mergeHeaders(base, extra) {
|
|
|
103
104
|
}
|
|
104
105
|
return merged;
|
|
105
106
|
}
|
|
107
|
+
function tenantParam(opts) {
|
|
108
|
+
return opts?.tenant === void 0 ? void 0 : { tenant: opts.tenant };
|
|
109
|
+
}
|
|
106
110
|
function abortedResult() {
|
|
107
111
|
return {
|
|
108
112
|
data: null,
|
|
@@ -206,22 +210,26 @@ var DLQNamespace = class {
|
|
|
206
210
|
this._ctx = ctx;
|
|
207
211
|
this._createStream = createStream;
|
|
208
212
|
}
|
|
209
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Get DLQ statistics (message counts per table) — of `opts.tenant`, the
|
|
215
|
+
* default tenant without it. A tenant with no dead-letter queue is a `404`.
|
|
216
|
+
*/
|
|
210
217
|
async list(opts) {
|
|
211
218
|
const { data, error } = await request(this._ctx, {
|
|
212
219
|
method: "GET",
|
|
213
220
|
path: "/v1/ops/dlq/stats",
|
|
221
|
+
params: tenantParam(opts),
|
|
214
222
|
signal: opts?.signal
|
|
215
223
|
});
|
|
216
224
|
if (error) return err(error);
|
|
217
225
|
return ok(data);
|
|
218
226
|
}
|
|
219
|
-
/** Get DLQ stats filtered by table name. */
|
|
227
|
+
/** Get DLQ stats filtered by table name — of `opts.tenant`, the default tenant without it. */
|
|
220
228
|
async table(name, opts) {
|
|
221
229
|
const { data, error } = await request(this._ctx, {
|
|
222
230
|
method: "GET",
|
|
223
231
|
path: "/v1/ops/dlq/stats",
|
|
224
|
-
params: { table: name },
|
|
232
|
+
params: { table: name, ...tenantParam(opts) },
|
|
225
233
|
signal: opts?.signal
|
|
226
234
|
});
|
|
227
235
|
if (error) return err(error);
|
|
@@ -276,82 +284,23 @@ var PipesNamespace = class {
|
|
|
276
284
|
constructor(ctx) {
|
|
277
285
|
this._ctx = ctx;
|
|
278
286
|
}
|
|
279
|
-
/** List all registered pipes. */
|
|
287
|
+
/** List all registered pipes — of `opts.tenant`, the default tenant without it. */
|
|
280
288
|
async list(opts) {
|
|
281
289
|
const { data, error } = await request(this._ctx, {
|
|
282
290
|
method: "GET",
|
|
283
291
|
path: "/v1/ops/pipes",
|
|
292
|
+
params: tenantParam(opts),
|
|
284
293
|
signal: opts?.signal
|
|
285
294
|
});
|
|
286
295
|
if (error) return err(error);
|
|
287
296
|
return ok(data);
|
|
288
297
|
}
|
|
289
|
-
/** Get a single pipe definition by name. */
|
|
298
|
+
/** Get a single pipe definition by name — of `opts.tenant`, the default tenant without it. */
|
|
290
299
|
async get(name, opts) {
|
|
291
300
|
const { data, error } = await request(this._ctx, {
|
|
292
301
|
method: "GET",
|
|
293
302
|
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
294
|
-
|
|
295
|
-
});
|
|
296
|
-
if (error) return err(error);
|
|
297
|
-
return ok(data);
|
|
298
|
-
}
|
|
299
|
-
/** Create or update a pipe. */
|
|
300
|
-
async set(name, def, opts) {
|
|
301
|
-
const { error } = await request(this._ctx, {
|
|
302
|
-
method: "PUT",
|
|
303
|
-
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
304
|
-
body: def,
|
|
305
|
-
signal: opts?.signal
|
|
306
|
-
});
|
|
307
|
-
if (error) return err(error);
|
|
308
|
-
return ok(void 0);
|
|
309
|
-
}
|
|
310
|
-
/** Delete a pipe by name. */
|
|
311
|
-
async delete(name, opts) {
|
|
312
|
-
const { error } = await request(this._ctx, {
|
|
313
|
-
method: "DELETE",
|
|
314
|
-
path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
|
|
315
|
-
signal: opts?.signal
|
|
316
|
-
});
|
|
317
|
-
if (error) return err(error);
|
|
318
|
-
return ok(void 0);
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
// src/policy.ts
|
|
323
|
-
var PolicyNamespace = class {
|
|
324
|
-
_ctx;
|
|
325
|
-
constructor(ctx) {
|
|
326
|
-
this._ctx = ctx;
|
|
327
|
-
}
|
|
328
|
-
/** Get the current access control policy. */
|
|
329
|
-
async get(opts) {
|
|
330
|
-
const { data, error } = await request(this._ctx, {
|
|
331
|
-
method: "GET",
|
|
332
|
-
path: "/v1/ops/policy",
|
|
333
|
-
signal: opts?.signal
|
|
334
|
-
});
|
|
335
|
-
if (error) return err(error);
|
|
336
|
-
return ok(data);
|
|
337
|
-
}
|
|
338
|
-
/** Replace the entire access control policy. */
|
|
339
|
-
async set(policy, opts) {
|
|
340
|
-
const { error } = await request(this._ctx, {
|
|
341
|
-
method: "PUT",
|
|
342
|
-
path: "/v1/ops/policy",
|
|
343
|
-
body: policy,
|
|
344
|
-
signal: opts?.signal
|
|
345
|
-
});
|
|
346
|
-
if (error) return err(error);
|
|
347
|
-
return ok(void 0);
|
|
348
|
-
}
|
|
349
|
-
/** Validate a policy without applying it (dry run). */
|
|
350
|
-
async validate(policy, opts) {
|
|
351
|
-
const { data, error } = await request(this._ctx, {
|
|
352
|
-
method: "POST",
|
|
353
|
-
path: "/v1/ops/policy/validate",
|
|
354
|
-
body: policy,
|
|
303
|
+
params: tenantParam(opts),
|
|
355
304
|
signal: opts?.signal
|
|
356
305
|
});
|
|
357
306
|
if (error) return err(error);
|
|
@@ -365,11 +314,16 @@ var SchemaNamespace = class {
|
|
|
365
314
|
constructor(ctx) {
|
|
366
315
|
this._ctx = ctx;
|
|
367
316
|
}
|
|
368
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* List all table schemas discovered from ClickHouse — `opts.tenant`'s, the
|
|
319
|
+
* default tenant's without it. A `503` with `Retry-After` is a tenant whose
|
|
320
|
+
* first discovery has not succeeded yet.
|
|
321
|
+
*/
|
|
369
322
|
async list(opts) {
|
|
370
323
|
const { data, error } = await request(this._ctx, {
|
|
371
324
|
method: "GET",
|
|
372
325
|
path: "/v1/ops/schema",
|
|
326
|
+
params: tenantParam(opts),
|
|
373
327
|
signal: opts?.signal
|
|
374
328
|
});
|
|
375
329
|
if (error) return err(error);
|
|
@@ -386,11 +340,15 @@ var SchemaNamespace = class {
|
|
|
386
340
|
}
|
|
387
341
|
return ok(schemas);
|
|
388
342
|
}
|
|
389
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* Force a schema refresh from ClickHouse system.columns — of `opts.tenant`,
|
|
345
|
+
* the default tenant without it.
|
|
346
|
+
*/
|
|
390
347
|
async refresh(opts) {
|
|
391
348
|
const { error } = await request(this._ctx, {
|
|
392
349
|
method: "POST",
|
|
393
350
|
path: "/v1/ops/schema/refresh",
|
|
351
|
+
params: tenantParam(opts),
|
|
394
352
|
signal: opts?.signal
|
|
395
353
|
});
|
|
396
354
|
if (error) return err(error);
|
|
@@ -398,11 +356,40 @@ var SchemaNamespace = class {
|
|
|
398
356
|
}
|
|
399
357
|
};
|
|
400
358
|
|
|
359
|
+
// src/settings.ts
|
|
360
|
+
var SettingsNamespace = class {
|
|
361
|
+
_ctx;
|
|
362
|
+
constructor(ctx) {
|
|
363
|
+
this._ctx = ctx;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Re-validate and adopt the settings directory (POST /v1/ops/settings/reload).
|
|
367
|
+
* Resolves with `{ adopted: true, findings }` when adopted (warnings
|
|
368
|
+
* included); a rejected directory is a 422 error whose `details` carries the
|
|
369
|
+
* same `{ adopted: false, findings }` body, and the previous settings stay
|
|
370
|
+
* in effect.
|
|
371
|
+
*
|
|
372
|
+
* `opts.tenant` reloads that tenant's folder alone; without it the whole
|
|
373
|
+
* directory is reloaded.
|
|
374
|
+
*/
|
|
375
|
+
async reload(opts) {
|
|
376
|
+
const { data, error } = await request(this._ctx, {
|
|
377
|
+
method: "POST",
|
|
378
|
+
path: "/v1/ops/settings/reload",
|
|
379
|
+
params: tenantParam(opts),
|
|
380
|
+
signal: opts?.signal
|
|
381
|
+
});
|
|
382
|
+
if (error) return err(error);
|
|
383
|
+
return ok(data);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
401
387
|
// src/sql.ts
|
|
402
388
|
async function sql(ctx, query, opts) {
|
|
403
389
|
const { data, error } = await request(ctx, {
|
|
404
390
|
method: "POST",
|
|
405
391
|
path: "/v1/ops/query",
|
|
392
|
+
params: tenantParam(opts),
|
|
406
393
|
body: { sql: query },
|
|
407
394
|
signal: opts?.signal
|
|
408
395
|
});
|
|
@@ -609,6 +596,30 @@ var SSETransport = class {
|
|
|
609
596
|
_retryFloorMs = 0;
|
|
610
597
|
/** Cuts a reconnect gap short when set — see `_sleep`. */
|
|
611
598
|
_wake = null;
|
|
599
|
+
/**
|
|
600
|
+
* Column names for the rows currently arriving, from the most recent
|
|
601
|
+
* `event: schema` frame. Rows travel positionally, so this is what turns one
|
|
602
|
+
* back into an object; `null` means none has been announced yet, and a data
|
|
603
|
+
* frame arriving in that state is unreadable rather than merely unlabeled.
|
|
604
|
+
*
|
|
605
|
+
* Reset at the start of every connection attempt: a reconnect re-announces,
|
|
606
|
+
* and zipping a new connection's rows under the previous one's list would
|
|
607
|
+
* silently mislabel every value.
|
|
608
|
+
*/
|
|
609
|
+
_columns = null;
|
|
610
|
+
/**
|
|
611
|
+
* Per-cause warning budget for the current connection. A server that predates
|
|
612
|
+
* the positional wire sends every row without a schema event, so an unbounded
|
|
613
|
+
* `console.warn` per row floods the console for the whole connection — and so
|
|
614
|
+
* does any other per-frame warn against a server sending something the reader
|
|
615
|
+
* cannot use. Every PER-FRAME warn site is bounded, but keyed by CAUSE so a row
|
|
616
|
+
* with no announcement, a short row, a long row, a non-array row, a malformed
|
|
617
|
+
* schema frame and unparseable JSON stay individually visible: collapsing them
|
|
618
|
+
* behind one flag would hide the distinct failures the warning exists to
|
|
619
|
+
* surface. Reset per attempt. (The per-connect() notices are not per-frame and
|
|
620
|
+
* are not bounded here.)
|
|
621
|
+
*/
|
|
622
|
+
_warnBudget = /* @__PURE__ */ new Map();
|
|
612
623
|
onEvent = null;
|
|
613
624
|
onStatus = null;
|
|
614
625
|
onError = null;
|
|
@@ -674,10 +685,26 @@ var SSETransport = class {
|
|
|
674
685
|
attempt++;
|
|
675
686
|
}
|
|
676
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* Warn at most `LIMIT` times per cause per connection, then once more to say
|
|
690
|
+
* how many were suppressed. Returns nothing — callers warn and bail as before.
|
|
691
|
+
*/
|
|
692
|
+
_warnBounded(cause, message, data) {
|
|
693
|
+
const LIMIT = 3;
|
|
694
|
+
const seen = (this._warnBudget.get(cause) ?? 0) + 1;
|
|
695
|
+
this._warnBudget.set(cause, seen);
|
|
696
|
+
if (seen <= LIMIT) {
|
|
697
|
+
console.warn(message, data);
|
|
698
|
+
} else if (seen === LIMIT + 1) {
|
|
699
|
+
console.warn(`${message} \u2014 further occurrences suppressed for this connection`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
677
702
|
/** One connection attempt: request, validate, then pump frames until it ends. */
|
|
678
703
|
async _attempt() {
|
|
679
704
|
const ac = new AbortController();
|
|
680
705
|
this._abort = ac;
|
|
706
|
+
this._columns = null;
|
|
707
|
+
this._warnBudget.clear();
|
|
681
708
|
let target;
|
|
682
709
|
try {
|
|
683
710
|
target = this._url();
|
|
@@ -839,6 +866,10 @@ var SSETransport = class {
|
|
|
839
866
|
onEvent: (msg) => {
|
|
840
867
|
if (msg.id) this._lastEventId = msg.id;
|
|
841
868
|
if (!msg.data) return;
|
|
869
|
+
if (msg.event === "schema") {
|
|
870
|
+
this._applySchema(msg.data);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
842
873
|
this._dispatch(msg.data);
|
|
843
874
|
},
|
|
844
875
|
onRetry: (ms) => {
|
|
@@ -877,9 +908,39 @@ var SSETransport = class {
|
|
|
877
908
|
});
|
|
878
909
|
}
|
|
879
910
|
}
|
|
911
|
+
/**
|
|
912
|
+
* Record the column list an `event: schema` frame announces. Rows that follow
|
|
913
|
+
* are zipped against it, so a malformed announcement drops the list rather
|
|
914
|
+
* than keeping a stale one: mislabeling values is worse than delivering none.
|
|
915
|
+
*/
|
|
916
|
+
_applySchema(data) {
|
|
917
|
+
try {
|
|
918
|
+
const msg = JSON.parse(data);
|
|
919
|
+
if (!Array.isArray(msg.columns) || msg.columns.some((c) => typeof c !== "string")) {
|
|
920
|
+
throw new Error("columns must be an array of strings");
|
|
921
|
+
}
|
|
922
|
+
if (new Set(msg.columns).size !== msg.columns.length) {
|
|
923
|
+
throw new Error("columns contains a duplicate name");
|
|
924
|
+
}
|
|
925
|
+
this._columns = msg.columns;
|
|
926
|
+
} catch {
|
|
927
|
+
this._columns = null;
|
|
928
|
+
this._warnBounded(
|
|
929
|
+
"bad-schema-frame",
|
|
930
|
+
"[wavehouse] SSE received a malformed schema event:",
|
|
931
|
+
data
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
880
935
|
/**
|
|
881
936
|
* Turn one frame's `data` into a StreamEvent.
|
|
882
937
|
*
|
|
938
|
+
* The row arrives positionally — a JSON array — and is zipped back into an
|
|
939
|
+
* object against the announced column list, so the public API still yields
|
|
940
|
+
* row objects. A row with no announcement, or one whose length disagrees with
|
|
941
|
+
* the list, cannot be read: there is no way to say which value is which, so
|
|
942
|
+
* it is dropped rather than delivered under guessed names.
|
|
943
|
+
*
|
|
883
944
|
* Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
|
|
884
945
|
* every complete frame in a chunk synchronously, so a subscriber that closes
|
|
885
946
|
* from inside its own `next` — "read until I see my event, then stop" —
|
|
@@ -892,9 +953,34 @@ var SSETransport = class {
|
|
|
892
953
|
let event;
|
|
893
954
|
try {
|
|
894
955
|
const msg = JSON.parse(data);
|
|
895
|
-
|
|
956
|
+
const columns = this._columns;
|
|
957
|
+
if (!columns) {
|
|
958
|
+
this._warnBounded(
|
|
959
|
+
"no-schema",
|
|
960
|
+
"[wavehouse] SSE received a row before any schema event:",
|
|
961
|
+
data
|
|
962
|
+
);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (!Array.isArray(msg.row)) {
|
|
966
|
+
this._warnBounded("row-not-array", "[wavehouse] SSE row is not an array:", data);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (msg.row.length !== columns.length) {
|
|
970
|
+
this._warnBounded(
|
|
971
|
+
msg.row.length < columns.length ? "row-short" : "row-long",
|
|
972
|
+
`[wavehouse] SSE row has ${msg.row.length} value(s) but ${columns.length} column(s) were announced:`,
|
|
973
|
+
data
|
|
974
|
+
);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const row = /* @__PURE__ */ Object.create(null);
|
|
978
|
+
for (let i = 0; i < columns.length; i++) {
|
|
979
|
+
row[columns[i]] = msg.row[i];
|
|
980
|
+
}
|
|
981
|
+
event = { table: msg.table_name, timestamp: msg.received_timestamp, data: row };
|
|
896
982
|
} catch {
|
|
897
|
-
|
|
983
|
+
this._warnBounded("bad-json", "[wavehouse] SSE received malformed message:", data);
|
|
898
984
|
return;
|
|
899
985
|
}
|
|
900
986
|
try {
|
|
@@ -1280,7 +1366,7 @@ function compareOrdered(actual, expected, cmp) {
|
|
|
1280
1366
|
return false;
|
|
1281
1367
|
}
|
|
1282
1368
|
function projectColumns(row, columns) {
|
|
1283
|
-
const result =
|
|
1369
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
1284
1370
|
for (const col of columns) {
|
|
1285
1371
|
if (col in row) result[col] = row[col];
|
|
1286
1372
|
}
|
|
@@ -1288,6 +1374,7 @@ function projectColumns(row, columns) {
|
|
|
1288
1374
|
}
|
|
1289
1375
|
|
|
1290
1376
|
// src/table.ts
|
|
1377
|
+
var JSON_CONTENT_TYPE = "application/json";
|
|
1291
1378
|
var NDJSON_CONTENT_TYPE = "application/x-ndjson";
|
|
1292
1379
|
async function ndjsonSourceToString(source) {
|
|
1293
1380
|
if (typeof source === "string") return source;
|
|
@@ -1339,8 +1426,10 @@ var TableRef = class {
|
|
|
1339
1426
|
* rest of the batch — per-record outcomes come back in the result
|
|
1340
1427
|
* (`failed` / `results`), and `ok` is true only when every record succeeded.
|
|
1341
1428
|
*
|
|
1342
|
-
* The array path sends one request regardless of size
|
|
1343
|
-
*
|
|
1429
|
+
* The array path sends one request regardless of size, so it is bound by the
|
|
1430
|
+
* server's 16 MiB request-body cap (an over-cap array is a `413` with nothing
|
|
1431
|
+
* ingested); bounded-concurrency chunking of very large arrays is tracked
|
|
1432
|
+
* separately (#196). For NDJSON you
|
|
1344
1433
|
* already have (a file or stream), use {@link insertNDJSON}.
|
|
1345
1434
|
*/
|
|
1346
1435
|
async insert(data, opts) {
|
|
@@ -1355,6 +1444,9 @@ var TableRef = class {
|
|
|
1355
1444
|
method: "POST",
|
|
1356
1445
|
path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
|
|
1357
1446
|
body: data,
|
|
1447
|
+
// Ingest requires a declared format — an undeclared Content-Type is a
|
|
1448
|
+
// 415 — so state it here rather than leaning on the request default.
|
|
1449
|
+
contentType: JSON_CONTENT_TYPE,
|
|
1358
1450
|
signal: opts?.signal
|
|
1359
1451
|
});
|
|
1360
1452
|
if (error) return err(error);
|
|
@@ -1368,9 +1460,11 @@ var TableRef = class {
|
|
|
1368
1460
|
* file or a stream produced by another system. For in-memory rows, prefer
|
|
1369
1461
|
* {@link insert}.
|
|
1370
1462
|
*
|
|
1371
|
-
* Non-string sources are read fully into memory before sending
|
|
1372
|
-
*
|
|
1373
|
-
* `
|
|
1463
|
+
* Non-string sources are read fully into memory before sending. The server
|
|
1464
|
+
* buffers the whole body too, so the request-body cap applies to NDJSON
|
|
1465
|
+
* exactly as to any other shape — an upload over it is a `413` with nothing
|
|
1466
|
+
* ingested, and must be split across several calls. Returns the same
|
|
1467
|
+
* per-record batch summary as an array `insert`.
|
|
1374
1468
|
*/
|
|
1375
1469
|
async insertNDJSON(source, opts) {
|
|
1376
1470
|
const ndjson = await ndjsonSourceToString(source);
|
|
@@ -1402,11 +1496,12 @@ var TableRef = class {
|
|
|
1402
1496
|
if (r.results && r.results.length > 0) result.results = r.results;
|
|
1403
1497
|
return ok(result);
|
|
1404
1498
|
}
|
|
1405
|
-
/** Fetch the schema for this table. */
|
|
1499
|
+
/** Fetch the schema for this table — under `opts.tenant`, the default tenant without it. */
|
|
1406
1500
|
async schema(opts) {
|
|
1407
1501
|
const { data, error } = await request(this._ctx, {
|
|
1408
1502
|
method: "GET",
|
|
1409
|
-
path:
|
|
1503
|
+
path: "/v1/ops/schema",
|
|
1504
|
+
params: { table: this._table, ...tenantParam(opts) },
|
|
1410
1505
|
signal: opts?.signal
|
|
1411
1506
|
});
|
|
1412
1507
|
if (error) return err(error);
|
|
@@ -1424,14 +1519,14 @@ var WaveHouseClient = class {
|
|
|
1424
1519
|
_ctx;
|
|
1425
1520
|
/** Schema introspection namespace. */
|
|
1426
1521
|
schema;
|
|
1427
|
-
/** Access control policy namespace (admin). */
|
|
1428
|
-
policy;
|
|
1429
1522
|
/** Dead Letter Queue namespace. */
|
|
1430
1523
|
dlq;
|
|
1431
1524
|
/** System health/readiness namespace. */
|
|
1432
1525
|
sys;
|
|
1433
1526
|
/** Named query pipes admin namespace. */
|
|
1434
1527
|
pipes;
|
|
1528
|
+
/** Settings directory namespace (admin). */
|
|
1529
|
+
settings;
|
|
1435
1530
|
constructor(config) {
|
|
1436
1531
|
this._ctx = {
|
|
1437
1532
|
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
@@ -1444,10 +1539,10 @@ var WaveHouseClient = class {
|
|
|
1444
1539
|
}
|
|
1445
1540
|
};
|
|
1446
1541
|
this.schema = new SchemaNamespace(this._ctx);
|
|
1447
|
-
this.policy = new PolicyNamespace(this._ctx);
|
|
1448
1542
|
this.dlq = new DLQNamespace(this._ctx, (table, opts) => this._createStream(table, opts));
|
|
1449
1543
|
this.sys = new SysNamespace(this._ctx);
|
|
1450
1544
|
this.pipes = new PipesNamespace(this._ctx);
|
|
1545
|
+
this.settings = new SettingsNamespace(this._ctx);
|
|
1451
1546
|
}
|
|
1452
1547
|
/** Get a table reference for building queries, inserts, and streams. */
|
|
1453
1548
|
from(table) {
|
|
@@ -1467,7 +1562,9 @@ var WaveHouseClient = class {
|
|
|
1467
1562
|
* `service` role). The endpoint proxies straight to ClickHouse's HTTP
|
|
1468
1563
|
* interface so any ClickHouse-accepted SQL works; positional `?` param
|
|
1469
1564
|
* binding is NOT supported — inline literals or use the structured query
|
|
1470
|
-
* builder for safe binding. See sql.ts for details.
|
|
1565
|
+
* builder for safe binding. See sql.ts for details. `opts.tenant` names the
|
|
1566
|
+
* tenant whose ClickHouse the SQL runs against, the default tenant without
|
|
1567
|
+
* it.
|
|
1471
1568
|
*/
|
|
1472
1569
|
sql(query, opts) {
|
|
1473
1570
|
if (Array.isArray(opts)) {
|
|
@@ -1502,9 +1599,9 @@ function createClient(config) {
|
|
|
1502
1599
|
LiveQuery,
|
|
1503
1600
|
PipeRef,
|
|
1504
1601
|
PipesNamespace,
|
|
1505
|
-
PolicyNamespace,
|
|
1506
1602
|
QueryBuilder,
|
|
1507
1603
|
SchemaNamespace,
|
|
1604
|
+
SettingsNamespace,
|
|
1508
1605
|
StreamController,
|
|
1509
1606
|
SysNamespace,
|
|
1510
1607
|
TableRef,
|