get-db9 0.4.1 → 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/client.js CHANGED
@@ -48,6 +48,9 @@ var Db9ConflictError = class extends Db9Error {
48
48
  };
49
49
 
50
50
  // src/http.ts
51
+ function delay(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
51
54
  function createHttpClient(options) {
52
55
  const fetchFn = options.fetch ?? globalThis.fetch;
53
56
  const baseUrl = options.baseUrl.replace(/\/$/, "");
@@ -56,38 +59,61 @@ function createHttpClient(options) {
56
59
  if (params) {
57
60
  const searchParams = new URLSearchParams();
58
61
  for (const [key, value] of Object.entries(params)) {
59
- if (value !== void 0) {
60
- searchParams.set(key, value);
61
- }
62
+ if (value !== void 0) searchParams.set(key, value);
62
63
  }
63
64
  const qs = searchParams.toString();
64
65
  if (qs) url += `?${qs}`;
65
66
  }
66
- const headers = {
67
+ const reqHeaders = {
67
68
  "Content-Type": "application/json",
68
69
  ...options.headers
69
70
  };
70
- const init = { method, headers };
71
+ const init = { method, headers: reqHeaders };
71
72
  if (body !== void 0) {
72
73
  init.body = JSON.stringify(body);
73
74
  }
74
- const response = await fetchFn(url, init);
75
- if (!response.ok) {
76
- throw await Db9Error.fromResponse(response);
77
- }
78
- if (response.status === 204) {
79
- return void 0;
75
+ const maxAttempts = Math.min(options.maxRetries ?? 0, 3) + 1;
76
+ const baseDelay = options.retryDelay ?? 1e3;
77
+ let lastError;
78
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
79
+ let timeoutId;
80
+ try {
81
+ const fetchInit = { ...init };
82
+ if (options.timeout) {
83
+ const controller = new AbortController();
84
+ fetchInit.signal = controller.signal;
85
+ timeoutId = setTimeout(() => controller.abort(), options.timeout);
86
+ }
87
+ const response = await fetchFn(url, fetchInit);
88
+ if (timeoutId) clearTimeout(timeoutId);
89
+ if (!response.ok) {
90
+ if (response.status >= 500 && attempt < maxAttempts - 1) {
91
+ lastError = await Db9Error.fromResponse(response);
92
+ await delay(baseDelay * Math.pow(2, attempt));
93
+ continue;
94
+ }
95
+ throw await Db9Error.fromResponse(response);
96
+ }
97
+ if (response.status === 204) return void 0;
98
+ return response.json();
99
+ } catch (err) {
100
+ if (timeoutId) clearTimeout(timeoutId);
101
+ if (err instanceof TypeError && attempt < maxAttempts - 1) {
102
+ lastError = err;
103
+ await delay(baseDelay * Math.pow(2, attempt));
104
+ continue;
105
+ }
106
+ throw err;
107
+ }
80
108
  }
81
- return response.json();
109
+ throw lastError;
82
110
  }
83
111
  async function requestRaw(method, path, body, params, customHeaders) {
84
112
  let url = `${baseUrl}${path}`;
85
113
  if (params) {
86
114
  const searchParams = new URLSearchParams();
87
115
  for (const [key, value] of Object.entries(params)) {
88
- if (value !== void 0) {
89
- searchParams.set(key, value);
90
- }
116
+ if (value !== void 0) searchParams.set(key, value);
91
117
  }
92
118
  const qs = searchParams.toString();
93
119
  if (qs) url += `?${qs}`;
@@ -96,15 +122,23 @@ function createHttpClient(options) {
96
122
  ...options.headers,
97
123
  ...customHeaders
98
124
  };
99
- const init = { method, headers };
100
- if (body !== void 0) {
101
- init.body = body;
125
+ const fetchInit = { method, headers };
126
+ if (body !== void 0) fetchInit.body = body;
127
+ let timeoutId;
128
+ if (options.timeout) {
129
+ const controller = new AbortController();
130
+ fetchInit.signal = controller.signal;
131
+ timeoutId = setTimeout(() => controller.abort(), options.timeout);
102
132
  }
103
- const response = await fetchFn(url, init);
104
- if (!response.ok) {
105
- throw await Db9Error.fromResponse(response);
133
+ try {
134
+ const response = await fetchFn(url, fetchInit);
135
+ if (timeoutId) clearTimeout(timeoutId);
136
+ if (!response.ok) throw await Db9Error.fromResponse(response);
137
+ return response;
138
+ } catch (err) {
139
+ if (timeoutId) clearTimeout(timeoutId);
140
+ throw err;
106
141
  }
107
- return response;
108
142
  }
109
143
  return {
110
144
  get: (path, params) => request("GET", path, void 0, params),
@@ -112,7 +146,9 @@ function createHttpClient(options) {
112
146
  put: (path, body) => request("PUT", path, body),
113
147
  del: (path) => request("DELETE", path),
114
148
  getRaw: (path, params) => requestRaw("GET", path, void 0, params),
115
- putRaw: (path, body, headers) => requestRaw("PUT", path, body, void 0, headers)
149
+ putRaw: (path, body, headers) => requestRaw("PUT", path, body, void 0, headers),
150
+ postRaw: (path, body, headers) => requestRaw("POST", path, body, void 0, headers),
151
+ delRaw: (path, params) => requestRaw("DELETE", path, void 0, params)
116
152
  };
117
153
  }
118
154
 
@@ -206,6 +242,239 @@ function defaultCredentialStore() {
206
242
  return new FileCredentialStore();
207
243
  }
208
244
 
245
+ // src/ws.ts
246
+ var FsError = class extends Error {
247
+ code;
248
+ constructor(code, message) {
249
+ super(message);
250
+ this.name = "FsError";
251
+ this.code = code;
252
+ }
253
+ };
254
+ var nextId = 1;
255
+ function nextRequestId() {
256
+ return String(nextId++);
257
+ }
258
+ function toBase64(data) {
259
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
260
+ if (typeof Buffer !== "undefined") {
261
+ return Buffer.from(bytes).toString("base64");
262
+ }
263
+ let binary = "";
264
+ for (let i = 0; i < bytes.length; i++) {
265
+ binary += String.fromCharCode(bytes[i]);
266
+ }
267
+ return btoa(binary);
268
+ }
269
+ function fromBase64(b64) {
270
+ if (typeof Buffer !== "undefined") {
271
+ return new Uint8Array(Buffer.from(b64, "base64"));
272
+ }
273
+ const binary = atob(b64);
274
+ const bytes = new Uint8Array(binary.length);
275
+ for (let i = 0; i < binary.length; i++) {
276
+ bytes[i] = binary.charCodeAt(i);
277
+ }
278
+ return bytes;
279
+ }
280
+ var FsClient = class _FsClient {
281
+ ws;
282
+ pending = /* @__PURE__ */ new Map();
283
+ closed = false;
284
+ constructor(ws) {
285
+ this.ws = ws;
286
+ ws.onmessage = (ev) => {
287
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data);
288
+ let resp;
289
+ try {
290
+ resp = JSON.parse(text);
291
+ } catch {
292
+ return;
293
+ }
294
+ const p = this.pending.get(resp.id);
295
+ if (p) {
296
+ this.pending.delete(resp.id);
297
+ p.resolve(resp);
298
+ }
299
+ };
300
+ ws.onclose = () => {
301
+ this.closed = true;
302
+ for (const [, p] of this.pending) {
303
+ p.reject(new FsError("CONNECTION_CLOSED", "WebSocket connection closed"));
304
+ }
305
+ this.pending.clear();
306
+ };
307
+ ws.onerror = () => {
308
+ };
309
+ }
310
+ /**
311
+ * Connect to an fs9 WebSocket server.
312
+ *
313
+ * @param url WebSocket URL, e.g. `wss://host:5480`
314
+ * @param WS WebSocket constructor (native or from `ws` package)
315
+ */
316
+ static connect(url, WS) {
317
+ return new Promise((resolve, reject) => {
318
+ const ws = new WS(url);
319
+ ws.onopen = () => {
320
+ ws.onopen = null;
321
+ ws.onerror = null;
322
+ resolve(new _FsClient(ws));
323
+ };
324
+ ws.onerror = (ev) => {
325
+ ws.onopen = null;
326
+ ws.onerror = null;
327
+ const msg = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket connection failed";
328
+ reject(new FsError("CONNECTION_ERROR", msg));
329
+ };
330
+ });
331
+ }
332
+ /** Authenticate with the server. Must be called first after connect. */
333
+ async authenticate(username, password) {
334
+ const resp = await this.sendAndRecv({
335
+ id: nextRequestId(),
336
+ op: "auth",
337
+ username,
338
+ password
339
+ });
340
+ if (!resp.ok) {
341
+ throw new FsError("AUTH_FAILED", this.errorMessage(resp));
342
+ }
343
+ const data = resp.data;
344
+ return {
345
+ user: String(data?.user ?? ""),
346
+ tenant: String(data?.tenant ?? ""),
347
+ keyspace: String(data?.keyspace ?? "")
348
+ };
349
+ }
350
+ /** Get file or directory metadata. */
351
+ async stat(path) {
352
+ const resp = await this.sendAndRecv({
353
+ id: nextRequestId(),
354
+ op: "stat",
355
+ path
356
+ });
357
+ this.expectOk(resp);
358
+ return resp.data;
359
+ }
360
+ /** List directory contents. */
361
+ async readdir(path) {
362
+ const resp = await this.sendAndRecv({
363
+ id: nextRequestId(),
364
+ op: "readdir",
365
+ path
366
+ });
367
+ this.expectOk(resp);
368
+ const data = resp.data;
369
+ return data?.entries ?? [];
370
+ }
371
+ /** Create a directory. Always recursive (mkdir -p). */
372
+ async mkdir(path, recursive = true) {
373
+ const resp = await this.sendAndRecv({
374
+ id: nextRequestId(),
375
+ op: "mkdir",
376
+ path,
377
+ recursive
378
+ });
379
+ this.expectOk(resp);
380
+ }
381
+ /** Read an entire file, returning raw bytes. */
382
+ async readFile(path) {
383
+ const resp = await this.sendAndRecv({
384
+ id: nextRequestId(),
385
+ op: "read",
386
+ path
387
+ });
388
+ this.expectOk(resp);
389
+ const data = resp.data;
390
+ const content = data?.content;
391
+ if (typeof content !== "string") {
392
+ throw new FsError("PROTOCOL", "missing content field in read response");
393
+ }
394
+ return fromBase64(content);
395
+ }
396
+ /** Write (overwrite) a file. Returns bytes written. */
397
+ async writeFile(path, data) {
398
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
399
+ const resp = await this.sendAndRecv({
400
+ id: nextRequestId(),
401
+ op: "write",
402
+ path,
403
+ content: toBase64(bytes),
404
+ encoding: "base64"
405
+ });
406
+ this.expectOk(resp);
407
+ const result = resp.data;
408
+ return result?.written ?? bytes.byteLength;
409
+ }
410
+ /** Append to a file. Returns bytes written. */
411
+ async appendFile(path, data) {
412
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data);
413
+ const resp = await this.sendAndRecv({
414
+ id: nextRequestId(),
415
+ op: "append",
416
+ path,
417
+ content: toBase64(bytes),
418
+ encoding: "base64"
419
+ });
420
+ this.expectOk(resp);
421
+ const result = resp.data;
422
+ return result?.written ?? bytes.byteLength;
423
+ }
424
+ /** Remove a file (non-recursive) or directory (recursive). */
425
+ async rm(path, recursive = false) {
426
+ const resp = await this.sendAndRecv(
427
+ recursive ? { id: nextRequestId(), op: "rm", path, recursive: true } : { id: nextRequestId(), op: "unlink", path }
428
+ );
429
+ this.expectOk(resp);
430
+ }
431
+ /** Rename (move) a file or directory. */
432
+ async rename(oldPath, newPath) {
433
+ const resp = await this.sendAndRecv({
434
+ id: nextRequestId(),
435
+ op: "rename",
436
+ old_path: oldPath,
437
+ new_path: newPath
438
+ });
439
+ this.expectOk(resp);
440
+ }
441
+ /** Gracefully close the WebSocket connection. */
442
+ async close() {
443
+ if (!this.closed) {
444
+ this.ws.close();
445
+ }
446
+ }
447
+ // ── internals ──────────────────────────────────────────────────
448
+ sendAndRecv(request) {
449
+ if (this.closed) {
450
+ return Promise.reject(new FsError("CONNECTION_CLOSED", "WebSocket is closed"));
451
+ }
452
+ return new Promise((resolve, reject) => {
453
+ const id = request.id;
454
+ this.pending.set(id, { resolve, reject });
455
+ try {
456
+ this.ws.send(JSON.stringify(request));
457
+ } catch (err) {
458
+ this.pending.delete(id);
459
+ reject(new FsError("CONNECTION_ERROR", String(err)));
460
+ }
461
+ });
462
+ }
463
+ expectOk(resp) {
464
+ if (!resp.ok) {
465
+ const detail = resp.error;
466
+ throw new FsError(
467
+ detail?.code ?? "UNKNOWN",
468
+ detail?.message ?? "unknown error"
469
+ );
470
+ }
471
+ }
472
+ errorMessage(resp) {
473
+ const detail = resp.error;
474
+ return detail ? `${detail.code}: ${detail.message}` : "unknown error";
475
+ }
476
+ };
477
+
209
478
  // src/client.ts
210
479
  function createDb9Client(options = {}) {
211
480
  const baseUrl = options.baseUrl ?? "https://db9.shared.aws.tidbcloud.com/api";
@@ -215,7 +484,10 @@ function createDb9Client(options = {}) {
215
484
  const fetchFn = options.fetch ?? globalThis.fetch;
216
485
  const publicClient = createHttpClient({
217
486
  baseUrl,
218
- fetch: options.fetch
487
+ fetch: options.fetch,
488
+ timeout: options.timeout,
489
+ maxRetries: options.maxRetries,
490
+ retryDelay: options.retryDelay
219
491
  });
220
492
  async function getAuthClient() {
221
493
  if (!token && !tokenLoaded) {
@@ -238,35 +510,110 @@ function createDb9Client(options = {}) {
238
510
  return createHttpClient({
239
511
  baseUrl,
240
512
  fetch: options.fetch,
241
- headers: { Authorization: `Bearer ${token}` }
513
+ headers: { Authorization: `Bearer ${token}` },
514
+ timeout: options.timeout,
515
+ maxRetries: options.maxRetries,
516
+ retryDelay: options.retryDelay
242
517
  });
243
518
  }
244
- function deriveFs9Url(dbId) {
245
- const origin = baseUrl.replace(/\/api\/?$/, "");
246
- return `${origin}/fs9/${dbId}`;
519
+ let refreshPromise = null;
520
+ async function refreshAnonymousToken() {
521
+ const creds = await store.load();
522
+ if (!creds?.anonymous_id || !creds?.anonymous_secret) {
523
+ throw new Error("Not an anonymous session");
524
+ }
525
+ const resp = await publicClient.post(
526
+ "/customer/anonymous-refresh",
527
+ {
528
+ anonymous_id: creds.anonymous_id,
529
+ anonymous_secret: creds.anonymous_secret
530
+ }
531
+ );
532
+ token = resp.token;
533
+ await store.save({ ...creds, token: resp.token });
247
534
  }
248
- async function fsRequest(method, dbId, fsPath, body, contentType) {
249
- if (!token && !tokenLoaded) {
250
- await getAuthClient();
535
+ async function withAuthRetry(operation) {
536
+ const client = await getAuthClient();
537
+ try {
538
+ return await operation(client);
539
+ } catch (err) {
540
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
541
+ throw err;
542
+ }
543
+ try {
544
+ if (!refreshPromise) {
545
+ refreshPromise = refreshAnonymousToken();
546
+ }
547
+ await refreshPromise;
548
+ } catch {
549
+ throw err;
550
+ } finally {
551
+ refreshPromise = null;
552
+ }
553
+ const newClient = await getAuthClient();
554
+ return operation(newClient);
251
555
  }
252
- const fs9Url = deriveFs9Url(dbId);
253
- const url = `${fs9Url}/api/v1${fsPath}`;
254
- const headers = {};
255
- if (token) {
256
- headers["Authorization"] = `Bearer ${token}`;
556
+ }
557
+ const fsWsPort = options.wsPort ?? 5480;
558
+ async function resolveFsConn(dbId) {
559
+ const creds = await withAuthRetry(
560
+ (client) => client.get(
561
+ `/customer/databases/${dbId}/credentials`
562
+ )
563
+ );
564
+ const connStr = creds.connection_string;
565
+ const hostMatch = connStr.match(/@([^:/?]+)/);
566
+ const host = hostMatch?.[1];
567
+ if (!host) {
568
+ throw new Error(`Cannot parse host from connection string for database '${dbId}'`);
257
569
  }
258
- if (body !== void 0) {
259
- headers["Content-Type"] = contentType || "text/plain";
570
+ const userMatch = connStr.match(/:\/\/([^:@]+)/);
571
+ const username = userMatch?.[1] ?? creds.admin_user;
572
+ const protocol = host === "localhost" || host === "127.0.0.1" ? "ws" : "wss";
573
+ return {
574
+ wsUrl: `${protocol}://${host}:${fsWsPort}`,
575
+ username,
576
+ password: creds.admin_password
577
+ };
578
+ }
579
+ async function withFsClient(dbId, operation) {
580
+ const WS = options.WebSocket ?? (typeof globalThis !== "undefined" ? globalThis.WebSocket : void 0);
581
+ if (!WS) {
582
+ throw new Error(
583
+ "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."
584
+ );
260
585
  }
261
- const init = { method, headers };
262
- if (body !== void 0) {
263
- init.body = body;
586
+ const conn = await resolveFsConn(dbId);
587
+ const client = await FsClient.connect(conn.wsUrl, WS);
588
+ try {
589
+ await client.authenticate(conn.username, conn.password);
590
+ return await operation(client);
591
+ } finally {
592
+ await client.close();
593
+ }
594
+ }
595
+ function parseSqlError(raw) {
596
+ try {
597
+ const parsed = JSON.parse(raw);
598
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.message === "string") {
599
+ return parsed;
600
+ }
601
+ } catch {
264
602
  }
265
- const response = await fetchFn(url, init);
266
- if (!response.ok) {
267
- throw await Db9Error.fromResponse(response);
603
+ const pgMatch = raw.match(/^(?:ERROR:\s*)?(.+?)(?:\s+DETAIL:\s+(.+?))?(?:\s+HINT:\s+(.+?))?(?:\s+\(SQLSTATE\s+(\w+)\))?$/s);
604
+ if (pgMatch && pgMatch[1]) {
605
+ const result = { message: pgMatch[1].trim() };
606
+ if (pgMatch[2]) result.detail = pgMatch[2].trim();
607
+ if (pgMatch[3]) result.hint = pgMatch[3].trim();
608
+ if (pgMatch[4]) result.code = pgMatch[4];
609
+ return result;
268
610
  }
269
- return response;
611
+ return { message: raw };
612
+ }
613
+ async function fetchAnonymousSecret() {
614
+ return withAuthRetry(
615
+ (client) => client.post("/customer/anonymous-secret", {})
616
+ );
270
617
  }
271
618
  return {
272
619
  auth: {
@@ -281,196 +628,226 @@ function createDb9Client(options = {}) {
281
628
  req
282
629
  ),
283
630
  // Authenticated endpoints
284
- me: async () => {
285
- const client = await getAuthClient();
286
- return client.get("/customer/me");
287
- },
288
- getAnonymousSecret: async () => {
289
- const client = await getAuthClient();
290
- return client.get(
291
- "/customer/anonymous-secret"
292
- );
631
+ me: async () => withAuthRetry(
632
+ (client) => client.get("/customer/me")
633
+ ),
634
+ getAnonymousSecret: () => {
635
+ return fetchAnonymousSecret();
293
636
  },
294
- claim: async (req) => {
295
- const client = await getAuthClient();
296
- return client.post("/customer/claim", req);
637
+ claim: async (req) => withAuthRetry(
638
+ (client) => client.post("/customer/claim", req)
639
+ ),
640
+ ensureAnonymousSecret: async () => {
641
+ const creds = await store.load();
642
+ if (!creds?.anonymous_id || creds.anonymous_secret) {
643
+ return;
644
+ }
645
+ const resp = await fetchAnonymousSecret();
646
+ await store.save({
647
+ ...creds,
648
+ anonymous_secret: resp.anonymous_secret
649
+ });
297
650
  }
298
651
  },
299
652
  tokens: {
300
- list: async () => {
301
- const client = await getAuthClient();
302
- return client.get("/customer/tokens");
303
- },
304
- revoke: async (tokenId) => {
305
- const client = await getAuthClient();
306
- return client.del(`/customer/tokens/${tokenId}`);
307
- }
653
+ list: async () => withAuthRetry(
654
+ (client) => client.get("/customer/tokens")
655
+ ),
656
+ revoke: async (tokenId) => withAuthRetry(
657
+ (client) => client.del(`/customer/tokens/${tokenId}`)
658
+ ),
659
+ create: async (req) => withAuthRetry(
660
+ (client) => client.post("/customer/tokens", req)
661
+ )
308
662
  },
309
663
  databases: {
310
664
  // ── CRUD ──────────────────────────────────────────────────
311
- create: async (req) => {
312
- const client = await getAuthClient();
313
- return client.post("/customer/databases", req);
314
- },
315
- list: async () => {
316
- const client = await getAuthClient();
317
- return client.get("/customer/databases");
318
- },
319
- get: async (databaseId) => {
320
- const client = await getAuthClient();
321
- return client.get(
665
+ create: async (req) => withAuthRetry(
666
+ (client) => client.post("/customer/databases", req)
667
+ ),
668
+ list: async () => withAuthRetry(
669
+ (client) => client.get("/customer/databases")
670
+ ),
671
+ get: async (databaseId) => withAuthRetry(
672
+ (client) => client.get(
322
673
  `/customer/databases/${databaseId}`
323
- );
324
- },
325
- delete: async (databaseId) => {
326
- const client = await getAuthClient();
327
- return client.del(
674
+ )
675
+ ),
676
+ delete: async (databaseId) => withAuthRetry(
677
+ (client) => client.del(
328
678
  `/customer/databases/${databaseId}`
329
- );
330
- },
331
- resetPassword: async (databaseId) => {
332
- const client = await getAuthClient();
333
- return client.post(
679
+ )
680
+ ),
681
+ resetPassword: async (databaseId) => withAuthRetry(
682
+ (client) => client.post(
334
683
  `/customer/databases/${databaseId}/reset-password`
335
- );
336
- },
337
- observability: async (databaseId) => {
338
- const client = await getAuthClient();
339
- return client.get(
684
+ )
685
+ ),
686
+ credentials: async (databaseId) => withAuthRetry(
687
+ (client) => client.get(
688
+ `/customer/databases/${databaseId}/credentials`
689
+ )
690
+ ),
691
+ observability: async (databaseId) => withAuthRetry(
692
+ (client) => client.get(
340
693
  `/customer/databases/${databaseId}/observability`
341
- );
342
- },
694
+ )
695
+ ),
343
696
  // ── SQL Execution ─────────────────────────────────────────
344
697
  sql: async (databaseId, query) => {
345
- const client = await getAuthClient();
346
- return client.post(
347
- `/customer/databases/${databaseId}/sql`,
348
- { query }
698
+ const result = await withAuthRetry(
699
+ (client) => client.post(
700
+ `/customer/databases/${databaseId}/sql`,
701
+ { query }
702
+ )
349
703
  );
704
+ if (result.error && typeof result.error === "string") {
705
+ result.error = parseSqlError(result.error);
706
+ }
707
+ return result;
350
708
  },
351
709
  sqlFile: async (databaseId, fileContent) => {
352
- const client = await getAuthClient();
353
- return client.post(
354
- `/customer/databases/${databaseId}/sql`,
355
- { file_content: fileContent }
710
+ const result = await withAuthRetry(
711
+ (client) => client.post(
712
+ `/customer/databases/${databaseId}/sql`,
713
+ { file_content: fileContent }
714
+ )
356
715
  );
716
+ if (result.error && typeof result.error === "string") {
717
+ result.error = parseSqlError(result.error);
718
+ }
719
+ return result;
357
720
  },
358
721
  // ── Schema & Dump ─────────────────────────────────────────
359
- schema: async (databaseId) => {
360
- const client = await getAuthClient();
361
- return client.get(
722
+ schema: async (databaseId) => withAuthRetry(
723
+ (client) => client.get(
362
724
  `/customer/databases/${databaseId}/schema`
363
- );
364
- },
365
- dump: async (databaseId, req) => {
366
- const client = await getAuthClient();
367
- return client.post(
725
+ )
726
+ ),
727
+ dump: async (databaseId, req) => withAuthRetry(
728
+ (client) => client.post(
368
729
  `/customer/databases/${databaseId}/dump`,
369
730
  req
370
- );
371
- },
731
+ )
732
+ ),
372
733
  // ── Migrations ────────────────────────────────────────────
373
- applyMigration: async (databaseId, req) => {
374
- const client = await getAuthClient();
375
- return client.post(
734
+ applyMigration: async (databaseId, req) => withAuthRetry(
735
+ (client) => client.post(
376
736
  `/customer/databases/${databaseId}/migrations`,
377
737
  req
378
- );
379
- },
380
- listMigrations: async (databaseId) => {
381
- const client = await getAuthClient();
382
- return client.get(
738
+ )
739
+ ),
740
+ listMigrations: async (databaseId) => withAuthRetry(
741
+ (client) => client.get(
383
742
  `/customer/databases/${databaseId}/migrations`
384
- );
385
- },
743
+ )
744
+ ),
386
745
  // ── Branching ─────────────────────────────────────────────
387
- branch: async (databaseId, req) => {
388
- const client = await getAuthClient();
389
- return client.post(
746
+ branch: async (databaseId, req) => withAuthRetry(
747
+ (client) => client.post(
390
748
  `/customer/databases/${databaseId}/branch`,
391
749
  req
392
- );
393
- },
750
+ )
751
+ ),
394
752
  // ── User Management ───────────────────────────────────────
395
753
  users: {
396
- list: async (databaseId) => {
397
- const client = await getAuthClient();
398
- return client.get(
754
+ list: async (databaseId) => withAuthRetry(
755
+ (client) => client.get(
399
756
  `/customer/databases/${databaseId}/users`
400
- );
401
- },
402
- create: async (databaseId, req) => {
403
- const client = await getAuthClient();
404
- return client.post(
757
+ )
758
+ ),
759
+ create: async (databaseId, req) => withAuthRetry(
760
+ (client) => client.post(
405
761
  `/customer/databases/${databaseId}/users`,
406
762
  req
407
- );
408
- },
409
- delete: async (databaseId, username) => {
410
- const client = await getAuthClient();
411
- return client.del(
763
+ )
764
+ ),
765
+ delete: async (databaseId, username) => withAuthRetry(
766
+ (client) => client.del(
412
767
  `/customer/databases/${databaseId}/users/${username}`
413
- );
414
- }
768
+ )
769
+ )
415
770
  }
416
771
  },
417
772
  fs: {
418
- list: async (dbId, path, options2) => {
419
- const params = new URLSearchParams({ path });
420
- if (options2?.recursive) params.set("recursive", "true");
421
- const response = await fsRequest(
422
- "GET",
423
- dbId,
424
- `/readdir?${params.toString()}`
425
- );
426
- return response.json();
427
- },
428
- read: async (dbId, path) => {
429
- const params = new URLSearchParams({ path });
430
- const response = await fsRequest(
431
- "GET",
432
- dbId,
433
- `/download?${params.toString()}`
434
- );
435
- return response.text();
773
+ /**
774
+ * Open a persistent WebSocket connection for multiple fs operations.
775
+ * Caller is responsible for calling `client.close()` when done.
776
+ */
777
+ connect: async (dbId) => {
778
+ const WS = options.WebSocket ?? (typeof globalThis !== "undefined" ? globalThis.WebSocket : void 0);
779
+ if (!WS) {
780
+ throw new Error(
781
+ "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."
782
+ );
783
+ }
784
+ const conn = await resolveFsConn(dbId);
785
+ const client = await FsClient.connect(conn.wsUrl, WS);
786
+ await client.authenticate(conn.username, conn.password);
787
+ return client;
436
788
  },
789
+ /** List directory contents. */
790
+ list: async (dbId, path) => withFsClient(dbId, (client) => client.readdir(path)),
791
+ /** Read a file as text (UTF-8). */
792
+ read: async (dbId, path) => withFsClient(dbId, async (client) => {
793
+ const bytes = await client.readFile(path);
794
+ return new TextDecoder().decode(bytes);
795
+ }),
796
+ /** Read a file as raw bytes. */
797
+ readBinary: async (dbId, path) => withFsClient(dbId, (client) => client.readFile(path)),
798
+ /** Write (overwrite) a file. Accepts string, ArrayBuffer, or Uint8Array. */
437
799
  write: async (dbId, path, content) => {
438
- const params = new URLSearchParams({ path });
439
- await fsRequest("PUT", dbId, `/upload?${params.toString()}`, content);
800
+ await withFsClient(dbId, (client) => client.writeFile(path, content));
440
801
  },
441
- stat: async (dbId, path) => {
442
- const params = new URLSearchParams({ path });
443
- const response = await fsRequest(
444
- "GET",
445
- dbId,
446
- `/stat?${params.toString()}`
447
- );
448
- return response.json();
802
+ /** Append to a file. Returns bytes written. */
803
+ append: async (dbId, path, content) => withFsClient(dbId, (client) => client.appendFile(path, content)),
804
+ /** Get file or directory metadata. */
805
+ stat: async (dbId, path) => withFsClient(dbId, (client) => client.stat(path)),
806
+ /** Check if a file or directory exists. */
807
+ exists: async (dbId, path) => {
808
+ try {
809
+ await withFsClient(dbId, (client) => client.stat(path));
810
+ return true;
811
+ } catch (err) {
812
+ if (err instanceof FsError && err.code === "ENOENT") {
813
+ return false;
814
+ }
815
+ throw err;
816
+ }
449
817
  },
818
+ /** Create a directory (recursive by default). */
450
819
  mkdir: async (dbId, path) => {
451
- const openResp = await fsRequest(
452
- "POST",
453
- dbId,
454
- "/open",
455
- JSON.stringify({
456
- path,
457
- flags: { create: true, directory: true }
458
- }),
459
- "application/json"
460
- );
461
- const { handle_id } = await openResp.json();
462
- await fsRequest(
463
- "POST",
464
- dbId,
465
- "/close",
466
- JSON.stringify({ handle_id }),
467
- "application/json"
468
- );
820
+ await withFsClient(dbId, (client) => client.mkdir(path, true));
469
821
  },
470
- remove: async (dbId, path) => {
471
- const params = new URLSearchParams({ path });
472
- await fsRequest("DELETE", dbId, `/remove?${params.toString()}`);
822
+ /** Remove a file or directory. */
823
+ remove: async (dbId, path, opts) => {
824
+ await withFsClient(dbId, (client) => client.rm(path, opts?.recursive ?? false));
825
+ },
826
+ /** Rename (move) a file or directory. */
827
+ rename: async (dbId, oldPath, newPath) => {
828
+ await withFsClient(dbId, (client) => client.rename(oldPath, newPath));
473
829
  }
830
+ },
831
+ // ── Device Auth Flow ─────────────────────────────────────────
832
+ deviceAuth: {
833
+ /** Start device code flow. Returns codes for user to authorize. */
834
+ createDeviceCode: () => publicClient.post("/customer/device-code"),
835
+ /** Poll for device token after user authorizes. Returns token or error status. */
836
+ pollDeviceToken: async (req) => {
837
+ try {
838
+ return await publicClient.post("/customer/device-token", req);
839
+ } catch (err) {
840
+ if (err instanceof Db9Error && err.statusCode === 400) {
841
+ const errorType = err.message;
842
+ if (errorType === "authorization_pending" || errorType === "expired_token" || errorType === "access_denied") {
843
+ return { error: errorType };
844
+ }
845
+ }
846
+ throw err;
847
+ }
848
+ },
849
+ /** Submit device verification with user credentials. */
850
+ verifyDevice: (req) => publicClient.post("/customer/device-verify", req)
474
851
  }
475
852
  };
476
853
  }