@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 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.1.0`). jsDelivr (`https://cdn.jsdelivr.net/npm/@wavehouse/sdk/+esm`) and unpkg (`https://unpkg.com/@wavehouse/sdk?module`) serve the same ES module.
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.1.0`) or float on a range (`@0` for the newest 0.x, `@0.1` for 0.1.x). Builds from `main` are published under the `dev` tag — `@wavehouse/sdk@dev` — for trying unreleased changes.
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).
@@ -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 WaveHouse base URL (default: http://localhost:8080)
45
- --out, -o Output file path (default: ./wavehouse.d.ts)
46
- --auth, -a Bearer token for authenticated endpoints
47
- --help, -h Show this help`);
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, auth) {
154
+ async function fetchSchemas(url, opts = {}) {
125
155
  const headers = {};
126
- if (auth) headers.Authorization = `Bearer ${auth}`;
127
- const res = await fetch(resolveURL(url, "/v1/ops/schema").toString(), { headers });
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, args.auth);
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 retryable = res.status === 503 || res.status >= 500;
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: `HTTP_${res.status}`,
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
- /** Get DLQ statistics (message counts per table). */
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
- signal: opts?.signal
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
- /** List all table schemas discovered from ClickHouse. */
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
- /** Force a schema refresh from ClickHouse system.columns. */
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
- event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
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
- console.warn("[wavehouse] SSE received malformed message:", data);
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; bounded-concurrency
1343
- * chunking of very large arrays is tracked separately (#196). For NDJSON you
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 (the server
1372
- * streams the parse). Returns the same per-record batch summary as an array
1373
- * `insert`.
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: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
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,