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/README.md +244 -2
- package/dist/client-B3NUYqOc.d.cts +643 -0
- package/dist/client-B3NUYqOc.d.ts +643 -0
- package/dist/client.cjs +567 -190
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +567 -190
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +575 -191
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +573 -191
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
- package/dist/client-CXv2ZlbR.d.cts +0 -429
- package/dist/client-CXv2ZlbR.d.ts +0 -429
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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
if (body !== void 0)
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
266
|
-
if (
|
|
267
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
439
|
-
await fsRequest("PUT", dbId, `/upload?${params.toString()}`, content);
|
|
800
|
+
await withFsClient(dbId, (client) => client.writeFile(path, content));
|
|
440
801
|
},
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
await
|
|
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
|
}
|