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