get-db9 0.5.0 → 0.6.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
@@ -259,6 +259,239 @@ function defaultCredentialStore() {
259
259
  return new FileCredentialStore();
260
260
  }
261
261
 
262
+ // src/ws.ts
263
+ var FsError = class extends Error {
264
+ code;
265
+ constructor(code, message) {
266
+ super(message);
267
+ this.name = "FsError";
268
+ this.code = code;
269
+ }
270
+ };
271
+ var nextId = 1;
272
+ function nextRequestId() {
273
+ return String(nextId++);
274
+ }
275
+ function toBase64(data) {
276
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
277
+ if (typeof Buffer !== "undefined") {
278
+ return Buffer.from(bytes).toString("base64");
279
+ }
280
+ let binary = "";
281
+ for (let i = 0; i < bytes.length; i++) {
282
+ binary += String.fromCharCode(bytes[i]);
283
+ }
284
+ return btoa(binary);
285
+ }
286
+ function fromBase64(b64) {
287
+ if (typeof Buffer !== "undefined") {
288
+ return new Uint8Array(Buffer.from(b64, "base64"));
289
+ }
290
+ const binary = atob(b64);
291
+ const bytes = new Uint8Array(binary.length);
292
+ for (let i = 0; i < binary.length; i++) {
293
+ bytes[i] = binary.charCodeAt(i);
294
+ }
295
+ return bytes;
296
+ }
297
+ var FsClient = class _FsClient {
298
+ ws;
299
+ pending = /* @__PURE__ */ new Map();
300
+ closed = false;
301
+ constructor(ws) {
302
+ this.ws = ws;
303
+ ws.onmessage = (ev) => {
304
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data);
305
+ let resp;
306
+ try {
307
+ resp = JSON.parse(text);
308
+ } catch {
309
+ return;
310
+ }
311
+ const p = this.pending.get(resp.id);
312
+ if (p) {
313
+ this.pending.delete(resp.id);
314
+ p.resolve(resp);
315
+ }
316
+ };
317
+ ws.onclose = () => {
318
+ this.closed = true;
319
+ for (const [, p] of this.pending) {
320
+ p.reject(new FsError("CONNECTION_CLOSED", "WebSocket connection closed"));
321
+ }
322
+ this.pending.clear();
323
+ };
324
+ ws.onerror = () => {
325
+ };
326
+ }
327
+ /**
328
+ * Connect to an fs9 WebSocket server.
329
+ *
330
+ * @param url WebSocket URL, e.g. `wss://host:5480`
331
+ * @param WS WebSocket constructor (native or from `ws` package)
332
+ */
333
+ static connect(url, WS) {
334
+ return new Promise((resolve, reject) => {
335
+ const ws = new WS(url);
336
+ ws.onopen = () => {
337
+ ws.onopen = null;
338
+ ws.onerror = null;
339
+ resolve(new _FsClient(ws));
340
+ };
341
+ ws.onerror = (ev) => {
342
+ ws.onopen = null;
343
+ ws.onerror = null;
344
+ const msg = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket connection failed";
345
+ reject(new FsError("CONNECTION_ERROR", msg));
346
+ };
347
+ });
348
+ }
349
+ /** Authenticate with the server. Must be called first after connect. */
350
+ async authenticate(username, password) {
351
+ const resp = await this.sendAndRecv({
352
+ id: nextRequestId(),
353
+ op: "auth",
354
+ username,
355
+ password
356
+ });
357
+ if (!resp.ok) {
358
+ throw new FsError("AUTH_FAILED", this.errorMessage(resp));
359
+ }
360
+ const data = resp.data;
361
+ return {
362
+ user: String(data?.user ?? ""),
363
+ tenant: String(data?.tenant ?? ""),
364
+ keyspace: String(data?.keyspace ?? "")
365
+ };
366
+ }
367
+ /** Get file or directory metadata. */
368
+ async stat(path) {
369
+ const resp = await this.sendAndRecv({
370
+ id: nextRequestId(),
371
+ op: "stat",
372
+ path
373
+ });
374
+ this.expectOk(resp);
375
+ return resp.data;
376
+ }
377
+ /** List directory contents. */
378
+ async readdir(path) {
379
+ const resp = await this.sendAndRecv({
380
+ id: nextRequestId(),
381
+ op: "readdir",
382
+ path
383
+ });
384
+ this.expectOk(resp);
385
+ const data = resp.data;
386
+ return data?.entries ?? [];
387
+ }
388
+ /** Create a directory. Always recursive (mkdir -p). */
389
+ async mkdir(path, recursive = true) {
390
+ const resp = await this.sendAndRecv({
391
+ id: nextRequestId(),
392
+ op: "mkdir",
393
+ path,
394
+ recursive
395
+ });
396
+ this.expectOk(resp);
397
+ }
398
+ /** Read an entire file, returning raw bytes. */
399
+ async readFile(path) {
400
+ const resp = await this.sendAndRecv({
401
+ id: nextRequestId(),
402
+ op: "read",
403
+ path
404
+ });
405
+ this.expectOk(resp);
406
+ const data = resp.data;
407
+ const content = data?.content;
408
+ if (typeof content !== "string") {
409
+ throw new FsError("PROTOCOL", "missing content field in read response");
410
+ }
411
+ return fromBase64(content);
412
+ }
413
+ /** Write (overwrite) a file. Returns bytes written. */
414
+ async writeFile(path, data) {
415
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
416
+ const resp = await this.sendAndRecv({
417
+ id: nextRequestId(),
418
+ op: "write",
419
+ path,
420
+ content: toBase64(bytes),
421
+ encoding: "base64"
422
+ });
423
+ this.expectOk(resp);
424
+ const result = resp.data;
425
+ return result?.written ?? bytes.byteLength;
426
+ }
427
+ /** Append to a file. Returns bytes written. */
428
+ async appendFile(path, data) {
429
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
430
+ const resp = await this.sendAndRecv({
431
+ id: nextRequestId(),
432
+ op: "append",
433
+ path,
434
+ content: toBase64(bytes),
435
+ encoding: "base64"
436
+ });
437
+ this.expectOk(resp);
438
+ const result = resp.data;
439
+ return result?.written ?? bytes.byteLength;
440
+ }
441
+ /** Remove a file (non-recursive) or directory (recursive). */
442
+ async rm(path, recursive = false) {
443
+ const resp = await this.sendAndRecv(
444
+ recursive ? { id: nextRequestId(), op: "rm", path, recursive: true } : { id: nextRequestId(), op: "unlink", path }
445
+ );
446
+ this.expectOk(resp);
447
+ }
448
+ /** Rename (move) a file or directory. */
449
+ async rename(oldPath, newPath) {
450
+ const resp = await this.sendAndRecv({
451
+ id: nextRequestId(),
452
+ op: "rename",
453
+ old_path: oldPath,
454
+ new_path: newPath
455
+ });
456
+ this.expectOk(resp);
457
+ }
458
+ /** Gracefully close the WebSocket connection. */
459
+ async close() {
460
+ if (!this.closed) {
461
+ this.ws.close();
462
+ }
463
+ }
464
+ // ── internals ──────────────────────────────────────────────────
465
+ sendAndRecv(request) {
466
+ if (this.closed) {
467
+ return Promise.reject(new FsError("CONNECTION_CLOSED", "WebSocket is closed"));
468
+ }
469
+ return new Promise((resolve, reject) => {
470
+ const id = request.id;
471
+ this.pending.set(id, { resolve, reject });
472
+ try {
473
+ this.ws.send(JSON.stringify(request));
474
+ } catch (err) {
475
+ this.pending.delete(id);
476
+ reject(new FsError("CONNECTION_ERROR", String(err)));
477
+ }
478
+ });
479
+ }
480
+ expectOk(resp) {
481
+ if (!resp.ok) {
482
+ const detail = resp.error;
483
+ throw new FsError(
484
+ detail?.code ?? "UNKNOWN",
485
+ detail?.message ?? "unknown error"
486
+ );
487
+ }
488
+ }
489
+ errorMessage(resp) {
490
+ const detail = resp.error;
491
+ return detail ? `${detail.code}: ${detail.message}` : "unknown error";
492
+ }
493
+ };
494
+
262
495
  // src/client.ts
263
496
  function createDb9Client(options = {}) {
264
497
  const baseUrl = options.baseUrl ?? "https://db9.shared.aws.tidbcloud.com/api";
@@ -338,44 +571,42 @@ function createDb9Client(options = {}) {
338
571
  return operation(newClient);
339
572
  }
340
573
  }
341
- function deriveFs9Url(dbId) {
342
- const origin = baseUrl.replace(/\/api\/?$/, "");
343
- return `${origin}/fs9/${dbId}`;
344
- }
345
- function getFsClient(dbId) {
346
- const fs9Base = deriveFs9Url(dbId) + "/api/v1";
347
- return createHttpClient({
348
- baseUrl: fs9Base,
349
- fetch: options.fetch,
350
- headers: token ? { Authorization: `Bearer ${token}` } : {},
351
- timeout: options.timeout,
352
- maxRetries: options.maxRetries,
353
- retryDelay: options.retryDelay
354
- });
574
+ const fsWsPort = options.wsPort ?? 5480;
575
+ async function resolveFsConn(dbId) {
576
+ const creds = await withAuthRetry(
577
+ (client) => client.get(
578
+ `/customer/databases/${dbId}/credentials`
579
+ )
580
+ );
581
+ const connStr = creds.connection_string;
582
+ const hostMatch = connStr.match(/@([^:/?]+)/);
583
+ const host = hostMatch?.[1];
584
+ if (!host) {
585
+ throw new Error(`Cannot parse host from connection string for database '${dbId}'`);
586
+ }
587
+ const userMatch = connStr.match(/:\/\/([^:@]+)/);
588
+ const username = userMatch?.[1] ?? creds.admin_user;
589
+ const protocol = host === "localhost" || host === "127.0.0.1" ? "ws" : "wss";
590
+ return {
591
+ wsUrl: `${protocol}://${host}:${fsWsPort}`,
592
+ username,
593
+ password: creds.admin_password
594
+ };
355
595
  }
356
- async function withFsAuthRetry(dbId, operation) {
357
- if (!token && !tokenLoaded) {
358
- await getAuthClient();
596
+ async function withFsClient(dbId, operation) {
597
+ const WS = options.WebSocket ?? (typeof globalThis !== "undefined" ? globalThis.WebSocket : void 0);
598
+ if (!WS) {
599
+ throw new Error(
600
+ "WebSocket constructor not available. Pass `WebSocket` in Db9ClientOptions, or use Node 21+ / a browser environment with native WebSocket support, or install the `ws` package for Node 18\u201320."
601
+ );
359
602
  }
360
- const client = getFsClient(dbId);
603
+ const conn = await resolveFsConn(dbId);
604
+ const client = await FsClient.connect(conn.wsUrl, WS);
361
605
  try {
606
+ await client.authenticate(conn.username, conn.password);
362
607
  return await operation(client);
363
- } catch (err) {
364
- if (!(err instanceof Db9Error) || err.statusCode !== 401) {
365
- throw err;
366
- }
367
- try {
368
- if (!refreshPromise) {
369
- refreshPromise = refreshAnonymousToken();
370
- }
371
- await refreshPromise;
372
- } catch {
373
- throw err;
374
- } finally {
375
- refreshPromise = null;
376
- }
377
- const newClient = getFsClient(dbId);
378
- return operation(newClient);
608
+ } finally {
609
+ await client.close();
379
610
  }
380
611
  }
381
612
  function parseSqlError(raw) {
@@ -396,12 +627,6 @@ function createDb9Client(options = {}) {
396
627
  }
397
628
  return { message: raw };
398
629
  }
399
- async function fsStat(dbId, path) {
400
- return withFsAuthRetry(
401
- dbId,
402
- (client) => client.get("/stat", { path })
403
- );
404
- }
405
630
  async function fetchAnonymousSecret() {
406
631
  return withAuthRetry(
407
632
  (client) => client.post("/customer/anonymous-secret", {})
@@ -475,6 +700,11 @@ function createDb9Client(options = {}) {
475
700
  `/customer/databases/${databaseId}/reset-password`
476
701
  )
477
702
  ),
703
+ credentials: async (databaseId) => withAuthRetry(
704
+ (client) => client.get(
705
+ `/customer/databases/${databaseId}/credentials`
706
+ )
707
+ ),
478
708
  observability: async (databaseId) => withAuthRetry(
479
709
  (client) => client.get(
480
710
  `/customer/databases/${databaseId}/observability`
@@ -557,70 +787,84 @@ function createDb9Client(options = {}) {
557
787
  }
558
788
  },
559
789
  fs: {
560
- list: async (dbId, path, options2) => {
561
- const params = { path };
562
- if (options2?.recursive) params.recursive = "true";
563
- return withFsAuthRetry(
564
- dbId,
565
- (client) => client.get("/readdir", params)
566
- );
567
- },
568
- read: async (dbId, path) => {
569
- return withFsAuthRetry(dbId, async (client) => {
570
- const resp = await client.getRaw("/download", { path });
571
- return resp.text();
572
- });
573
- },
574
- readBinary: async (dbId, path) => {
575
- return withFsAuthRetry(dbId, async (client) => {
576
- const resp = await client.getRaw("/download", { path });
577
- return resp.arrayBuffer();
578
- });
790
+ /**
791
+ * Open a persistent WebSocket connection for multiple fs operations.
792
+ * Caller is responsible for calling `client.close()` when done.
793
+ */
794
+ connect: async (dbId) => {
795
+ const WS = options.WebSocket ?? (typeof globalThis !== "undefined" ? globalThis.WebSocket : void 0);
796
+ if (!WS) {
797
+ throw new Error(
798
+ "WebSocket constructor not available. Pass `WebSocket` in Db9ClientOptions, or use Node 21+ / a browser environment with native WebSocket support, or install the `ws` package for Node 18\u201320."
799
+ );
800
+ }
801
+ const conn = await resolveFsConn(dbId);
802
+ const client = await FsClient.connect(conn.wsUrl, WS);
803
+ await client.authenticate(conn.username, conn.password);
804
+ return client;
579
805
  },
806
+ /** List directory contents. */
807
+ list: async (dbId, path) => withFsClient(dbId, (client) => client.readdir(path)),
808
+ /** Read a file as text (UTF-8). */
809
+ read: async (dbId, path) => withFsClient(dbId, async (client) => {
810
+ const bytes = await client.readFile(path);
811
+ return new TextDecoder().decode(bytes);
812
+ }),
813
+ /** Read a file as raw bytes. */
814
+ readBinary: async (dbId, path) => withFsClient(dbId, (client) => client.readFile(path)),
815
+ /** Write (overwrite) a file. Accepts string, ArrayBuffer, or Uint8Array. */
580
816
  write: async (dbId, path, content) => {
581
- const contentType = typeof content === "string" ? "text/plain" : "application/octet-stream";
582
- await withFsAuthRetry(
583
- dbId,
584
- (client) => client.putRaw(`/upload?${new URLSearchParams({ path })}`, content, { "Content-Type": contentType })
585
- );
586
- },
587
- stat: (dbId, path) => {
588
- return fsStat(dbId, path);
817
+ await withFsClient(dbId, (client) => client.writeFile(path, content));
589
818
  },
819
+ /** Append to a file. Returns bytes written. */
820
+ append: async (dbId, path, content) => withFsClient(dbId, (client) => client.appendFile(path, content)),
821
+ /** Get file or directory metadata. */
822
+ stat: async (dbId, path) => withFsClient(dbId, (client) => client.stat(path)),
823
+ /** Check if a file or directory exists. */
590
824
  exists: async (dbId, path) => {
591
825
  try {
592
- await fsStat(dbId, path);
826
+ await withFsClient(dbId, (client) => client.stat(path));
593
827
  return true;
594
828
  } catch (err) {
595
- if (err instanceof Db9Error && err.statusCode === 404) {
829
+ if (err instanceof FsError && err.code === "ENOENT") {
596
830
  return false;
597
831
  }
598
832
  throw err;
599
833
  }
600
834
  },
835
+ /** Create a directory (recursive by default). */
601
836
  mkdir: async (dbId, path) => {
602
- await withFsAuthRetry(
603
- dbId,
604
- (client) => client.postRaw(`/mkdir?${new URLSearchParams({ path, recursive: "true" })}`)
605
- );
837
+ await withFsClient(dbId, (client) => client.mkdir(path, true));
606
838
  },
607
- remove: async (dbId, path) => {
608
- await withFsAuthRetry(
609
- dbId,
610
- (client) => client.delRaw("/remove", { path })
611
- );
839
+ /** Remove a file or directory. */
840
+ remove: async (dbId, path, opts) => {
841
+ await withFsClient(dbId, (client) => client.rm(path, opts?.recursive ?? false));
612
842
  },
613
- events: async (dbId, options2) => {
614
- const params = {};
615
- if (options2?.limit !== void 0) params.limit = String(options2.limit);
616
- if (options2?.offset !== void 0) params.offset = String(options2.offset);
617
- if (options2?.path) params.path = options2.path;
618
- if (options2?.type) params.type = options2.type;
619
- return withFsAuthRetry(
620
- dbId,
621
- (client) => client.get("/events", params)
622
- );
843
+ /** Rename (move) a file or directory. */
844
+ rename: async (dbId, oldPath, newPath) => {
845
+ await withFsClient(dbId, (client) => client.rename(oldPath, newPath));
623
846
  }
847
+ },
848
+ // ── Device Auth Flow ─────────────────────────────────────────
849
+ deviceAuth: {
850
+ /** Start device code flow. Returns codes for user to authorize. */
851
+ createDeviceCode: () => publicClient.post("/customer/device-code"),
852
+ /** Poll for device token after user authorizes. Returns token or error status. */
853
+ pollDeviceToken: async (req) => {
854
+ try {
855
+ return await publicClient.post("/customer/device-token", req);
856
+ } catch (err) {
857
+ if (err instanceof Db9Error && err.statusCode === 400) {
858
+ const errorType = err.message;
859
+ if (errorType === "authorization_pending" || errorType === "expired_token" || errorType === "access_denied") {
860
+ return { error: errorType };
861
+ }
862
+ }
863
+ throw err;
864
+ }
865
+ },
866
+ /** Submit device verification with user credentials. */
867
+ verifyDevice: (req) => publicClient.post("/customer/device-verify", req)
624
868
  }
625
869
  };
626
870
  }
@@ -665,6 +909,8 @@ export {
665
909
  Db9Error,
666
910
  Db9NotFoundError,
667
911
  FileCredentialStore,
912
+ FsClient,
913
+ FsError,
668
914
  MemoryCredentialStore,
669
915
  createDb9Client,
670
916
  defaultCredentialStore,