@debros/network-ts-sdk 0.2.5 → 0.3.1

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/src/core/http.ts CHANGED
@@ -28,14 +28,6 @@ export class HttpClient {
28
28
  setApiKey(apiKey?: string) {
29
29
  this.apiKey = apiKey;
30
30
  // Don't clear JWT - allow both to coexist
31
- if (typeof console !== "undefined") {
32
- console.log(
33
- "[HttpClient] API key set:",
34
- !!apiKey,
35
- "JWT still present:",
36
- !!this.jwt
37
- );
38
- }
39
31
  }
40
32
 
41
33
  setJwt(jwt?: string) {
@@ -54,22 +46,39 @@ export class HttpClient {
54
46
  private getAuthHeaders(path: string): Record<string, string> {
55
47
  const headers: Record<string, string> = {};
56
48
 
57
- // For database, pubsub, and proxy operations, ONLY use API key to avoid JWT user context
49
+ // For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
58
50
  // interfering with namespace-level authorization
59
51
  const isDbOperation = path.includes("/v1/rqlite/");
60
52
  const isPubSubOperation = path.includes("/v1/pubsub/");
61
53
  const isProxyOperation = path.includes("/v1/proxy/");
54
+ const isCacheOperation = path.includes("/v1/cache/");
55
+
56
+ // For auth operations, prefer API key over JWT to ensure proper authentication
57
+ const isAuthOperation = path.includes("/v1/auth/");
62
58
 
63
- if (isDbOperation || isPubSubOperation || isProxyOperation) {
64
- // For database/pubsub/proxy operations: use only API key (preferred for namespace operations)
59
+ if (
60
+ isDbOperation ||
61
+ isPubSubOperation ||
62
+ isProxyOperation ||
63
+ isCacheOperation
64
+ ) {
65
+ // For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations)
65
66
  if (this.apiKey) {
66
67
  headers["X-API-Key"] = this.apiKey;
67
68
  } else if (this.jwt) {
68
69
  // Fallback to JWT if no API key
69
70
  headers["Authorization"] = `Bearer ${this.jwt}`;
70
71
  }
72
+ } else if (isAuthOperation) {
73
+ // For auth operations: prefer API key over JWT (auth endpoints should use explicit API key)
74
+ if (this.apiKey) {
75
+ headers["X-API-Key"] = this.apiKey;
76
+ }
77
+ if (this.jwt) {
78
+ headers["Authorization"] = `Bearer ${this.jwt}`;
79
+ }
71
80
  } else {
72
- // For auth/other operations: send both JWT and API key
81
+ // For other operations: send both JWT and API key
73
82
  if (this.jwt) {
74
83
  headers["Authorization"] = `Bearer ${this.jwt}`;
75
84
  }
@@ -98,6 +107,7 @@ export class HttpClient {
98
107
  timeout?: number; // Per-request timeout override
99
108
  } = {}
100
109
  ): Promise<T> {
110
+ const startTime = performance.now(); // Track request start time
101
111
  const url = new URL(this.baseURL + path);
102
112
  if (options.query) {
103
113
  Object.entries(options.query).forEach(([key, value]) => {
@@ -111,27 +121,6 @@ export class HttpClient {
111
121
  ...options.headers,
112
122
  };
113
123
 
114
- // Debug: Log headers being sent
115
- if (
116
- typeof console !== "undefined" &&
117
- (path.includes("/db/") ||
118
- path.includes("/query") ||
119
- path.includes("/auth/") ||
120
- path.includes("/pubsub/") ||
121
- path.includes("/proxy/"))
122
- ) {
123
- console.log("[HttpClient] Request headers for", path, {
124
- hasAuth: !!headers["Authorization"],
125
- hasApiKey: !!headers["X-API-Key"],
126
- authPrefix: headers["Authorization"]
127
- ? headers["Authorization"].substring(0, 20)
128
- : "none",
129
- apiKeyPrefix: headers["X-API-Key"]
130
- ? headers["X-API-Key"].substring(0, 20)
131
- : "none",
132
- });
133
- }
134
-
135
124
  const controller = new AbortController();
136
125
  const requestTimeout = options.timeout ?? this.timeout; // Use override or default
137
126
  const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
@@ -147,7 +136,85 @@ export class HttpClient {
147
136
  }
148
137
 
149
138
  try {
150
- return await this.requestWithRetry(url.toString(), fetchOptions);
139
+ const result = await this.requestWithRetry(
140
+ url.toString(),
141
+ fetchOptions,
142
+ 0,
143
+ startTime
144
+ );
145
+ const duration = performance.now() - startTime;
146
+ if (typeof console !== "undefined") {
147
+ console.log(
148
+ `[HttpClient] ${method} ${path} completed in ${duration.toFixed(2)}ms`
149
+ );
150
+ }
151
+ return result;
152
+ } catch (error) {
153
+ const duration = performance.now() - startTime;
154
+ if (typeof console !== "undefined") {
155
+ // Cache "key not found" (404 or error message) is expected behavior - don't log as error
156
+ const isCacheGetNotFound =
157
+ path === "/v1/cache/get" &&
158
+ error instanceof SDKError &&
159
+ (error.httpStatus === 404 ||
160
+ (error.httpStatus === 500 &&
161
+ error.message?.toLowerCase().includes("key not found")));
162
+
163
+ // "Not found" (404) for blocked_users is expected behavior - don't log as error
164
+ // This happens when checking if users are blocked (most users aren't blocked)
165
+ const isBlockedUsersNotFound =
166
+ path === "/v1/rqlite/find-one" &&
167
+ error instanceof SDKError &&
168
+ error.httpStatus === 404 &&
169
+ options.body &&
170
+ (() => {
171
+ try {
172
+ const body =
173
+ typeof options.body === "string"
174
+ ? JSON.parse(options.body)
175
+ : options.body;
176
+ return body.table === "blocked_users";
177
+ } catch {
178
+ return false;
179
+ }
180
+ })();
181
+
182
+ // "Not found" (404) for conversation_participants is expected behavior - don't log as error
183
+ // This happens when checking if a user is a participant (e.g., on first group join)
184
+ const isConversationParticipantNotFound =
185
+ path === "/v1/rqlite/find-one" &&
186
+ error instanceof SDKError &&
187
+ error.httpStatus === 404 &&
188
+ options.body &&
189
+ (() => {
190
+ try {
191
+ const body =
192
+ typeof options.body === "string"
193
+ ? JSON.parse(options.body)
194
+ : options.body;
195
+ return body.table === "conversation_participants";
196
+ } catch {
197
+ return false;
198
+ }
199
+ })();
200
+
201
+ if (
202
+ isCacheGetNotFound ||
203
+ isBlockedUsersNotFound ||
204
+ isConversationParticipantNotFound
205
+ ) {
206
+ // Log cache miss, non-blocked status, or non-participant status as debug/info, not error
207
+ // These are expected behaviors
208
+ } else {
209
+ console.error(
210
+ `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
211
+ 2
212
+ )}ms:`,
213
+ error
214
+ );
215
+ }
216
+ }
217
+ throw error;
151
218
  } finally {
152
219
  clearTimeout(timeoutId);
153
220
  }
@@ -156,7 +223,8 @@ export class HttpClient {
156
223
  private async requestWithRetry(
157
224
  url: string,
158
225
  options: RequestInit,
159
- attempt: number = 0
226
+ attempt: number = 0,
227
+ startTime?: number // Track start time for timing across retries
160
228
  ): Promise<any> {
161
229
  try {
162
230
  const response = await this.fetch(url, options);
@@ -185,7 +253,7 @@ export class HttpClient {
185
253
  await new Promise((resolve) =>
186
254
  setTimeout(resolve, this.retryDelayMs * (attempt + 1))
187
255
  );
188
- return this.requestWithRetry(url, options, attempt + 1);
256
+ return this.requestWithRetry(url, options, attempt + 1, startTime);
189
257
  }
190
258
  throw error;
191
259
  }
@@ -221,6 +289,104 @@ export class HttpClient {
221
289
  return this.request<T>("DELETE", path, options);
222
290
  }
223
291
 
292
+ /**
293
+ * Upload a file using multipart/form-data
294
+ * This is a special method for file uploads that bypasses JSON serialization
295
+ */
296
+ async uploadFile<T = any>(
297
+ path: string,
298
+ formData: FormData,
299
+ options?: {
300
+ timeout?: number;
301
+ }
302
+ ): Promise<T> {
303
+ const startTime = performance.now(); // Track upload start time
304
+ const url = new URL(this.baseURL + path);
305
+ const headers: Record<string, string> = {
306
+ ...this.getAuthHeaders(path),
307
+ // Don't set Content-Type - browser will set it with boundary
308
+ };
309
+
310
+ const controller = new AbortController();
311
+ const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
312
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
313
+
314
+ const fetchOptions: RequestInit = {
315
+ method: "POST",
316
+ headers,
317
+ body: formData,
318
+ signal: controller.signal,
319
+ };
320
+
321
+ try {
322
+ const result = await this.requestWithRetry(
323
+ url.toString(),
324
+ fetchOptions,
325
+ 0,
326
+ startTime
327
+ );
328
+ const duration = performance.now() - startTime;
329
+ if (typeof console !== "undefined") {
330
+ console.log(
331
+ `[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
332
+ 2
333
+ )}ms`
334
+ );
335
+ }
336
+ return result;
337
+ } catch (error) {
338
+ const duration = performance.now() - startTime;
339
+ if (typeof console !== "undefined") {
340
+ console.error(
341
+ `[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
342
+ 2
343
+ )}ms:`,
344
+ error
345
+ );
346
+ }
347
+ throw error;
348
+ } finally {
349
+ clearTimeout(timeoutId);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get a binary response (returns Response object for streaming)
355
+ */
356
+ async getBinary(path: string): Promise<Response> {
357
+ const url = new URL(this.baseURL + path);
358
+ const headers: Record<string, string> = {
359
+ ...this.getAuthHeaders(path),
360
+ };
361
+
362
+ const controller = new AbortController();
363
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
364
+
365
+ const fetchOptions: RequestInit = {
366
+ method: "GET",
367
+ headers,
368
+ signal: controller.signal,
369
+ };
370
+
371
+ try {
372
+ const response = await this.fetch(url.toString(), fetchOptions);
373
+ if (!response.ok) {
374
+ clearTimeout(timeoutId);
375
+ const error = await response.json().catch(() => ({
376
+ error: response.statusText,
377
+ }));
378
+ throw SDKError.fromResponse(response.status, error);
379
+ }
380
+ return response;
381
+ } catch (error) {
382
+ clearTimeout(timeoutId);
383
+ if (error instanceof SDKError) {
384
+ throw error;
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+
224
390
  getToken(): string | undefined {
225
391
  return this.getAuthToken();
226
392
  }
@@ -98,7 +98,9 @@ export class Repository<T extends Record<string, any>> {
98
98
  private buildInsertSql(entity: T): string {
99
99
  const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
100
100
  const placeholders = columns.map(() => "?").join(", ");
101
- return `INSERT INTO ${this.tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
101
+ return `INSERT INTO ${this.tableName} (${columns.join(
102
+ ", "
103
+ )}) VALUES (${placeholders})`;
102
104
  }
103
105
 
104
106
  private buildInsertArgs(entity: T): any[] {
@@ -111,7 +113,9 @@ export class Repository<T extends Record<string, any>> {
111
113
  const columns = Object.keys(entity)
112
114
  .filter((k) => entity[k] !== undefined && k !== this.primaryKey)
113
115
  .map((k) => `${k} = ?`);
114
- return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${this.primaryKey} = ?`;
116
+ return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${
117
+ this.primaryKey
118
+ } = ?`;
115
119
  }
116
120
 
117
121
  private buildUpdateArgs(entity: T): any[] {
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import { AuthClient } from "./auth/client";
3
3
  import { DBClient } from "./db/client";
4
4
  import { PubSubClient } from "./pubsub/client";
5
5
  import { NetworkClient } from "./network/client";
6
+ import { CacheClient } from "./cache/client";
7
+ import { StorageClient } from "./storage/client";
6
8
  import { WSClientConfig } from "./core/ws";
7
9
  import {
8
10
  StorageAdapter,
@@ -23,6 +25,8 @@ export interface Client {
23
25
  db: DBClient;
24
26
  pubsub: PubSubClient;
25
27
  network: NetworkClient;
28
+ cache: CacheClient;
29
+ storage: StorageClient;
26
30
  }
27
31
 
28
32
  export function createClient(config: ClientConfig): Client {
@@ -52,16 +56,19 @@ export function createClient(config: ClientConfig): Client {
52
56
  wsURL,
53
57
  });
54
58
  const network = new NetworkClient(httpClient);
59
+ const cache = new CacheClient(httpClient);
60
+ const storage = new StorageClient(httpClient);
55
61
 
56
62
  return {
57
63
  auth,
58
64
  db,
59
65
  pubsub,
60
66
  network,
67
+ cache,
68
+ storage,
61
69
  };
62
70
  }
63
71
 
64
- // Re-exports
65
72
  export { HttpClient } from "./core/http";
66
73
  export { WSClient } from "./core/ws";
67
74
  export { AuthClient } from "./auth/client";
@@ -70,6 +77,8 @@ export { QueryBuilder } from "./db/qb";
70
77
  export { Repository } from "./db/repository";
71
78
  export { PubSubClient, Subscription } from "./pubsub/client";
72
79
  export { NetworkClient } from "./network/client";
80
+ export { CacheClient } from "./cache/client";
81
+ export { StorageClient } from "./storage/client";
73
82
  export { SDKError } from "./errors";
74
83
  export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
75
84
  export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types";
@@ -86,3 +95,22 @@ export type {
86
95
  ProxyRequest,
87
96
  ProxyResponse,
88
97
  } from "./network/client";
98
+ export type {
99
+ CacheGetRequest,
100
+ CacheGetResponse,
101
+ CachePutRequest,
102
+ CachePutResponse,
103
+ CacheDeleteRequest,
104
+ CacheDeleteResponse,
105
+ CacheMultiGetRequest,
106
+ CacheMultiGetResponse,
107
+ CacheScanRequest,
108
+ CacheScanResponse,
109
+ CacheHealthResponse,
110
+ } from "./cache/client";
111
+ export type {
112
+ StorageUploadResponse,
113
+ StoragePinRequest,
114
+ StoragePinResponse,
115
+ StorageStatus,
116
+ } from "./storage/client";
@@ -0,0 +1,270 @@
1
+ import { HttpClient } from "../core/http";
2
+
3
+ export interface StorageUploadResponse {
4
+ cid: string;
5
+ name: string;
6
+ size: number;
7
+ }
8
+
9
+ export interface StoragePinRequest {
10
+ cid: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface StoragePinResponse {
15
+ cid: string;
16
+ name: string;
17
+ }
18
+
19
+ export interface StorageStatus {
20
+ cid: string;
21
+ name: string;
22
+ status: string; // "pinned", "pinning", "queued", "unpinned", "error"
23
+ replication_min: number;
24
+ replication_max: number;
25
+ replication_factor: number;
26
+ peers: string[];
27
+ error?: string;
28
+ }
29
+
30
+ export class StorageClient {
31
+ private httpClient: HttpClient;
32
+
33
+ constructor(httpClient: HttpClient) {
34
+ this.httpClient = httpClient;
35
+ }
36
+
37
+ /**
38
+ * Upload content to IPFS and optionally pin it.
39
+ * Supports both File objects (browser) and Buffer/ReadableStream (Node.js).
40
+ *
41
+ * @param file - File to upload (File, Blob, or Buffer)
42
+ * @param name - Optional filename
43
+ * @param options - Optional upload options
44
+ * @param options.pin - Whether to pin the content (default: true). Pinning happens asynchronously on the backend.
45
+ * @returns Upload result with CID
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Browser
50
+ * const fileInput = document.querySelector('input[type="file"]');
51
+ * const file = fileInput.files[0];
52
+ * const result = await client.storage.upload(file, file.name);
53
+ * console.log(result.cid);
54
+ *
55
+ * // Node.js
56
+ * const fs = require('fs');
57
+ * const fileBuffer = fs.readFileSync('image.jpg');
58
+ * const result = await client.storage.upload(fileBuffer, 'image.jpg', { pin: true });
59
+ * ```
60
+ */
61
+ async upload(
62
+ file: File | Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>,
63
+ name?: string,
64
+ options?: {
65
+ pin?: boolean;
66
+ }
67
+ ): Promise<StorageUploadResponse> {
68
+ // Create FormData for multipart upload
69
+ const formData = new FormData();
70
+
71
+ // Handle different input types
72
+ if (file instanceof File) {
73
+ formData.append("file", file);
74
+ } else if (file instanceof Blob) {
75
+ formData.append("file", file, name);
76
+ } else if (file instanceof ArrayBuffer) {
77
+ const blob = new Blob([file]);
78
+ formData.append("file", blob, name);
79
+ } else if (file instanceof Uint8Array) {
80
+ // Convert Uint8Array to ArrayBuffer for Blob constructor
81
+ const buffer = file.buffer.slice(
82
+ file.byteOffset,
83
+ file.byteOffset + file.byteLength
84
+ ) as ArrayBuffer;
85
+ const blob = new Blob([buffer], { type: "application/octet-stream" });
86
+ formData.append("file", blob, name);
87
+ } else if (file instanceof ReadableStream) {
88
+ // For ReadableStream, we need to read it into a blob first
89
+ // This is a limitation - in practice, pass File/Blob/Buffer
90
+ const chunks: ArrayBuffer[] = [];
91
+ const reader = file.getReader();
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ const buffer = value.buffer.slice(
96
+ value.byteOffset,
97
+ value.byteOffset + value.byteLength
98
+ ) as ArrayBuffer;
99
+ chunks.push(buffer);
100
+ }
101
+ const blob = new Blob(chunks);
102
+ formData.append("file", blob, name);
103
+ } else {
104
+ throw new Error(
105
+ "Unsupported file type. Use File, Blob, ArrayBuffer, Uint8Array, or ReadableStream."
106
+ );
107
+ }
108
+
109
+ // Add pin flag (default: true)
110
+ const shouldPin = options?.pin !== false; // Default to true
111
+ formData.append("pin", shouldPin ? "true" : "false");
112
+
113
+ return this.httpClient.uploadFile<StorageUploadResponse>(
114
+ "/v1/storage/upload",
115
+ formData,
116
+ { timeout: 300000 } // 5 minute timeout for large files
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Pin an existing CID
122
+ *
123
+ * @param cid - Content ID to pin
124
+ * @param name - Optional name for the pin
125
+ * @returns Pin result
126
+ */
127
+ async pin(cid: string, name?: string): Promise<StoragePinResponse> {
128
+ return this.httpClient.post<StoragePinResponse>("/v1/storage/pin", {
129
+ cid,
130
+ name,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Get the pin status for a CID
136
+ *
137
+ * @param cid - Content ID to check
138
+ * @returns Pin status information
139
+ */
140
+ async status(cid: string): Promise<StorageStatus> {
141
+ return this.httpClient.get<StorageStatus>(`/v1/storage/status/${cid}`);
142
+ }
143
+
144
+ /**
145
+ * Retrieve content from IPFS by CID
146
+ *
147
+ * @param cid - Content ID to retrieve
148
+ * @returns ReadableStream of the content
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * const stream = await client.storage.get(cid);
153
+ * const reader = stream.getReader();
154
+ * while (true) {
155
+ * const { done, value } = await reader.read();
156
+ * if (done) break;
157
+ * // Process chunk
158
+ * }
159
+ * ```
160
+ */
161
+ async get(cid: string): Promise<ReadableStream<Uint8Array>> {
162
+ // Retry logic for content retrieval - content may not be immediately available
163
+ // after upload due to eventual consistency in IPFS Cluster
164
+ // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
165
+ const maxAttempts = 8;
166
+ let lastError: Error | null = null;
167
+
168
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
169
+ try {
170
+ const response = await this.httpClient.getBinary(
171
+ `/v1/storage/get/${cid}`
172
+ );
173
+
174
+ if (!response.body) {
175
+ throw new Error("Response body is null");
176
+ }
177
+
178
+ return response.body;
179
+ } catch (error: any) {
180
+ lastError = error;
181
+
182
+ // Check if this is a 404 error (content not found)
183
+ const isNotFound =
184
+ error?.httpStatus === 404 ||
185
+ error?.message?.includes("not found") ||
186
+ error?.message?.includes("404");
187
+
188
+ // If it's not a 404 error, or this is the last attempt, give up
189
+ if (!isNotFound || attempt === maxAttempts) {
190
+ throw error;
191
+ }
192
+
193
+ // Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
194
+ // This gives up to ~12 seconds total wait time, covering typical pin completion
195
+ const backoffMs = attempt * 2500;
196
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
197
+ }
198
+ }
199
+
200
+ // This should never be reached, but TypeScript needs it
201
+ throw lastError || new Error("Failed to retrieve content");
202
+ }
203
+
204
+ /**
205
+ * Retrieve content from IPFS by CID and return the full Response object
206
+ * Useful when you need access to response headers (e.g., content-length)
207
+ *
208
+ * @param cid - Content ID to retrieve
209
+ * @returns Response object with body stream and headers
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * const response = await client.storage.getBinary(cid);
214
+ * const contentLength = response.headers.get('content-length');
215
+ * const reader = response.body.getReader();
216
+ * // ... read stream
217
+ * ```
218
+ */
219
+ async getBinary(cid: string): Promise<Response> {
220
+ // Retry logic for content retrieval - content may not be immediately available
221
+ // after upload due to eventual consistency in IPFS Cluster
222
+ // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
223
+ const maxAttempts = 8;
224
+ let lastError: Error | null = null;
225
+
226
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
227
+ try {
228
+ const response = await this.httpClient.getBinary(
229
+ `/v1/storage/get/${cid}`
230
+ );
231
+
232
+ if (!response) {
233
+ throw new Error("Response is null");
234
+ }
235
+
236
+ return response;
237
+ } catch (error: any) {
238
+ lastError = error;
239
+
240
+ // Check if this is a 404 error (content not found)
241
+ const isNotFound =
242
+ error?.httpStatus === 404 ||
243
+ error?.message?.includes("not found") ||
244
+ error?.message?.includes("404");
245
+
246
+ // If it's not a 404 error, or this is the last attempt, give up
247
+ if (!isNotFound || attempt === maxAttempts) {
248
+ throw error;
249
+ }
250
+
251
+ // Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
252
+ // This gives up to ~12 seconds total wait time, covering typical pin completion
253
+ const backoffMs = attempt * 2500;
254
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
255
+ }
256
+ }
257
+
258
+ // This should never be reached, but TypeScript needs it
259
+ throw lastError || new Error("Failed to retrieve content");
260
+ }
261
+
262
+ /**
263
+ * Unpin a CID
264
+ *
265
+ * @param cid - Content ID to unpin
266
+ */
267
+ async unpin(cid: string): Promise<void> {
268
+ await this.httpClient.delete(`/v1/storage/unpin/${cid}`);
269
+ }
270
+ }