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