get-db9 0.4.0 → 0.5.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
 
@@ -232,7 +268,10 @@ function createDb9Client(options = {}) {
232
268
  const fetchFn = options.fetch ?? globalThis.fetch;
233
269
  const publicClient = createHttpClient({
234
270
  baseUrl,
235
- fetch: options.fetch
271
+ fetch: options.fetch,
272
+ timeout: options.timeout,
273
+ maxRetries: options.maxRetries,
274
+ retryDelay: options.retryDelay
236
275
  });
237
276
  async function getAuthClient() {
238
277
  if (!token && !tokenLoaded) {
@@ -255,35 +294,118 @@ function createDb9Client(options = {}) {
255
294
  return createHttpClient({
256
295
  baseUrl,
257
296
  fetch: options.fetch,
258
- headers: { Authorization: `Bearer ${token}` }
297
+ headers: { Authorization: `Bearer ${token}` },
298
+ timeout: options.timeout,
299
+ maxRetries: options.maxRetries,
300
+ retryDelay: options.retryDelay
259
301
  });
260
302
  }
303
+ let refreshPromise = null;
304
+ async function refreshAnonymousToken() {
305
+ const creds = await store.load();
306
+ if (!creds?.anonymous_id || !creds?.anonymous_secret) {
307
+ throw new Error("Not an anonymous session");
308
+ }
309
+ const resp = await publicClient.post(
310
+ "/customer/anonymous-refresh",
311
+ {
312
+ anonymous_id: creds.anonymous_id,
313
+ anonymous_secret: creds.anonymous_secret
314
+ }
315
+ );
316
+ token = resp.token;
317
+ await store.save({ ...creds, token: resp.token });
318
+ }
319
+ async function withAuthRetry(operation) {
320
+ const client = await getAuthClient();
321
+ try {
322
+ return await operation(client);
323
+ } catch (err) {
324
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
325
+ throw err;
326
+ }
327
+ try {
328
+ if (!refreshPromise) {
329
+ refreshPromise = refreshAnonymousToken();
330
+ }
331
+ await refreshPromise;
332
+ } catch {
333
+ throw err;
334
+ } finally {
335
+ refreshPromise = null;
336
+ }
337
+ const newClient = await getAuthClient();
338
+ return operation(newClient);
339
+ }
340
+ }
261
341
  function deriveFs9Url(dbId) {
262
342
  const origin = baseUrl.replace(/\/api\/?$/, "");
263
343
  return `${origin}/fs9/${dbId}`;
264
344
  }
265
- async function fsRequest(method, dbId, fsPath, body) {
345
+ function getFsClient(dbId) {
346
+ const fs9Base = deriveFs9Url(dbId) + "/api/v1";
347
+ return createHttpClient({
348
+ baseUrl: fs9Base,
349
+ fetch: options.fetch,
350
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
351
+ timeout: options.timeout,
352
+ maxRetries: options.maxRetries,
353
+ retryDelay: options.retryDelay
354
+ });
355
+ }
356
+ async function withFsAuthRetry(dbId, operation) {
266
357
  if (!token && !tokenLoaded) {
267
358
  await getAuthClient();
268
359
  }
269
- const fs9Url = deriveFs9Url(dbId);
270
- const url = `${fs9Url}/api/v1${fsPath}`;
271
- const headers = {};
272
- if (token) {
273
- headers["Authorization"] = `Bearer ${token}`;
274
- }
275
- if (body !== void 0) {
276
- headers["Content-Type"] = "text/plain";
360
+ const client = getFsClient(dbId);
361
+ try {
362
+ return await operation(client);
363
+ } catch (err) {
364
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
365
+ throw err;
366
+ }
367
+ try {
368
+ if (!refreshPromise) {
369
+ refreshPromise = refreshAnonymousToken();
370
+ }
371
+ await refreshPromise;
372
+ } catch {
373
+ throw err;
374
+ } finally {
375
+ refreshPromise = null;
376
+ }
377
+ const newClient = getFsClient(dbId);
378
+ return operation(newClient);
277
379
  }
278
- const init = { method, headers };
279
- if (body !== void 0) {
280
- init.body = body;
380
+ }
381
+ function parseSqlError(raw) {
382
+ try {
383
+ const parsed = JSON.parse(raw);
384
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.message === "string") {
385
+ return parsed;
386
+ }
387
+ } catch {
281
388
  }
282
- const response = await fetchFn(url, init);
283
- if (!response.ok) {
284
- throw await Db9Error.fromResponse(response);
389
+ const pgMatch = raw.match(/^(?:ERROR:\s*)?(.+?)(?:\s+DETAIL:\s+(.+?))?(?:\s+HINT:\s+(.+?))?(?:\s+\(SQLSTATE\s+(\w+)\))?$/s);
390
+ if (pgMatch && pgMatch[1]) {
391
+ const result = { message: pgMatch[1].trim() };
392
+ if (pgMatch[2]) result.detail = pgMatch[2].trim();
393
+ if (pgMatch[3]) result.hint = pgMatch[3].trim();
394
+ if (pgMatch[4]) result.code = pgMatch[4];
395
+ return result;
285
396
  }
286
- return response;
397
+ return { message: raw };
398
+ }
399
+ async function fsStat(dbId, path) {
400
+ return withFsAuthRetry(
401
+ dbId,
402
+ (client) => client.get("/stat", { path })
403
+ );
404
+ }
405
+ async function fetchAnonymousSecret() {
406
+ return withAuthRetry(
407
+ (client) => client.post("/customer/anonymous-secret", {})
408
+ );
287
409
  }
288
410
  return {
289
411
  auth: {
@@ -298,171 +420,206 @@ function createDb9Client(options = {}) {
298
420
  req
299
421
  ),
300
422
  // 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
- );
423
+ me: async () => withAuthRetry(
424
+ (client) => client.get("/customer/me")
425
+ ),
426
+ getAnonymousSecret: () => {
427
+ return fetchAnonymousSecret();
310
428
  },
311
- claim: async (req) => {
312
- const client = await getAuthClient();
313
- return client.post("/customer/claim", req);
429
+ claim: async (req) => withAuthRetry(
430
+ (client) => client.post("/customer/claim", req)
431
+ ),
432
+ ensureAnonymousSecret: async () => {
433
+ const creds = await store.load();
434
+ if (!creds?.anonymous_id || creds.anonymous_secret) {
435
+ return;
436
+ }
437
+ const resp = await fetchAnonymousSecret();
438
+ await store.save({
439
+ ...creds,
440
+ anonymous_secret: resp.anonymous_secret
441
+ });
314
442
  }
315
443
  },
316
444
  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
- }
445
+ list: async () => withAuthRetry(
446
+ (client) => client.get("/customer/tokens")
447
+ ),
448
+ revoke: async (tokenId) => withAuthRetry(
449
+ (client) => client.del(`/customer/tokens/${tokenId}`)
450
+ ),
451
+ create: async (req) => withAuthRetry(
452
+ (client) => client.post("/customer/tokens", req)
453
+ )
325
454
  },
326
455
  databases: {
327
456
  // ── 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(
457
+ create: async (req) => withAuthRetry(
458
+ (client) => client.post("/customer/databases", req)
459
+ ),
460
+ list: async () => withAuthRetry(
461
+ (client) => client.get("/customer/databases")
462
+ ),
463
+ get: async (databaseId) => withAuthRetry(
464
+ (client) => client.get(
339
465
  `/customer/databases/${databaseId}`
340
- );
341
- },
342
- delete: async (databaseId) => {
343
- const client = await getAuthClient();
344
- return client.del(
466
+ )
467
+ ),
468
+ delete: async (databaseId) => withAuthRetry(
469
+ (client) => client.del(
345
470
  `/customer/databases/${databaseId}`
346
- );
347
- },
348
- resetPassword: async (databaseId) => {
349
- const client = await getAuthClient();
350
- return client.post(
471
+ )
472
+ ),
473
+ resetPassword: async (databaseId) => withAuthRetry(
474
+ (client) => client.post(
351
475
  `/customer/databases/${databaseId}/reset-password`
352
- );
353
- },
354
- observability: async (databaseId) => {
355
- const client = await getAuthClient();
356
- return client.get(
476
+ )
477
+ ),
478
+ observability: async (databaseId) => withAuthRetry(
479
+ (client) => client.get(
357
480
  `/customer/databases/${databaseId}/observability`
358
- );
359
- },
481
+ )
482
+ ),
360
483
  // ── SQL Execution ─────────────────────────────────────────
361
484
  sql: async (databaseId, query) => {
362
- const client = await getAuthClient();
363
- return client.post(
364
- `/customer/databases/${databaseId}/sql`,
365
- { query }
485
+ const result = await withAuthRetry(
486
+ (client) => client.post(
487
+ `/customer/databases/${databaseId}/sql`,
488
+ { query }
489
+ )
366
490
  );
491
+ if (result.error && typeof result.error === "string") {
492
+ result.error = parseSqlError(result.error);
493
+ }
494
+ return result;
367
495
  },
368
496
  sqlFile: async (databaseId, fileContent) => {
369
- const client = await getAuthClient();
370
- return client.post(
371
- `/customer/databases/${databaseId}/sql`,
372
- { file_content: fileContent }
497
+ const result = await withAuthRetry(
498
+ (client) => client.post(
499
+ `/customer/databases/${databaseId}/sql`,
500
+ { file_content: fileContent }
501
+ )
373
502
  );
503
+ if (result.error && typeof result.error === "string") {
504
+ result.error = parseSqlError(result.error);
505
+ }
506
+ return result;
374
507
  },
375
508
  // ── Schema & Dump ─────────────────────────────────────────
376
- schema: async (databaseId) => {
377
- const client = await getAuthClient();
378
- return client.get(
509
+ schema: async (databaseId) => withAuthRetry(
510
+ (client) => client.get(
379
511
  `/customer/databases/${databaseId}/schema`
380
- );
381
- },
382
- dump: async (databaseId, req) => {
383
- const client = await getAuthClient();
384
- return client.post(
512
+ )
513
+ ),
514
+ dump: async (databaseId, req) => withAuthRetry(
515
+ (client) => client.post(
385
516
  `/customer/databases/${databaseId}/dump`,
386
517
  req
387
- );
388
- },
518
+ )
519
+ ),
389
520
  // ── Migrations ────────────────────────────────────────────
390
- applyMigration: async (databaseId, req) => {
391
- const client = await getAuthClient();
392
- return client.post(
521
+ applyMigration: async (databaseId, req) => withAuthRetry(
522
+ (client) => client.post(
393
523
  `/customer/databases/${databaseId}/migrations`,
394
524
  req
395
- );
396
- },
397
- listMigrations: async (databaseId) => {
398
- const client = await getAuthClient();
399
- return client.get(
525
+ )
526
+ ),
527
+ listMigrations: async (databaseId) => withAuthRetry(
528
+ (client) => client.get(
400
529
  `/customer/databases/${databaseId}/migrations`
401
- );
402
- },
530
+ )
531
+ ),
403
532
  // ── Branching ─────────────────────────────────────────────
404
- branch: async (databaseId, req) => {
405
- const client = await getAuthClient();
406
- return client.post(
533
+ branch: async (databaseId, req) => withAuthRetry(
534
+ (client) => client.post(
407
535
  `/customer/databases/${databaseId}/branch`,
408
536
  req
409
- );
410
- },
537
+ )
538
+ ),
411
539
  // ── User Management ───────────────────────────────────────
412
540
  users: {
413
- list: async (databaseId) => {
414
- const client = await getAuthClient();
415
- return client.get(
541
+ list: async (databaseId) => withAuthRetry(
542
+ (client) => client.get(
416
543
  `/customer/databases/${databaseId}/users`
417
- );
418
- },
419
- create: async (databaseId, req) => {
420
- const client = await getAuthClient();
421
- return client.post(
544
+ )
545
+ ),
546
+ create: async (databaseId, req) => withAuthRetry(
547
+ (client) => client.post(
422
548
  `/customer/databases/${databaseId}/users`,
423
549
  req
424
- );
425
- },
426
- delete: async (databaseId, username) => {
427
- const client = await getAuthClient();
428
- return client.del(
550
+ )
551
+ ),
552
+ delete: async (databaseId, username) => withAuthRetry(
553
+ (client) => client.del(
429
554
  `/customer/databases/${databaseId}/users/${username}`
430
- );
431
- }
555
+ )
556
+ )
432
557
  }
433
558
  },
434
559
  fs: {
435
560
  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",
561
+ const params = { path };
562
+ if (options2?.recursive) params.recursive = "true";
563
+ return withFsAuthRetry(
440
564
  dbId,
441
- `/readdir?${params.toString()}`
565
+ (client) => client.get("/readdir", params)
442
566
  );
443
- return response.json();
444
567
  },
445
568
  read: async (dbId, path) => {
446
- const params = new URLSearchParams({ path });
447
- const response = await fsRequest(
448
- "GET",
569
+ return withFsAuthRetry(dbId, async (client) => {
570
+ const resp = await client.getRaw("/download", { path });
571
+ return resp.text();
572
+ });
573
+ },
574
+ readBinary: async (dbId, path) => {
575
+ return withFsAuthRetry(dbId, async (client) => {
576
+ const resp = await client.getRaw("/download", { path });
577
+ return resp.arrayBuffer();
578
+ });
579
+ },
580
+ write: async (dbId, path, content) => {
581
+ const contentType = typeof content === "string" ? "text/plain" : "application/octet-stream";
582
+ await withFsAuthRetry(
449
583
  dbId,
450
- `/download?${params.toString()}`
584
+ (client) => client.putRaw(`/upload?${new URLSearchParams({ path })}`, content, { "Content-Type": contentType })
451
585
  );
452
- return response.text();
453
586
  },
454
- write: async (dbId, path, content) => {
455
- const params = new URLSearchParams({ path });
456
- await fsRequest("PUT", dbId, `/upload?${params.toString()}`, content);
587
+ stat: (dbId, path) => {
588
+ return fsStat(dbId, path);
457
589
  },
458
- stat: async (dbId, path) => {
459
- const params = new URLSearchParams({ path });
460
- const response = await fsRequest(
461
- "GET",
590
+ exists: async (dbId, path) => {
591
+ try {
592
+ await fsStat(dbId, path);
593
+ return true;
594
+ } catch (err) {
595
+ if (err instanceof Db9Error && err.statusCode === 404) {
596
+ return false;
597
+ }
598
+ throw err;
599
+ }
600
+ },
601
+ mkdir: async (dbId, path) => {
602
+ await withFsAuthRetry(
462
603
  dbId,
463
- `/stat?${params.toString()}`
604
+ (client) => client.postRaw(`/mkdir?${new URLSearchParams({ path, recursive: "true" })}`)
605
+ );
606
+ },
607
+ remove: async (dbId, path) => {
608
+ await withFsAuthRetry(
609
+ dbId,
610
+ (client) => client.delRaw("/remove", { path })
611
+ );
612
+ },
613
+ events: async (dbId, options2) => {
614
+ const params = {};
615
+ if (options2?.limit !== void 0) params.limit = String(options2.limit);
616
+ if (options2?.offset !== void 0) params.offset = String(options2.offset);
617
+ if (options2?.path) params.path = options2.path;
618
+ if (options2?.type) params.type = options2.type;
619
+ return withFsAuthRetry(
620
+ dbId,
621
+ (client) => client.get("/events", params)
464
622
  );
465
- return response.json();
466
623
  }
467
624
  }
468
625
  };
@@ -474,7 +631,10 @@ async function instantDatabase(options = {}) {
474
631
  const client = createDb9Client({
475
632
  baseUrl: options.baseUrl,
476
633
  fetch: options.fetch,
477
- credentialStore: options.credentialStore
634
+ credentialStore: options.credentialStore,
635
+ timeout: options.timeout,
636
+ maxRetries: options.maxRetries,
637
+ retryDelay: options.retryDelay
478
638
  });
479
639
  const existing = await client.databases.list();
480
640
  const found = existing.find((db) => db.name === dbName);