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/index.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
 
@@ -223,6 +259,239 @@ function defaultCredentialStore() {
223
259
  return new FileCredentialStore();
224
260
  }
225
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
+
226
495
  // src/client.ts
227
496
  function createDb9Client(options = {}) {
228
497
  const baseUrl = options.baseUrl ?? "https://db9.shared.aws.tidbcloud.com/api";
@@ -232,7 +501,10 @@ function createDb9Client(options = {}) {
232
501
  const fetchFn = options.fetch ?? globalThis.fetch;
233
502
  const publicClient = createHttpClient({
234
503
  baseUrl,
235
- fetch: options.fetch
504
+ fetch: options.fetch,
505
+ timeout: options.timeout,
506
+ maxRetries: options.maxRetries,
507
+ retryDelay: options.retryDelay
236
508
  });
237
509
  async function getAuthClient() {
238
510
  if (!token && !tokenLoaded) {
@@ -255,35 +527,110 @@ function createDb9Client(options = {}) {
255
527
  return createHttpClient({
256
528
  baseUrl,
257
529
  fetch: options.fetch,
258
- headers: { Authorization: `Bearer ${token}` }
530
+ headers: { Authorization: `Bearer ${token}` },
531
+ timeout: options.timeout,
532
+ maxRetries: options.maxRetries,
533
+ retryDelay: options.retryDelay
259
534
  });
260
535
  }
261
- function deriveFs9Url(dbId) {
262
- const origin = baseUrl.replace(/\/api\/?$/, "");
263
- return `${origin}/fs9/${dbId}`;
536
+ let refreshPromise = null;
537
+ async function refreshAnonymousToken() {
538
+ const creds = await store.load();
539
+ if (!creds?.anonymous_id || !creds?.anonymous_secret) {
540
+ throw new Error("Not an anonymous session");
541
+ }
542
+ const resp = await publicClient.post(
543
+ "/customer/anonymous-refresh",
544
+ {
545
+ anonymous_id: creds.anonymous_id,
546
+ anonymous_secret: creds.anonymous_secret
547
+ }
548
+ );
549
+ token = resp.token;
550
+ await store.save({ ...creds, token: resp.token });
264
551
  }
265
- async function fsRequest(method, dbId, fsPath, body, contentType) {
266
- if (!token && !tokenLoaded) {
267
- await getAuthClient();
552
+ async function withAuthRetry(operation) {
553
+ const client = await getAuthClient();
554
+ try {
555
+ return await operation(client);
556
+ } catch (err) {
557
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
558
+ throw err;
559
+ }
560
+ try {
561
+ if (!refreshPromise) {
562
+ refreshPromise = refreshAnonymousToken();
563
+ }
564
+ await refreshPromise;
565
+ } catch {
566
+ throw err;
567
+ } finally {
568
+ refreshPromise = null;
569
+ }
570
+ const newClient = await getAuthClient();
571
+ return operation(newClient);
268
572
  }
269
- const fs9Url = deriveFs9Url(dbId);
270
- const url = `${fs9Url}/api/v1${fsPath}`;
271
- const headers = {};
272
- if (token) {
273
- headers["Authorization"] = `Bearer ${token}`;
573
+ }
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}'`);
274
586
  }
275
- if (body !== void 0) {
276
- headers["Content-Type"] = contentType || "text/plain";
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
+ };
595
+ }
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
+ );
277
602
  }
278
- const init = { method, headers };
279
- if (body !== void 0) {
280
- init.body = body;
603
+ const conn = await resolveFsConn(dbId);
604
+ const client = await FsClient.connect(conn.wsUrl, WS);
605
+ try {
606
+ await client.authenticate(conn.username, conn.password);
607
+ return await operation(client);
608
+ } finally {
609
+ await client.close();
610
+ }
611
+ }
612
+ function parseSqlError(raw) {
613
+ try {
614
+ const parsed = JSON.parse(raw);
615
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.message === "string") {
616
+ return parsed;
617
+ }
618
+ } catch {
281
619
  }
282
- const response = await fetchFn(url, init);
283
- if (!response.ok) {
284
- throw await Db9Error.fromResponse(response);
620
+ const pgMatch = raw.match(/^(?:ERROR:\s*)?(.+?)(?:\s+DETAIL:\s+(.+?))?(?:\s+HINT:\s+(.+?))?(?:\s+\(SQLSTATE\s+(\w+)\))?$/s);
621
+ if (pgMatch && pgMatch[1]) {
622
+ const result = { message: pgMatch[1].trim() };
623
+ if (pgMatch[2]) result.detail = pgMatch[2].trim();
624
+ if (pgMatch[3]) result.hint = pgMatch[3].trim();
625
+ if (pgMatch[4]) result.code = pgMatch[4];
626
+ return result;
285
627
  }
286
- return response;
628
+ return { message: raw };
629
+ }
630
+ async function fetchAnonymousSecret() {
631
+ return withAuthRetry(
632
+ (client) => client.post("/customer/anonymous-secret", {})
633
+ );
287
634
  }
288
635
  return {
289
636
  auth: {
@@ -298,196 +645,226 @@ function createDb9Client(options = {}) {
298
645
  req
299
646
  ),
300
647
  // Authenticated endpoints
301
- me: async () => {
302
- const client = await getAuthClient();
303
- return client.get("/customer/me");
304
- },
305
- getAnonymousSecret: async () => {
306
- const client = await getAuthClient();
307
- return client.get(
308
- "/customer/anonymous-secret"
309
- );
648
+ me: async () => withAuthRetry(
649
+ (client) => client.get("/customer/me")
650
+ ),
651
+ getAnonymousSecret: () => {
652
+ return fetchAnonymousSecret();
310
653
  },
311
- claim: async (req) => {
312
- const client = await getAuthClient();
313
- return client.post("/customer/claim", req);
654
+ claim: async (req) => withAuthRetry(
655
+ (client) => client.post("/customer/claim", req)
656
+ ),
657
+ ensureAnonymousSecret: async () => {
658
+ const creds = await store.load();
659
+ if (!creds?.anonymous_id || creds.anonymous_secret) {
660
+ return;
661
+ }
662
+ const resp = await fetchAnonymousSecret();
663
+ await store.save({
664
+ ...creds,
665
+ anonymous_secret: resp.anonymous_secret
666
+ });
314
667
  }
315
668
  },
316
669
  tokens: {
317
- list: async () => {
318
- const client = await getAuthClient();
319
- return client.get("/customer/tokens");
320
- },
321
- revoke: async (tokenId) => {
322
- const client = await getAuthClient();
323
- return client.del(`/customer/tokens/${tokenId}`);
324
- }
670
+ list: async () => withAuthRetry(
671
+ (client) => client.get("/customer/tokens")
672
+ ),
673
+ revoke: async (tokenId) => withAuthRetry(
674
+ (client) => client.del(`/customer/tokens/${tokenId}`)
675
+ ),
676
+ create: async (req) => withAuthRetry(
677
+ (client) => client.post("/customer/tokens", req)
678
+ )
325
679
  },
326
680
  databases: {
327
681
  // ── CRUD ──────────────────────────────────────────────────
328
- create: async (req) => {
329
- const client = await getAuthClient();
330
- return client.post("/customer/databases", req);
331
- },
332
- list: async () => {
333
- const client = await getAuthClient();
334
- return client.get("/customer/databases");
335
- },
336
- get: async (databaseId) => {
337
- const client = await getAuthClient();
338
- return client.get(
682
+ create: async (req) => withAuthRetry(
683
+ (client) => client.post("/customer/databases", req)
684
+ ),
685
+ list: async () => withAuthRetry(
686
+ (client) => client.get("/customer/databases")
687
+ ),
688
+ get: async (databaseId) => withAuthRetry(
689
+ (client) => client.get(
339
690
  `/customer/databases/${databaseId}`
340
- );
341
- },
342
- delete: async (databaseId) => {
343
- const client = await getAuthClient();
344
- return client.del(
691
+ )
692
+ ),
693
+ delete: async (databaseId) => withAuthRetry(
694
+ (client) => client.del(
345
695
  `/customer/databases/${databaseId}`
346
- );
347
- },
348
- resetPassword: async (databaseId) => {
349
- const client = await getAuthClient();
350
- return client.post(
696
+ )
697
+ ),
698
+ resetPassword: async (databaseId) => withAuthRetry(
699
+ (client) => client.post(
351
700
  `/customer/databases/${databaseId}/reset-password`
352
- );
353
- },
354
- observability: async (databaseId) => {
355
- const client = await getAuthClient();
356
- return client.get(
701
+ )
702
+ ),
703
+ credentials: async (databaseId) => withAuthRetry(
704
+ (client) => client.get(
705
+ `/customer/databases/${databaseId}/credentials`
706
+ )
707
+ ),
708
+ observability: async (databaseId) => withAuthRetry(
709
+ (client) => client.get(
357
710
  `/customer/databases/${databaseId}/observability`
358
- );
359
- },
711
+ )
712
+ ),
360
713
  // ── SQL Execution ─────────────────────────────────────────
361
714
  sql: async (databaseId, query) => {
362
- const client = await getAuthClient();
363
- return client.post(
364
- `/customer/databases/${databaseId}/sql`,
365
- { query }
715
+ const result = await withAuthRetry(
716
+ (client) => client.post(
717
+ `/customer/databases/${databaseId}/sql`,
718
+ { query }
719
+ )
366
720
  );
721
+ if (result.error && typeof result.error === "string") {
722
+ result.error = parseSqlError(result.error);
723
+ }
724
+ return result;
367
725
  },
368
726
  sqlFile: async (databaseId, fileContent) => {
369
- const client = await getAuthClient();
370
- return client.post(
371
- `/customer/databases/${databaseId}/sql`,
372
- { file_content: fileContent }
727
+ const result = await withAuthRetry(
728
+ (client) => client.post(
729
+ `/customer/databases/${databaseId}/sql`,
730
+ { file_content: fileContent }
731
+ )
373
732
  );
733
+ if (result.error && typeof result.error === "string") {
734
+ result.error = parseSqlError(result.error);
735
+ }
736
+ return result;
374
737
  },
375
738
  // ── Schema & Dump ─────────────────────────────────────────
376
- schema: async (databaseId) => {
377
- const client = await getAuthClient();
378
- return client.get(
739
+ schema: async (databaseId) => withAuthRetry(
740
+ (client) => client.get(
379
741
  `/customer/databases/${databaseId}/schema`
380
- );
381
- },
382
- dump: async (databaseId, req) => {
383
- const client = await getAuthClient();
384
- return client.post(
742
+ )
743
+ ),
744
+ dump: async (databaseId, req) => withAuthRetry(
745
+ (client) => client.post(
385
746
  `/customer/databases/${databaseId}/dump`,
386
747
  req
387
- );
388
- },
748
+ )
749
+ ),
389
750
  // ── Migrations ────────────────────────────────────────────
390
- applyMigration: async (databaseId, req) => {
391
- const client = await getAuthClient();
392
- return client.post(
751
+ applyMigration: async (databaseId, req) => withAuthRetry(
752
+ (client) => client.post(
393
753
  `/customer/databases/${databaseId}/migrations`,
394
754
  req
395
- );
396
- },
397
- listMigrations: async (databaseId) => {
398
- const client = await getAuthClient();
399
- return client.get(
755
+ )
756
+ ),
757
+ listMigrations: async (databaseId) => withAuthRetry(
758
+ (client) => client.get(
400
759
  `/customer/databases/${databaseId}/migrations`
401
- );
402
- },
760
+ )
761
+ ),
403
762
  // ── Branching ─────────────────────────────────────────────
404
- branch: async (databaseId, req) => {
405
- const client = await getAuthClient();
406
- return client.post(
763
+ branch: async (databaseId, req) => withAuthRetry(
764
+ (client) => client.post(
407
765
  `/customer/databases/${databaseId}/branch`,
408
766
  req
409
- );
410
- },
767
+ )
768
+ ),
411
769
  // ── User Management ───────────────────────────────────────
412
770
  users: {
413
- list: async (databaseId) => {
414
- const client = await getAuthClient();
415
- return client.get(
771
+ list: async (databaseId) => withAuthRetry(
772
+ (client) => client.get(
416
773
  `/customer/databases/${databaseId}/users`
417
- );
418
- },
419
- create: async (databaseId, req) => {
420
- const client = await getAuthClient();
421
- return client.post(
774
+ )
775
+ ),
776
+ create: async (databaseId, req) => withAuthRetry(
777
+ (client) => client.post(
422
778
  `/customer/databases/${databaseId}/users`,
423
779
  req
424
- );
425
- },
426
- delete: async (databaseId, username) => {
427
- const client = await getAuthClient();
428
- return client.del(
780
+ )
781
+ ),
782
+ delete: async (databaseId, username) => withAuthRetry(
783
+ (client) => client.del(
429
784
  `/customer/databases/${databaseId}/users/${username}`
430
- );
431
- }
785
+ )
786
+ )
432
787
  }
433
788
  },
434
789
  fs: {
435
- list: async (dbId, path, options2) => {
436
- const params = new URLSearchParams({ path });
437
- if (options2?.recursive) params.set("recursive", "true");
438
- const response = await fsRequest(
439
- "GET",
440
- dbId,
441
- `/readdir?${params.toString()}`
442
- );
443
- return response.json();
444
- },
445
- read: async (dbId, path) => {
446
- const params = new URLSearchParams({ path });
447
- const response = await fsRequest(
448
- "GET",
449
- dbId,
450
- `/download?${params.toString()}`
451
- );
452
- return response.text();
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;
453
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. */
454
816
  write: async (dbId, path, content) => {
455
- const params = new URLSearchParams({ path });
456
- await fsRequest("PUT", dbId, `/upload?${params.toString()}`, content);
817
+ await withFsClient(dbId, (client) => client.writeFile(path, content));
457
818
  },
458
- stat: async (dbId, path) => {
459
- const params = new URLSearchParams({ path });
460
- const response = await fsRequest(
461
- "GET",
462
- dbId,
463
- `/stat?${params.toString()}`
464
- );
465
- return response.json();
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. */
824
+ exists: async (dbId, path) => {
825
+ try {
826
+ await withFsClient(dbId, (client) => client.stat(path));
827
+ return true;
828
+ } catch (err) {
829
+ if (err instanceof FsError && err.code === "ENOENT") {
830
+ return false;
831
+ }
832
+ throw err;
833
+ }
466
834
  },
835
+ /** Create a directory (recursive by default). */
467
836
  mkdir: async (dbId, path) => {
468
- const openResp = await fsRequest(
469
- "POST",
470
- dbId,
471
- "/open",
472
- JSON.stringify({
473
- path,
474
- flags: { create: true, directory: true }
475
- }),
476
- "application/json"
477
- );
478
- const { handle_id } = await openResp.json();
479
- await fsRequest(
480
- "POST",
481
- dbId,
482
- "/close",
483
- JSON.stringify({ handle_id }),
484
- "application/json"
485
- );
837
+ await withFsClient(dbId, (client) => client.mkdir(path, true));
486
838
  },
487
- remove: async (dbId, path) => {
488
- const params = new URLSearchParams({ path });
489
- await fsRequest("DELETE", dbId, `/remove?${params.toString()}`);
839
+ /** Remove a file or directory. */
840
+ remove: async (dbId, path, opts) => {
841
+ await withFsClient(dbId, (client) => client.rm(path, opts?.recursive ?? false));
842
+ },
843
+ /** Rename (move) a file or directory. */
844
+ rename: async (dbId, oldPath, newPath) => {
845
+ await withFsClient(dbId, (client) => client.rename(oldPath, newPath));
490
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)
491
868
  }
492
869
  };
493
870
  }
@@ -498,7 +875,10 @@ async function instantDatabase(options = {}) {
498
875
  const client = createDb9Client({
499
876
  baseUrl: options.baseUrl,
500
877
  fetch: options.fetch,
501
- credentialStore: options.credentialStore
878
+ credentialStore: options.credentialStore,
879
+ timeout: options.timeout,
880
+ maxRetries: options.maxRetries,
881
+ retryDelay: options.retryDelay
502
882
  });
503
883
  const existing = await client.databases.list();
504
884
  const found = existing.find((db) => db.name === dbName);
@@ -529,6 +909,8 @@ export {
529
909
  Db9Error,
530
910
  Db9NotFoundError,
531
911
  FileCredentialStore,
912
+ FsClient,
913
+ FsError,
532
914
  MemoryCredentialStore,
533
915
  createDb9Client,
534
916
  defaultCredentialStore,