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.cjs CHANGED
@@ -92,6 +92,9 @@ var Db9ConflictError = class extends Db9Error {
92
92
  };
93
93
 
94
94
  // src/http.ts
95
+ function delay(ms) {
96
+ return new Promise((resolve) => setTimeout(resolve, ms));
97
+ }
95
98
  function createHttpClient(options) {
96
99
  const fetchFn = options.fetch ?? globalThis.fetch;
97
100
  const baseUrl = options.baseUrl.replace(/\/$/, "");
@@ -100,38 +103,61 @@ function createHttpClient(options) {
100
103
  if (params) {
101
104
  const searchParams = new URLSearchParams();
102
105
  for (const [key, value] of Object.entries(params)) {
103
- if (value !== void 0) {
104
- searchParams.set(key, value);
105
- }
106
+ if (value !== void 0) searchParams.set(key, value);
106
107
  }
107
108
  const qs = searchParams.toString();
108
109
  if (qs) url += `?${qs}`;
109
110
  }
110
- const headers = {
111
+ const reqHeaders = {
111
112
  "Content-Type": "application/json",
112
113
  ...options.headers
113
114
  };
114
- const init = { method, headers };
115
+ const init = { method, headers: reqHeaders };
115
116
  if (body !== void 0) {
116
117
  init.body = JSON.stringify(body);
117
118
  }
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;
119
+ const maxAttempts = Math.min(options.maxRetries ?? 0, 3) + 1;
120
+ const baseDelay = options.retryDelay ?? 1e3;
121
+ let lastError;
122
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
123
+ let timeoutId;
124
+ try {
125
+ const fetchInit = { ...init };
126
+ if (options.timeout) {
127
+ const controller = new AbortController();
128
+ fetchInit.signal = controller.signal;
129
+ timeoutId = setTimeout(() => controller.abort(), options.timeout);
130
+ }
131
+ const response = await fetchFn(url, fetchInit);
132
+ if (timeoutId) clearTimeout(timeoutId);
133
+ if (!response.ok) {
134
+ if (response.status >= 500 && attempt < maxAttempts - 1) {
135
+ lastError = await Db9Error.fromResponse(response);
136
+ await delay(baseDelay * Math.pow(2, attempt));
137
+ continue;
138
+ }
139
+ throw await Db9Error.fromResponse(response);
140
+ }
141
+ if (response.status === 204) return void 0;
142
+ return response.json();
143
+ } catch (err) {
144
+ if (timeoutId) clearTimeout(timeoutId);
145
+ if (err instanceof TypeError && attempt < maxAttempts - 1) {
146
+ lastError = err;
147
+ await delay(baseDelay * Math.pow(2, attempt));
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
124
152
  }
125
- return response.json();
153
+ throw lastError;
126
154
  }
127
155
  async function requestRaw(method, path, body, params, customHeaders) {
128
156
  let url = `${baseUrl}${path}`;
129
157
  if (params) {
130
158
  const searchParams = new URLSearchParams();
131
159
  for (const [key, value] of Object.entries(params)) {
132
- if (value !== void 0) {
133
- searchParams.set(key, value);
134
- }
160
+ if (value !== void 0) searchParams.set(key, value);
135
161
  }
136
162
  const qs = searchParams.toString();
137
163
  if (qs) url += `?${qs}`;
@@ -140,15 +166,23 @@ function createHttpClient(options) {
140
166
  ...options.headers,
141
167
  ...customHeaders
142
168
  };
143
- const init = { method, headers };
144
- if (body !== void 0) {
145
- init.body = body;
169
+ const fetchInit = { method, headers };
170
+ if (body !== void 0) fetchInit.body = body;
171
+ let timeoutId;
172
+ if (options.timeout) {
173
+ const controller = new AbortController();
174
+ fetchInit.signal = controller.signal;
175
+ timeoutId = setTimeout(() => controller.abort(), options.timeout);
146
176
  }
147
- const response = await fetchFn(url, init);
148
- if (!response.ok) {
149
- throw await Db9Error.fromResponse(response);
177
+ try {
178
+ const response = await fetchFn(url, fetchInit);
179
+ if (timeoutId) clearTimeout(timeoutId);
180
+ if (!response.ok) throw await Db9Error.fromResponse(response);
181
+ return response;
182
+ } catch (err) {
183
+ if (timeoutId) clearTimeout(timeoutId);
184
+ throw err;
150
185
  }
151
- return response;
152
186
  }
153
187
  return {
154
188
  get: (path, params) => request("GET", path, void 0, params),
@@ -156,7 +190,9 @@ function createHttpClient(options) {
156
190
  put: (path, body) => request("PUT", path, body),
157
191
  del: (path) => request("DELETE", path),
158
192
  getRaw: (path, params) => requestRaw("GET", path, void 0, params),
159
- putRaw: (path, body, headers) => requestRaw("PUT", path, body, void 0, headers)
193
+ putRaw: (path, body, headers) => requestRaw("PUT", path, body, void 0, headers),
194
+ postRaw: (path, body, headers) => requestRaw("POST", path, body, void 0, headers),
195
+ delRaw: (path, params) => requestRaw("DELETE", path, void 0, params)
160
196
  };
161
197
  }
162
198
 
@@ -276,7 +312,10 @@ function createDb9Client(options = {}) {
276
312
  const fetchFn = options.fetch ?? globalThis.fetch;
277
313
  const publicClient = createHttpClient({
278
314
  baseUrl,
279
- fetch: options.fetch
315
+ fetch: options.fetch,
316
+ timeout: options.timeout,
317
+ maxRetries: options.maxRetries,
318
+ retryDelay: options.retryDelay
280
319
  });
281
320
  async function getAuthClient() {
282
321
  if (!token && !tokenLoaded) {
@@ -299,35 +338,118 @@ function createDb9Client(options = {}) {
299
338
  return createHttpClient({
300
339
  baseUrl,
301
340
  fetch: options.fetch,
302
- headers: { Authorization: `Bearer ${token}` }
341
+ headers: { Authorization: `Bearer ${token}` },
342
+ timeout: options.timeout,
343
+ maxRetries: options.maxRetries,
344
+ retryDelay: options.retryDelay
303
345
  });
304
346
  }
347
+ let refreshPromise = null;
348
+ async function refreshAnonymousToken() {
349
+ const creds = await store.load();
350
+ if (!creds?.anonymous_id || !creds?.anonymous_secret) {
351
+ throw new Error("Not an anonymous session");
352
+ }
353
+ const resp = await publicClient.post(
354
+ "/customer/anonymous-refresh",
355
+ {
356
+ anonymous_id: creds.anonymous_id,
357
+ anonymous_secret: creds.anonymous_secret
358
+ }
359
+ );
360
+ token = resp.token;
361
+ await store.save({ ...creds, token: resp.token });
362
+ }
363
+ async function withAuthRetry(operation) {
364
+ const client = await getAuthClient();
365
+ try {
366
+ return await operation(client);
367
+ } catch (err) {
368
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
369
+ throw err;
370
+ }
371
+ try {
372
+ if (!refreshPromise) {
373
+ refreshPromise = refreshAnonymousToken();
374
+ }
375
+ await refreshPromise;
376
+ } catch {
377
+ throw err;
378
+ } finally {
379
+ refreshPromise = null;
380
+ }
381
+ const newClient = await getAuthClient();
382
+ return operation(newClient);
383
+ }
384
+ }
305
385
  function deriveFs9Url(dbId) {
306
386
  const origin = baseUrl.replace(/\/api\/?$/, "");
307
387
  return `${origin}/fs9/${dbId}`;
308
388
  }
309
- async function fsRequest(method, dbId, fsPath, body) {
389
+ function getFsClient(dbId) {
390
+ const fs9Base = deriveFs9Url(dbId) + "/api/v1";
391
+ return createHttpClient({
392
+ baseUrl: fs9Base,
393
+ fetch: options.fetch,
394
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
395
+ timeout: options.timeout,
396
+ maxRetries: options.maxRetries,
397
+ retryDelay: options.retryDelay
398
+ });
399
+ }
400
+ async function withFsAuthRetry(dbId, operation) {
310
401
  if (!token && !tokenLoaded) {
311
402
  await getAuthClient();
312
403
  }
313
- const fs9Url = deriveFs9Url(dbId);
314
- const url = `${fs9Url}/api/v1${fsPath}`;
315
- const headers = {};
316
- if (token) {
317
- headers["Authorization"] = `Bearer ${token}`;
318
- }
319
- if (body !== void 0) {
320
- headers["Content-Type"] = "text/plain";
404
+ const client = getFsClient(dbId);
405
+ try {
406
+ return await operation(client);
407
+ } catch (err) {
408
+ if (!(err instanceof Db9Error) || err.statusCode !== 401) {
409
+ throw err;
410
+ }
411
+ try {
412
+ if (!refreshPromise) {
413
+ refreshPromise = refreshAnonymousToken();
414
+ }
415
+ await refreshPromise;
416
+ } catch {
417
+ throw err;
418
+ } finally {
419
+ refreshPromise = null;
420
+ }
421
+ const newClient = getFsClient(dbId);
422
+ return operation(newClient);
321
423
  }
322
- const init = { method, headers };
323
- if (body !== void 0) {
324
- init.body = body;
424
+ }
425
+ function parseSqlError(raw) {
426
+ try {
427
+ const parsed = JSON.parse(raw);
428
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.message === "string") {
429
+ return parsed;
430
+ }
431
+ } catch {
325
432
  }
326
- const response = await fetchFn(url, init);
327
- if (!response.ok) {
328
- throw await Db9Error.fromResponse(response);
433
+ const pgMatch = raw.match(/^(?:ERROR:\s*)?(.+?)(?:\s+DETAIL:\s+(.+?))?(?:\s+HINT:\s+(.+?))?(?:\s+\(SQLSTATE\s+(\w+)\))?$/s);
434
+ if (pgMatch && pgMatch[1]) {
435
+ const result = { message: pgMatch[1].trim() };
436
+ if (pgMatch[2]) result.detail = pgMatch[2].trim();
437
+ if (pgMatch[3]) result.hint = pgMatch[3].trim();
438
+ if (pgMatch[4]) result.code = pgMatch[4];
439
+ return result;
329
440
  }
330
- return response;
441
+ return { message: raw };
442
+ }
443
+ async function fsStat(dbId, path) {
444
+ return withFsAuthRetry(
445
+ dbId,
446
+ (client) => client.get("/stat", { path })
447
+ );
448
+ }
449
+ async function fetchAnonymousSecret() {
450
+ return withAuthRetry(
451
+ (client) => client.post("/customer/anonymous-secret", {})
452
+ );
331
453
  }
332
454
  return {
333
455
  auth: {
@@ -342,171 +464,206 @@ function createDb9Client(options = {}) {
342
464
  req
343
465
  ),
344
466
  // 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
- );
467
+ me: async () => withAuthRetry(
468
+ (client) => client.get("/customer/me")
469
+ ),
470
+ getAnonymousSecret: () => {
471
+ return fetchAnonymousSecret();
354
472
  },
355
- claim: async (req) => {
356
- const client = await getAuthClient();
357
- return client.post("/customer/claim", req);
473
+ claim: async (req) => withAuthRetry(
474
+ (client) => client.post("/customer/claim", req)
475
+ ),
476
+ ensureAnonymousSecret: async () => {
477
+ const creds = await store.load();
478
+ if (!creds?.anonymous_id || creds.anonymous_secret) {
479
+ return;
480
+ }
481
+ const resp = await fetchAnonymousSecret();
482
+ await store.save({
483
+ ...creds,
484
+ anonymous_secret: resp.anonymous_secret
485
+ });
358
486
  }
359
487
  },
360
488
  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
- }
489
+ list: async () => withAuthRetry(
490
+ (client) => client.get("/customer/tokens")
491
+ ),
492
+ revoke: async (tokenId) => withAuthRetry(
493
+ (client) => client.del(`/customer/tokens/${tokenId}`)
494
+ ),
495
+ create: async (req) => withAuthRetry(
496
+ (client) => client.post("/customer/tokens", req)
497
+ )
369
498
  },
370
499
  databases: {
371
500
  // ── 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(
501
+ create: async (req) => withAuthRetry(
502
+ (client) => client.post("/customer/databases", req)
503
+ ),
504
+ list: async () => withAuthRetry(
505
+ (client) => client.get("/customer/databases")
506
+ ),
507
+ get: async (databaseId) => withAuthRetry(
508
+ (client) => client.get(
383
509
  `/customer/databases/${databaseId}`
384
- );
385
- },
386
- delete: async (databaseId) => {
387
- const client = await getAuthClient();
388
- return client.del(
510
+ )
511
+ ),
512
+ delete: async (databaseId) => withAuthRetry(
513
+ (client) => client.del(
389
514
  `/customer/databases/${databaseId}`
390
- );
391
- },
392
- resetPassword: async (databaseId) => {
393
- const client = await getAuthClient();
394
- return client.post(
515
+ )
516
+ ),
517
+ resetPassword: async (databaseId) => withAuthRetry(
518
+ (client) => client.post(
395
519
  `/customer/databases/${databaseId}/reset-password`
396
- );
397
- },
398
- observability: async (databaseId) => {
399
- const client = await getAuthClient();
400
- return client.get(
520
+ )
521
+ ),
522
+ observability: async (databaseId) => withAuthRetry(
523
+ (client) => client.get(
401
524
  `/customer/databases/${databaseId}/observability`
402
- );
403
- },
525
+ )
526
+ ),
404
527
  // ── SQL Execution ─────────────────────────────────────────
405
528
  sql: async (databaseId, query) => {
406
- const client = await getAuthClient();
407
- return client.post(
408
- `/customer/databases/${databaseId}/sql`,
409
- { query }
529
+ const result = await withAuthRetry(
530
+ (client) => client.post(
531
+ `/customer/databases/${databaseId}/sql`,
532
+ { query }
533
+ )
410
534
  );
535
+ if (result.error && typeof result.error === "string") {
536
+ result.error = parseSqlError(result.error);
537
+ }
538
+ return result;
411
539
  },
412
540
  sqlFile: async (databaseId, fileContent) => {
413
- const client = await getAuthClient();
414
- return client.post(
415
- `/customer/databases/${databaseId}/sql`,
416
- { file_content: fileContent }
541
+ const result = await withAuthRetry(
542
+ (client) => client.post(
543
+ `/customer/databases/${databaseId}/sql`,
544
+ { file_content: fileContent }
545
+ )
417
546
  );
547
+ if (result.error && typeof result.error === "string") {
548
+ result.error = parseSqlError(result.error);
549
+ }
550
+ return result;
418
551
  },
419
552
  // ── Schema & Dump ─────────────────────────────────────────
420
- schema: async (databaseId) => {
421
- const client = await getAuthClient();
422
- return client.get(
553
+ schema: async (databaseId) => withAuthRetry(
554
+ (client) => client.get(
423
555
  `/customer/databases/${databaseId}/schema`
424
- );
425
- },
426
- dump: async (databaseId, req) => {
427
- const client = await getAuthClient();
428
- return client.post(
556
+ )
557
+ ),
558
+ dump: async (databaseId, req) => withAuthRetry(
559
+ (client) => client.post(
429
560
  `/customer/databases/${databaseId}/dump`,
430
561
  req
431
- );
432
- },
562
+ )
563
+ ),
433
564
  // ── Migrations ────────────────────────────────────────────
434
- applyMigration: async (databaseId, req) => {
435
- const client = await getAuthClient();
436
- return client.post(
565
+ applyMigration: async (databaseId, req) => withAuthRetry(
566
+ (client) => client.post(
437
567
  `/customer/databases/${databaseId}/migrations`,
438
568
  req
439
- );
440
- },
441
- listMigrations: async (databaseId) => {
442
- const client = await getAuthClient();
443
- return client.get(
569
+ )
570
+ ),
571
+ listMigrations: async (databaseId) => withAuthRetry(
572
+ (client) => client.get(
444
573
  `/customer/databases/${databaseId}/migrations`
445
- );
446
- },
574
+ )
575
+ ),
447
576
  // ── Branching ─────────────────────────────────────────────
448
- branch: async (databaseId, req) => {
449
- const client = await getAuthClient();
450
- return client.post(
577
+ branch: async (databaseId, req) => withAuthRetry(
578
+ (client) => client.post(
451
579
  `/customer/databases/${databaseId}/branch`,
452
580
  req
453
- );
454
- },
581
+ )
582
+ ),
455
583
  // ── User Management ───────────────────────────────────────
456
584
  users: {
457
- list: async (databaseId) => {
458
- const client = await getAuthClient();
459
- return client.get(
585
+ list: async (databaseId) => withAuthRetry(
586
+ (client) => client.get(
460
587
  `/customer/databases/${databaseId}/users`
461
- );
462
- },
463
- create: async (databaseId, req) => {
464
- const client = await getAuthClient();
465
- return client.post(
588
+ )
589
+ ),
590
+ create: async (databaseId, req) => withAuthRetry(
591
+ (client) => client.post(
466
592
  `/customer/databases/${databaseId}/users`,
467
593
  req
468
- );
469
- },
470
- delete: async (databaseId, username) => {
471
- const client = await getAuthClient();
472
- return client.del(
594
+ )
595
+ ),
596
+ delete: async (databaseId, username) => withAuthRetry(
597
+ (client) => client.del(
473
598
  `/customer/databases/${databaseId}/users/${username}`
474
- );
475
- }
599
+ )
600
+ )
476
601
  }
477
602
  },
478
603
  fs: {
479
604
  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",
605
+ const params = { path };
606
+ if (options2?.recursive) params.recursive = "true";
607
+ return withFsAuthRetry(
484
608
  dbId,
485
- `/readdir?${params.toString()}`
609
+ (client) => client.get("/readdir", params)
486
610
  );
487
- return response.json();
488
611
  },
489
612
  read: async (dbId, path) => {
490
- const params = new URLSearchParams({ path });
491
- const response = await fsRequest(
492
- "GET",
613
+ return withFsAuthRetry(dbId, async (client) => {
614
+ const resp = await client.getRaw("/download", { path });
615
+ return resp.text();
616
+ });
617
+ },
618
+ readBinary: async (dbId, path) => {
619
+ return withFsAuthRetry(dbId, async (client) => {
620
+ const resp = await client.getRaw("/download", { path });
621
+ return resp.arrayBuffer();
622
+ });
623
+ },
624
+ write: async (dbId, path, content) => {
625
+ const contentType = typeof content === "string" ? "text/plain" : "application/octet-stream";
626
+ await withFsAuthRetry(
493
627
  dbId,
494
- `/download?${params.toString()}`
628
+ (client) => client.putRaw(`/upload?${new URLSearchParams({ path })}`, content, { "Content-Type": contentType })
495
629
  );
496
- return response.text();
497
630
  },
498
- write: async (dbId, path, content) => {
499
- const params = new URLSearchParams({ path });
500
- await fsRequest("PUT", dbId, `/upload?${params.toString()}`, content);
631
+ stat: (dbId, path) => {
632
+ return fsStat(dbId, path);
501
633
  },
502
- stat: async (dbId, path) => {
503
- const params = new URLSearchParams({ path });
504
- const response = await fsRequest(
505
- "GET",
634
+ exists: async (dbId, path) => {
635
+ try {
636
+ await fsStat(dbId, path);
637
+ return true;
638
+ } catch (err) {
639
+ if (err instanceof Db9Error && err.statusCode === 404) {
640
+ return false;
641
+ }
642
+ throw err;
643
+ }
644
+ },
645
+ mkdir: async (dbId, path) => {
646
+ await withFsAuthRetry(
506
647
  dbId,
507
- `/stat?${params.toString()}`
648
+ (client) => client.postRaw(`/mkdir?${new URLSearchParams({ path, recursive: "true" })}`)
649
+ );
650
+ },
651
+ remove: async (dbId, path) => {
652
+ await withFsAuthRetry(
653
+ dbId,
654
+ (client) => client.delRaw("/remove", { path })
655
+ );
656
+ },
657
+ events: async (dbId, options2) => {
658
+ const params = {};
659
+ if (options2?.limit !== void 0) params.limit = String(options2.limit);
660
+ if (options2?.offset !== void 0) params.offset = String(options2.offset);
661
+ if (options2?.path) params.path = options2.path;
662
+ if (options2?.type) params.type = options2.type;
663
+ return withFsAuthRetry(
664
+ dbId,
665
+ (client) => client.get("/events", params)
508
666
  );
509
- return response.json();
510
667
  }
511
668
  }
512
669
  };
@@ -518,7 +675,10 @@ async function instantDatabase(options = {}) {
518
675
  const client = createDb9Client({
519
676
  baseUrl: options.baseUrl,
520
677
  fetch: options.fetch,
521
- credentialStore: options.credentialStore
678
+ credentialStore: options.credentialStore,
679
+ timeout: options.timeout,
680
+ maxRetries: options.maxRetries,
681
+ retryDelay: options.retryDelay
522
682
  });
523
683
  const existing = await client.databases.list();
524
684
  const found = existing.find((db) => db.name === dbName);