@wavehouse/sdk 0.1.1 → 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/dist/index.js CHANGED
@@ -7,10 +7,11 @@ async function parseErrorResponse(res) {
7
7
  body = void 0;
8
8
  }
9
9
  const message = typeof body?.error === "string" ? body.error : typeof body?.message === "string" ? body.message : res.statusText;
10
- const retryable = res.status === 503 || res.status >= 500;
10
+ const code = typeof body?.code === "string" && body.code !== "" ? body.code : `HTTP_${res.status}`;
11
+ const retryable = typeof body?.retryable === "boolean" ? body.retryable : res.status >= 500;
11
12
  return {
12
13
  status: res.status,
13
- code: `HTTP_${res.status}`,
14
+ code,
14
15
  message,
15
16
  details: body,
16
17
  retryable
@@ -66,6 +67,9 @@ function mergeHeaders(base, extra) {
66
67
  }
67
68
  return merged;
68
69
  }
70
+ function tenantParam(opts) {
71
+ return opts?.tenant === void 0 ? void 0 : { tenant: opts.tenant };
72
+ }
69
73
  function abortedResult() {
70
74
  return {
71
75
  data: null,
@@ -169,22 +173,26 @@ var DLQNamespace = class {
169
173
  this._ctx = ctx;
170
174
  this._createStream = createStream;
171
175
  }
172
- /** Get DLQ statistics (message counts per table). */
176
+ /**
177
+ * Get DLQ statistics (message counts per table) — of `opts.tenant`, the
178
+ * default tenant without it. A tenant with no dead-letter queue is a `404`.
179
+ */
173
180
  async list(opts) {
174
181
  const { data, error } = await request(this._ctx, {
175
182
  method: "GET",
176
183
  path: "/v1/ops/dlq/stats",
184
+ params: tenantParam(opts),
177
185
  signal: opts?.signal
178
186
  });
179
187
  if (error) return err(error);
180
188
  return ok(data);
181
189
  }
182
- /** Get DLQ stats filtered by table name. */
190
+ /** Get DLQ stats filtered by table name — of `opts.tenant`, the default tenant without it. */
183
191
  async table(name, opts) {
184
192
  const { data, error } = await request(this._ctx, {
185
193
  method: "GET",
186
194
  path: "/v1/ops/dlq/stats",
187
- params: { table: name },
195
+ params: { table: name, ...tenantParam(opts) },
188
196
  signal: opts?.signal
189
197
  });
190
198
  if (error) return err(error);
@@ -239,82 +247,23 @@ var PipesNamespace = class {
239
247
  constructor(ctx) {
240
248
  this._ctx = ctx;
241
249
  }
242
- /** List all registered pipes. */
250
+ /** List all registered pipes — of `opts.tenant`, the default tenant without it. */
243
251
  async list(opts) {
244
252
  const { data, error } = await request(this._ctx, {
245
253
  method: "GET",
246
254
  path: "/v1/ops/pipes",
255
+ params: tenantParam(opts),
247
256
  signal: opts?.signal
248
257
  });
249
258
  if (error) return err(error);
250
259
  return ok(data);
251
260
  }
252
- /** Get a single pipe definition by name. */
261
+ /** Get a single pipe definition by name — of `opts.tenant`, the default tenant without it. */
253
262
  async get(name, opts) {
254
263
  const { data, error } = await request(this._ctx, {
255
264
  method: "GET",
256
265
  path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
257
- signal: opts?.signal
258
- });
259
- if (error) return err(error);
260
- return ok(data);
261
- }
262
- /** Create or update a pipe. */
263
- async set(name, def, opts) {
264
- const { error } = await request(this._ctx, {
265
- method: "PUT",
266
- path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
267
- body: def,
268
- signal: opts?.signal
269
- });
270
- if (error) return err(error);
271
- return ok(void 0);
272
- }
273
- /** Delete a pipe by name. */
274
- async delete(name, opts) {
275
- const { error } = await request(this._ctx, {
276
- method: "DELETE",
277
- path: `/v1/ops/pipes/${encodeURIComponent(name)}`,
278
- signal: opts?.signal
279
- });
280
- if (error) return err(error);
281
- return ok(void 0);
282
- }
283
- };
284
-
285
- // src/policy.ts
286
- var PolicyNamespace = class {
287
- _ctx;
288
- constructor(ctx) {
289
- this._ctx = ctx;
290
- }
291
- /** Get the current access control policy. */
292
- async get(opts) {
293
- const { data, error } = await request(this._ctx, {
294
- method: "GET",
295
- path: "/v1/ops/policy",
296
- signal: opts?.signal
297
- });
298
- if (error) return err(error);
299
- return ok(data);
300
- }
301
- /** Replace the entire access control policy. */
302
- async set(policy, opts) {
303
- const { error } = await request(this._ctx, {
304
- method: "PUT",
305
- path: "/v1/ops/policy",
306
- body: policy,
307
- signal: opts?.signal
308
- });
309
- if (error) return err(error);
310
- return ok(void 0);
311
- }
312
- /** Validate a policy without applying it (dry run). */
313
- async validate(policy, opts) {
314
- const { data, error } = await request(this._ctx, {
315
- method: "POST",
316
- path: "/v1/ops/policy/validate",
317
- body: policy,
266
+ params: tenantParam(opts),
318
267
  signal: opts?.signal
319
268
  });
320
269
  if (error) return err(error);
@@ -328,11 +277,16 @@ var SchemaNamespace = class {
328
277
  constructor(ctx) {
329
278
  this._ctx = ctx;
330
279
  }
331
- /** List all table schemas discovered from ClickHouse. */
280
+ /**
281
+ * List all table schemas discovered from ClickHouse — `opts.tenant`'s, the
282
+ * default tenant's without it. A `503` with `Retry-After` is a tenant whose
283
+ * first discovery has not succeeded yet.
284
+ */
332
285
  async list(opts) {
333
286
  const { data, error } = await request(this._ctx, {
334
287
  method: "GET",
335
288
  path: "/v1/ops/schema",
289
+ params: tenantParam(opts),
336
290
  signal: opts?.signal
337
291
  });
338
292
  if (error) return err(error);
@@ -349,11 +303,15 @@ var SchemaNamespace = class {
349
303
  }
350
304
  return ok(schemas);
351
305
  }
352
- /** Force a schema refresh from ClickHouse system.columns. */
306
+ /**
307
+ * Force a schema refresh from ClickHouse system.columns — of `opts.tenant`,
308
+ * the default tenant without it.
309
+ */
353
310
  async refresh(opts) {
354
311
  const { error } = await request(this._ctx, {
355
312
  method: "POST",
356
313
  path: "/v1/ops/schema/refresh",
314
+ params: tenantParam(opts),
357
315
  signal: opts?.signal
358
316
  });
359
317
  if (error) return err(error);
@@ -361,11 +319,40 @@ var SchemaNamespace = class {
361
319
  }
362
320
  };
363
321
 
322
+ // src/settings.ts
323
+ var SettingsNamespace = class {
324
+ _ctx;
325
+ constructor(ctx) {
326
+ this._ctx = ctx;
327
+ }
328
+ /**
329
+ * Re-validate and adopt the settings directory (POST /v1/ops/settings/reload).
330
+ * Resolves with `{ adopted: true, findings }` when adopted (warnings
331
+ * included); a rejected directory is a 422 error whose `details` carries the
332
+ * same `{ adopted: false, findings }` body, and the previous settings stay
333
+ * in effect.
334
+ *
335
+ * `opts.tenant` reloads that tenant's folder alone; without it the whole
336
+ * directory is reloaded.
337
+ */
338
+ async reload(opts) {
339
+ const { data, error } = await request(this._ctx, {
340
+ method: "POST",
341
+ path: "/v1/ops/settings/reload",
342
+ params: tenantParam(opts),
343
+ signal: opts?.signal
344
+ });
345
+ if (error) return err(error);
346
+ return ok(data);
347
+ }
348
+ };
349
+
364
350
  // src/sql.ts
365
351
  async function sql(ctx, query, opts) {
366
352
  const { data, error } = await request(ctx, {
367
353
  method: "POST",
368
354
  path: "/v1/ops/query",
355
+ params: tenantParam(opts),
369
356
  body: { sql: query },
370
357
  signal: opts?.signal
371
358
  });
@@ -572,6 +559,30 @@ var SSETransport = class {
572
559
  _retryFloorMs = 0;
573
560
  /** Cuts a reconnect gap short when set — see `_sleep`. */
574
561
  _wake = null;
562
+ /**
563
+ * Column names for the rows currently arriving, from the most recent
564
+ * `event: schema` frame. Rows travel positionally, so this is what turns one
565
+ * back into an object; `null` means none has been announced yet, and a data
566
+ * frame arriving in that state is unreadable rather than merely unlabeled.
567
+ *
568
+ * Reset at the start of every connection attempt: a reconnect re-announces,
569
+ * and zipping a new connection's rows under the previous one's list would
570
+ * silently mislabel every value.
571
+ */
572
+ _columns = null;
573
+ /**
574
+ * Per-cause warning budget for the current connection. A server that predates
575
+ * the positional wire sends every row without a schema event, so an unbounded
576
+ * `console.warn` per row floods the console for the whole connection — and so
577
+ * does any other per-frame warn against a server sending something the reader
578
+ * cannot use. Every PER-FRAME warn site is bounded, but keyed by CAUSE so a row
579
+ * with no announcement, a short row, a long row, a non-array row, a malformed
580
+ * schema frame and unparseable JSON stay individually visible: collapsing them
581
+ * behind one flag would hide the distinct failures the warning exists to
582
+ * surface. Reset per attempt. (The per-connect() notices are not per-frame and
583
+ * are not bounded here.)
584
+ */
585
+ _warnBudget = /* @__PURE__ */ new Map();
575
586
  onEvent = null;
576
587
  onStatus = null;
577
588
  onError = null;
@@ -637,10 +648,26 @@ var SSETransport = class {
637
648
  attempt++;
638
649
  }
639
650
  }
651
+ /**
652
+ * Warn at most `LIMIT` times per cause per connection, then once more to say
653
+ * how many were suppressed. Returns nothing — callers warn and bail as before.
654
+ */
655
+ _warnBounded(cause, message, data) {
656
+ const LIMIT = 3;
657
+ const seen = (this._warnBudget.get(cause) ?? 0) + 1;
658
+ this._warnBudget.set(cause, seen);
659
+ if (seen <= LIMIT) {
660
+ console.warn(message, data);
661
+ } else if (seen === LIMIT + 1) {
662
+ console.warn(`${message} \u2014 further occurrences suppressed for this connection`);
663
+ }
664
+ }
640
665
  /** One connection attempt: request, validate, then pump frames until it ends. */
641
666
  async _attempt() {
642
667
  const ac = new AbortController();
643
668
  this._abort = ac;
669
+ this._columns = null;
670
+ this._warnBudget.clear();
644
671
  let target;
645
672
  try {
646
673
  target = this._url();
@@ -802,6 +829,10 @@ var SSETransport = class {
802
829
  onEvent: (msg) => {
803
830
  if (msg.id) this._lastEventId = msg.id;
804
831
  if (!msg.data) return;
832
+ if (msg.event === "schema") {
833
+ this._applySchema(msg.data);
834
+ return;
835
+ }
805
836
  this._dispatch(msg.data);
806
837
  },
807
838
  onRetry: (ms) => {
@@ -840,9 +871,39 @@ var SSETransport = class {
840
871
  });
841
872
  }
842
873
  }
874
+ /**
875
+ * Record the column list an `event: schema` frame announces. Rows that follow
876
+ * are zipped against it, so a malformed announcement drops the list rather
877
+ * than keeping a stale one: mislabeling values is worse than delivering none.
878
+ */
879
+ _applySchema(data) {
880
+ try {
881
+ const msg = JSON.parse(data);
882
+ if (!Array.isArray(msg.columns) || msg.columns.some((c) => typeof c !== "string")) {
883
+ throw new Error("columns must be an array of strings");
884
+ }
885
+ if (new Set(msg.columns).size !== msg.columns.length) {
886
+ throw new Error("columns contains a duplicate name");
887
+ }
888
+ this._columns = msg.columns;
889
+ } catch {
890
+ this._columns = null;
891
+ this._warnBounded(
892
+ "bad-schema-frame",
893
+ "[wavehouse] SSE received a malformed schema event:",
894
+ data
895
+ );
896
+ }
897
+ }
843
898
  /**
844
899
  * Turn one frame's `data` into a StreamEvent.
845
900
  *
901
+ * The row arrives positionally — a JSON array — and is zipped back into an
902
+ * object against the announced column list, so the public API still yields
903
+ * row objects. A row with no announcement, or one whose length disagrees with
904
+ * the list, cannot be read: there is no way to say which value is which, so
905
+ * it is dropped rather than delivered under guessed names.
906
+ *
846
907
  * Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
847
908
  * every complete frame in a chunk synchronously, so a subscriber that closes
848
909
  * from inside its own `next` — "read until I see my event, then stop" —
@@ -855,9 +916,34 @@ var SSETransport = class {
855
916
  let event;
856
917
  try {
857
918
  const msg = JSON.parse(data);
858
- event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
919
+ const columns = this._columns;
920
+ if (!columns) {
921
+ this._warnBounded(
922
+ "no-schema",
923
+ "[wavehouse] SSE received a row before any schema event:",
924
+ data
925
+ );
926
+ return;
927
+ }
928
+ if (!Array.isArray(msg.row)) {
929
+ this._warnBounded("row-not-array", "[wavehouse] SSE row is not an array:", data);
930
+ return;
931
+ }
932
+ if (msg.row.length !== columns.length) {
933
+ this._warnBounded(
934
+ msg.row.length < columns.length ? "row-short" : "row-long",
935
+ `[wavehouse] SSE row has ${msg.row.length} value(s) but ${columns.length} column(s) were announced:`,
936
+ data
937
+ );
938
+ return;
939
+ }
940
+ const row = /* @__PURE__ */ Object.create(null);
941
+ for (let i = 0; i < columns.length; i++) {
942
+ row[columns[i]] = msg.row[i];
943
+ }
944
+ event = { table: msg.table_name, timestamp: msg.received_timestamp, data: row };
859
945
  } catch {
860
- console.warn("[wavehouse] SSE received malformed message:", data);
946
+ this._warnBounded("bad-json", "[wavehouse] SSE received malformed message:", data);
861
947
  return;
862
948
  }
863
949
  try {
@@ -1243,7 +1329,7 @@ function compareOrdered(actual, expected, cmp) {
1243
1329
  return false;
1244
1330
  }
1245
1331
  function projectColumns(row, columns) {
1246
- const result = {};
1332
+ const result = /* @__PURE__ */ Object.create(null);
1247
1333
  for (const col of columns) {
1248
1334
  if (col in row) result[col] = row[col];
1249
1335
  }
@@ -1251,6 +1337,7 @@ function projectColumns(row, columns) {
1251
1337
  }
1252
1338
 
1253
1339
  // src/table.ts
1340
+ var JSON_CONTENT_TYPE = "application/json";
1254
1341
  var NDJSON_CONTENT_TYPE = "application/x-ndjson";
1255
1342
  async function ndjsonSourceToString(source) {
1256
1343
  if (typeof source === "string") return source;
@@ -1302,8 +1389,10 @@ var TableRef = class {
1302
1389
  * rest of the batch — per-record outcomes come back in the result
1303
1390
  * (`failed` / `results`), and `ok` is true only when every record succeeded.
1304
1391
  *
1305
- * The array path sends one request regardless of size; bounded-concurrency
1306
- * chunking of very large arrays is tracked separately (#196). For NDJSON you
1392
+ * The array path sends one request regardless of size, so it is bound by the
1393
+ * server's 16 MiB request-body cap (an over-cap array is a `413` with nothing
1394
+ * ingested); bounded-concurrency chunking of very large arrays is tracked
1395
+ * separately (#196). For NDJSON you
1307
1396
  * already have (a file or stream), use {@link insertNDJSON}.
1308
1397
  */
1309
1398
  async insert(data, opts) {
@@ -1318,6 +1407,9 @@ var TableRef = class {
1318
1407
  method: "POST",
1319
1408
  path: `/v1/ingest?table=${encodeURIComponent(this._table)}`,
1320
1409
  body: data,
1410
+ // Ingest requires a declared format — an undeclared Content-Type is a
1411
+ // 415 — so state it here rather than leaning on the request default.
1412
+ contentType: JSON_CONTENT_TYPE,
1321
1413
  signal: opts?.signal
1322
1414
  });
1323
1415
  if (error) return err(error);
@@ -1331,9 +1423,11 @@ var TableRef = class {
1331
1423
  * file or a stream produced by another system. For in-memory rows, prefer
1332
1424
  * {@link insert}.
1333
1425
  *
1334
- * Non-string sources are read fully into memory before sending (the server
1335
- * streams the parse). Returns the same per-record batch summary as an array
1336
- * `insert`.
1426
+ * Non-string sources are read fully into memory before sending. The server
1427
+ * buffers the whole body too, so the request-body cap applies to NDJSON
1428
+ * exactly as to any other shape — an upload over it is a `413` with nothing
1429
+ * ingested, and must be split across several calls. Returns the same
1430
+ * per-record batch summary as an array `insert`.
1337
1431
  */
1338
1432
  async insertNDJSON(source, opts) {
1339
1433
  const ndjson = await ndjsonSourceToString(source);
@@ -1365,11 +1459,12 @@ var TableRef = class {
1365
1459
  if (r.results && r.results.length > 0) result.results = r.results;
1366
1460
  return ok(result);
1367
1461
  }
1368
- /** Fetch the schema for this table. */
1462
+ /** Fetch the schema for this table — under `opts.tenant`, the default tenant without it. */
1369
1463
  async schema(opts) {
1370
1464
  const { data, error } = await request(this._ctx, {
1371
1465
  method: "GET",
1372
- path: `/v1/ops/schema?table=${encodeURIComponent(this._table)}`,
1466
+ path: "/v1/ops/schema",
1467
+ params: { table: this._table, ...tenantParam(opts) },
1373
1468
  signal: opts?.signal
1374
1469
  });
1375
1470
  if (error) return err(error);
@@ -1387,14 +1482,14 @@ var WaveHouseClient = class {
1387
1482
  _ctx;
1388
1483
  /** Schema introspection namespace. */
1389
1484
  schema;
1390
- /** Access control policy namespace (admin). */
1391
- policy;
1392
1485
  /** Dead Letter Queue namespace. */
1393
1486
  dlq;
1394
1487
  /** System health/readiness namespace. */
1395
1488
  sys;
1396
1489
  /** Named query pipes admin namespace. */
1397
1490
  pipes;
1491
+ /** Settings directory namespace (admin). */
1492
+ settings;
1398
1493
  constructor(config) {
1399
1494
  this._ctx = {
1400
1495
  baseURL: config.baseURL.replace(/\/+$/, ""),
@@ -1407,10 +1502,10 @@ var WaveHouseClient = class {
1407
1502
  }
1408
1503
  };
1409
1504
  this.schema = new SchemaNamespace(this._ctx);
1410
- this.policy = new PolicyNamespace(this._ctx);
1411
1505
  this.dlq = new DLQNamespace(this._ctx, (table, opts) => this._createStream(table, opts));
1412
1506
  this.sys = new SysNamespace(this._ctx);
1413
1507
  this.pipes = new PipesNamespace(this._ctx);
1508
+ this.settings = new SettingsNamespace(this._ctx);
1414
1509
  }
1415
1510
  /** Get a table reference for building queries, inserts, and streams. */
1416
1511
  from(table) {
@@ -1430,7 +1525,9 @@ var WaveHouseClient = class {
1430
1525
  * `service` role). The endpoint proxies straight to ClickHouse's HTTP
1431
1526
  * interface so any ClickHouse-accepted SQL works; positional `?` param
1432
1527
  * binding is NOT supported — inline literals or use the structured query
1433
- * builder for safe binding. See sql.ts for details.
1528
+ * builder for safe binding. See sql.ts for details. `opts.tenant` names the
1529
+ * tenant whose ClickHouse the SQL runs against, the default tenant without
1530
+ * it.
1434
1531
  */
1435
1532
  sql(query, opts) {
1436
1533
  if (Array.isArray(opts)) {
@@ -1464,9 +1561,9 @@ export {
1464
1561
  LiveQuery,
1465
1562
  PipeRef,
1466
1563
  PipesNamespace,
1467
- PolicyNamespace,
1468
1564
  QueryBuilder,
1469
1565
  SchemaNamespace,
1566
+ SettingsNamespace,
1470
1567
  StreamController,
1471
1568
  SysNamespace,
1472
1569
  TableRef,