@onchaindb/sdk 0.4.0 → 0.4.2

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.
Files changed (98) hide show
  1. package/.DS_Store +0 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/.gitignore +5 -0
  4. package/.idea/.gitignore +5 -0
  5. package/.idea/compiler.xml +6 -0
  6. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  7. package/.idea/jsLinters/eslint.xml +6 -0
  8. package/.idea/modules.xml +8 -0
  9. package/.idea/prettier.xml +7 -0
  10. package/.idea/sdk.iml +12 -0
  11. package/.idea/vcs.xml +6 -0
  12. package/.idea/workspace.xml +257 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +11 -3
  15. package/dist/client.js.map +1 -1
  16. package/dist/database.d.ts +0 -20
  17. package/dist/database.d.ts.map +1 -1
  18. package/dist/database.js +0 -40
  19. package/dist/database.js.map +1 -1
  20. package/dist/query-sdk/tests/setup.d.ts +16 -0
  21. package/dist/query-sdk/tests/setup.d.ts.map +1 -0
  22. package/dist/query-sdk/tests/setup.js +49 -0
  23. package/dist/query-sdk/tests/setup.js.map +1 -0
  24. package/examples/basic-usage.ts +136 -0
  25. package/examples/blob-upload-example.ts +140 -0
  26. package/examples/collection-schema-example.ts +304 -0
  27. package/examples/server-side-joins.ts +201 -0
  28. package/examples/tweet-self-joins-example.ts +352 -0
  29. package/package-lock.json +3823 -0
  30. package/package.json +1 -1
  31. package/skills.md +1096 -0
  32. package/src/.env +1 -0
  33. package/src/batch.d.ts +121 -0
  34. package/src/batch.js +205 -0
  35. package/src/batch.ts +257 -0
  36. package/src/client.ts +1856 -0
  37. package/src/database.d.ts +268 -0
  38. package/src/database.js +294 -0
  39. package/src/database.ts +695 -0
  40. package/src/index.d.ts +160 -0
  41. package/src/index.js +186 -0
  42. package/src/index.ts +253 -0
  43. package/src/query-sdk/ConditionBuilder.ts +103 -0
  44. package/src/query-sdk/FieldConditionBuilder.ts +2 -0
  45. package/src/query-sdk/NestedBuilders.ts +186 -0
  46. package/src/query-sdk/OnChainDB.ts +294 -0
  47. package/src/query-sdk/QueryBuilder.ts +1191 -0
  48. package/src/query-sdk/QueryResult.ts +375 -0
  49. package/src/query-sdk/README.md +866 -0
  50. package/src/query-sdk/SelectionBuilder.ts +94 -0
  51. package/src/query-sdk/adapters/HttpClientAdapter.ts +249 -0
  52. package/src/query-sdk/dist/ConditionBuilder.d.ts +22 -0
  53. package/src/query-sdk/dist/ConditionBuilder.js +90 -0
  54. package/src/query-sdk/dist/FieldConditionBuilder.d.ts +1 -0
  55. package/src/query-sdk/dist/FieldConditionBuilder.js +6 -0
  56. package/src/query-sdk/dist/NestedBuilders.d.ts +43 -0
  57. package/src/query-sdk/dist/NestedBuilders.js +144 -0
  58. package/src/query-sdk/dist/OnChainDB.d.ts +19 -0
  59. package/src/query-sdk/dist/OnChainDB.js +123 -0
  60. package/src/query-sdk/dist/QueryBuilder.d.ts +70 -0
  61. package/src/query-sdk/dist/QueryBuilder.js +295 -0
  62. package/src/query-sdk/dist/QueryResult.d.ts +52 -0
  63. package/src/query-sdk/dist/QueryResult.js +293 -0
  64. package/src/query-sdk/dist/SelectionBuilder.d.ts +20 -0
  65. package/src/query-sdk/dist/SelectionBuilder.js +80 -0
  66. package/src/query-sdk/dist/adapters/HttpClientAdapter.d.ts +27 -0
  67. package/src/query-sdk/dist/adapters/HttpClientAdapter.js +170 -0
  68. package/src/query-sdk/dist/index.d.ts +36 -0
  69. package/src/query-sdk/dist/index.js +27 -0
  70. package/src/query-sdk/dist/operators.d.ts +56 -0
  71. package/src/query-sdk/dist/operators.js +289 -0
  72. package/src/query-sdk/dist/tests/setup.d.ts +15 -0
  73. package/src/query-sdk/dist/tests/setup.js +46 -0
  74. package/src/query-sdk/index.ts +59 -0
  75. package/src/query-sdk/jest.config.js +25 -0
  76. package/src/query-sdk/operators.ts +335 -0
  77. package/src/query-sdk/package.json +46 -0
  78. package/src/query-sdk/tests/FieldConditionBuilder.test.ts +84 -0
  79. package/src/query-sdk/tests/LogicalOperator.test.ts +85 -0
  80. package/src/query-sdk/tests/NestedBuilders.test.ts +321 -0
  81. package/src/query-sdk/tests/QueryBuilder.test.ts +348 -0
  82. package/src/query-sdk/tests/QueryResult.test.ts +464 -0
  83. package/src/query-sdk/tests/aggregations.test.ts +653 -0
  84. package/src/query-sdk/tests/comprehensive.test.ts +279 -0
  85. package/src/query-sdk/tests/integration.test.ts +608 -0
  86. package/src/query-sdk/tests/operators.test.ts +327 -0
  87. package/src/query-sdk/tests/setup.ts +59 -0
  88. package/src/query-sdk/tests/unit.test.ts +794 -0
  89. package/src/query-sdk/tsconfig.json +26 -0
  90. package/src/query-sdk/yarn.lock +3092 -0
  91. package/src/types.d.ts +131 -0
  92. package/src/types.js +46 -0
  93. package/src/types.ts +534 -0
  94. package/src/x402/index.ts +12 -0
  95. package/src/x402/types.ts +250 -0
  96. package/src/x402/utils.ts +332 -0
  97. package/tsconfig.json +20 -0
  98. package/yarn.lock +2309 -0
package/src/client.ts ADDED
@@ -0,0 +1,1856 @@
1
+ import axios, {AxiosError, AxiosInstance} from 'axios';
2
+ import {EventEmitter} from 'eventemitter3';
3
+ import {
4
+ BlobMetadata,
5
+ CreateCollectionResult,
6
+ IndexRequest,
7
+ IndexResponse,
8
+ OnChainDBConfig,
9
+ OnChainDBError,
10
+ PricingQuoteRequest,
11
+ PricingQuoteResponse,
12
+ RelationRequest,
13
+ RelationResponse,
14
+ RetrieveBlobRequest,
15
+ SimpleCollectionSchema,
16
+ SimpleFieldDefinition,
17
+ StoreRequest,
18
+ StoreResponse,
19
+ SyncCollectionResult,
20
+ TaskInfo,
21
+ TransactionError,
22
+ TransactionEvents,
23
+ UploadBlobRequest,
24
+ UploadBlobResponse,
25
+ ValidationError
26
+ } from './types';
27
+
28
+ // Import query builder components
29
+ // Import database management
30
+ import {createDatabaseManager, DatabaseManager} from './database';
31
+ import {LogicalOperator, QueryBuilder, QueryResponse} from "./query-sdk";
32
+
33
+ // Import x402 utilities
34
+ import {
35
+ buildFacilitatorPaymentPayload,
36
+ buildPaymentPayload,
37
+ encodePaymentHeader,
38
+ isFacilitatorPaymentResult,
39
+ parseX402Response,
40
+ requirementToQuote,
41
+ selectPaymentOption,
42
+ X402FacilitatorPaymentResult,
43
+ X402PaymentCallbackResult,
44
+ X402PaymentResult,
45
+ X402Quote,
46
+ } from './x402';
47
+
48
+ /**
49
+ * OnChainDB TypeScript SDK
50
+ *
51
+ * Provides a complete interface for storing and querying data on OnChainDB,
52
+ * built on Celestia blockchain with automatic transaction management.
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * // Initialize with app key (for writes) and user key (for Auto-Pay)
57
+ * const db = new OnChainDBClient({
58
+ * endpoint: 'http://localhost:9092',
59
+ * appId: 'app_abc123',
60
+ * appKey: 'app_xxx...', // Required for write operations
61
+ * userKey: 'user_yyy...' // Optional: enables Auto-Pay for reads/writes
62
+ * });
63
+ *
64
+ * // Store data (requires appKey)
65
+ * const result = await db.store({
66
+ * data: [{ message: 'Hello OnChainDB!', user: 'alice' }],
67
+ * collection: 'messages'
68
+ * });
69
+ *
70
+ * // Query data (userKey enables Auto-Pay if authz granted)
71
+ * const messages = await db.query({ collection: 'messages' });
72
+ *
73
+ * // Backwards compatible: legacy apiKey maps to appKey
74
+ * const legacyDb = new OnChainDBClient({
75
+ * endpoint: 'http://localhost:9092',
76
+ * apiKey: 'app_xxx...' // Still works, maps to appKey
77
+ * });
78
+ * ```
79
+ */
80
+ export class OnChainDBClient extends EventEmitter<TransactionEvents> {
81
+ /**
82
+ * Base fields that are automatically indexed when useBaseFields is true
83
+ */
84
+ private static readonly BASE_FIELDS: Record<string, SimpleFieldDefinition> = {
85
+ id: {type: 'string', index: true, unique: true},
86
+ createdAt: {type: 'date', index: true},
87
+ updatedAt: {type: 'date', index: true},
88
+ deletedAt: {type: 'date', index: true}
89
+ };
90
+ private http: AxiosInstance;
91
+ private config: Required<Omit<OnChainDBConfig, 'appId'>> & { appId?: string; appKey?: string; userKey?: string };
92
+ private _database?: DatabaseManager;
93
+
94
+ constructor(config: OnChainDBConfig) {
95
+ super();
96
+
97
+ // Support legacy apiKey for backwards compatibility (maps to appKey)
98
+ const appKey = config.appKey || '';
99
+ const userKey = config.userKey || '';
100
+
101
+ this.config = {
102
+ endpoint: config.endpoint,
103
+ apiKey: appKey, // Keep for backwards compat, but maps to appKey
104
+ appKey: appKey,
105
+ userKey: userKey,
106
+ appId: config.appId || undefined,
107
+ timeout: config.timeout || 30000,
108
+ retryCount: config.retryCount || 3,
109
+ retryDelay: config.retryDelay || 1000
110
+ };
111
+
112
+ // Build headers with both keys if provided
113
+ const headers: Record<string, string> = {
114
+ 'Content-Type': 'application/json'
115
+ };
116
+
117
+ if (appKey) {
118
+ headers['X-App-Key'] = appKey;
119
+ }
120
+
121
+ if (config.apiKey) {
122
+ headers['X-Api-Key'] = config.apiKey;
123
+ }
124
+
125
+
126
+ if (userKey) {
127
+ headers['X-User-Key'] = userKey;
128
+ }
129
+
130
+ this.http = axios.create({
131
+ baseURL: this.config.endpoint,
132
+ timeout: this.config.timeout,
133
+ headers
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Get database manager instance for collection and index management
139
+ *
140
+ * @param appId - Application ID for database operations
141
+ * @returns DatabaseManager instance
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * const db = client.database('app_12345');
146
+ *
147
+ * // Create collection with schema
148
+ * await db.createCollection('users', {
149
+ * fields: {
150
+ * name: { type: 'string', required: true },
151
+ * email: { type: 'string', unique: true }
152
+ * }
153
+ * });
154
+ *
155
+ * // Create index
156
+ * await db.createIndex({
157
+ * name: 'users_email_index',
158
+ * collection: 'users',
159
+ * field_name: 'email',
160
+ * index_type: 'btree',
161
+ * options: { unique: true }
162
+ * });
163
+ * ```
164
+ */
165
+ database(appId: string): DatabaseManager {
166
+ if (!this._database || this._database['appId'] !== appId) {
167
+ this._database = createDatabaseManager(
168
+ this.http,
169
+ this.config.endpoint,
170
+ appId,
171
+ this.config.apiKey
172
+ );
173
+ }
174
+ return this._database;
175
+ }
176
+
177
+ /**
178
+ * Handle 402 Payment Required response (x402 protocol)
179
+ *
180
+ * Supports two payment flows:
181
+ * 1. Celestia native: User broadcasts tx, returns txHash
182
+ * 2. Facilitator (EVM/Solana): User signs authorization, server sends to facilitator
183
+ *
184
+ * @param response - The 402 response from the server
185
+ * @param paymentCallback - Callback to handle payment (user signs tx or authorization)
186
+ * @param finalRequest - The original request to retry after payment
187
+ * @param waitForConfirmation - Whether to wait for blockchain confirmation
188
+ * @returns Promise resolving to the operation result
189
+ */
190
+ async handleX402(
191
+ response: { data: any },
192
+ paymentCallback: ((quote: X402Quote) => Promise<X402PaymentCallbackResult>) | undefined,
193
+ finalRequest: any,
194
+ waitForConfirmation: boolean
195
+ ): Promise<any> {
196
+ console.log('[x402] Received 402 Payment Required');
197
+
198
+ // Parse x402 response
199
+ let x402Response;
200
+ try {
201
+ x402Response = parseX402Response(response.data);
202
+ } catch (e) {
203
+ throw new OnChainDBError(
204
+ `Invalid x402 response: ${e instanceof Error ? e.message : String(e)}`,
205
+ 'PAYMENT_ERROR',
206
+ 402
207
+ );
208
+ }
209
+
210
+ // Select payment option (default to first/native)
211
+ const requirement = selectPaymentOption(x402Response.accepts);
212
+
213
+ // Convert to quote format for callback
214
+ const quote = requirementToQuote(requirement, x402Response.accepts);
215
+
216
+ console.log('[x402] Quote:', {
217
+ quoteId: quote.quoteId,
218
+ amount: quote.amountRaw,
219
+ token: quote.tokenSymbol,
220
+ network: quote.network,
221
+ chainType: quote.chainType,
222
+ paymentMethod: quote.paymentMethod,
223
+ });
224
+
225
+ // Call payment callback if provided
226
+ if (!paymentCallback) {
227
+ throw new OnChainDBError(
228
+ 'Payment required but no payment callback provided. Please provide a payment callback to handle x402.',
229
+ 'PAYMENT_REQUIRED',
230
+ 402,
231
+ quote
232
+ );
233
+ }
234
+
235
+ console.log('[x402] Calling payment callback...');
236
+ const payment = await paymentCallback(quote);
237
+
238
+ // Build x402 payment payload based on payment type
239
+ let x402Payload;
240
+ if (isFacilitatorPaymentResult(payment)) {
241
+ console.log('[x402] Facilitator payment - sending authorization to server');
242
+ const facilitatorPayment = payment as X402FacilitatorPaymentResult;
243
+
244
+ // IMPORTANT: Find the requirement that matches the payment result's network
245
+ // The payment callback may have selected a different network than the default
246
+ const selectedRequirement = x402Response.accepts.find(
247
+ (req) => req.network === facilitatorPayment.network
248
+ );
249
+
250
+ if (!selectedRequirement) {
251
+ throw new OnChainDBError(
252
+ `Payment callback returned network '${facilitatorPayment.network}' but no matching payment option found`,
253
+ 'PAYMENT_ERROR'
254
+ );
255
+ }
256
+
257
+ console.log('[x402] Using payment option for network:', selectedRequirement.network);
258
+
259
+ if (facilitatorPayment.evmAuthorization) {
260
+ x402Payload = buildFacilitatorPaymentPayload(selectedRequirement, {
261
+ type: 'evm',
262
+ signature: facilitatorPayment.evmAuthorization.signature,
263
+ authorization: facilitatorPayment.evmAuthorization.authorization,
264
+ });
265
+ } else if (facilitatorPayment.solanaAuthorization) {
266
+ x402Payload = buildFacilitatorPaymentPayload(selectedRequirement, {
267
+ type: 'solana',
268
+ transaction: facilitatorPayment.solanaAuthorization.transaction,
269
+ });
270
+ } else {
271
+ throw new OnChainDBError(
272
+ 'Facilitator payment missing authorization data',
273
+ 'PAYMENT_ERROR'
274
+ );
275
+ }
276
+ } else {
277
+ console.log('[x402] Native payment - tx hash:', payment.txHash);
278
+ x402Payload = buildPaymentPayload(requirement, payment);
279
+ }
280
+
281
+ const encodedPayment = encodePaymentHeader(x402Payload);
282
+
283
+ console.log('[x402] Retrying with X-PAYMENT header...');
284
+
285
+ const retryResponse = await this.http.post('/store', finalRequest, {
286
+ headers: {
287
+ 'X-PAYMENT': encodedPayment
288
+ }
289
+ });
290
+
291
+ console.log('[x402] Server response after payment:', retryResponse.data);
292
+ const serverResult = retryResponse.data;
293
+
294
+ // Continue with ticket polling
295
+ if (serverResult.ticket_id) {
296
+ if (waitForConfirmation) {
297
+ const taskInfo = await this.waitForTaskCompletion(serverResult.ticket_id);
298
+ // Extract result from task
299
+ if (taskInfo.result && taskInfo.result.results && taskInfo.result.results.length > 0) {
300
+ const firstResult = taskInfo.result.results[0];
301
+ return {
302
+ id: firstResult.id,
303
+ block_height: firstResult.celestia_height || 0,
304
+ transaction_hash: firstResult.blob_id || '',
305
+ celestia_height: firstResult.celestia_height || 0,
306
+ namespace: firstResult.namespace || '',
307
+ confirmed: firstResult.celestia_height > 0
308
+ };
309
+ }
310
+ } else {
311
+ // Return ticket without waiting
312
+ return {
313
+ id: serverResult.ticket_id,
314
+ block_height: 0,
315
+ transaction_hash: '',
316
+ celestia_height: 0,
317
+ namespace: '',
318
+ confirmed: false,
319
+ ticket_id: serverResult.ticket_id
320
+ } as StoreResponse;
321
+ }
322
+ }
323
+
324
+ throw new OnChainDBError('No ticket_id in response after payment', 'STORE_ERROR');
325
+ }
326
+
327
+ /**
328
+ * Store data on OnChainDB using the new root-based API with broker payment
329
+ *
330
+ * @param request - Store request with root (app::collection) and data array
331
+ * @param paymentOptions - Payment configuration for broker fees
332
+ * @param waitForConfirmation - Whether to wait for blockchain confirmation (default: true)
333
+ * @returns Promise resolving to store response with transaction details
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * // With payment (user pays broker fees)
338
+ * const result = await db.store({
339
+ * root: "twitter_app::tweets",
340
+ * data: [{ content: 'Hello world!', author: 'alice' }]
341
+ * }, {
342
+ * userWallet: keplrWallet,
343
+ * brokerAddress: 'celestia1xyz...'
344
+ * });
345
+ *
346
+ * // Direct submission (broker pays - for backwards compatibility)
347
+ * const result = await db.store({
348
+ * root: "system_logs",
349
+ * data: [{ level: 'info', message: 'App started' }]
350
+ * });
351
+ * ```
352
+ */
353
+ async store(
354
+ request: StoreRequest,
355
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>,
356
+ waitForConfirmation: boolean = true
357
+ ): Promise<StoreResponse> {
358
+ this.validateStoreRequest(request);
359
+ const resolvedRequest = {
360
+ ...request,
361
+ root: this.resolveRoot(request)
362
+ };
363
+ try {
364
+ // Build the actual request with resolved root
365
+
366
+
367
+ // Remove collection from the request since we now have root
368
+ delete resolvedRequest.collection;
369
+
370
+
371
+ // Step 1: Try to store without payment (may get 402)
372
+ const response = await this.http.post('/store', resolvedRequest);
373
+
374
+ // Step 2: Handle 402 Payment Required (x402)
375
+ if (response.status === 402 && response.data) {
376
+ const v = await this.handleX402(response, paymentCallback, resolvedRequest, waitForConfirmation);
377
+
378
+ if (v) {
379
+ return v;
380
+ }
381
+ }
382
+
383
+ // Step 5: No payment required or legacy flow
384
+ console.log('Server response:', response.data);
385
+ const serverResult = response.data;
386
+
387
+ // Check if we got an async response with ticket_id (new flow)
388
+ if (serverResult.ticket_id) {
389
+ console.log(`🎫 Got ticket ${serverResult.ticket_id}, polling for completion...`);
390
+
391
+ // Emit ticket received event
392
+ this.emit('transaction:queued', {
393
+ ticket_id: serverResult.ticket_id,
394
+ status: serverResult.status,
395
+ message: serverResult.message
396
+ });
397
+
398
+ // Poll for task completion if requested
399
+ if (waitForConfirmation) {
400
+ const taskInfo = await this.waitForTaskCompletion(serverResult.ticket_id);
401
+
402
+ // Extract the actual storage result from the completed task
403
+ if (taskInfo.result && taskInfo.result.results && taskInfo.result.results.length > 0) {
404
+ const firstResult = taskInfo.result.results[0];
405
+
406
+ // Transform to SDK format
407
+ const result: StoreResponse = {
408
+ id: firstResult.id,
409
+ block_height: firstResult.celestia_height || 0,
410
+ transaction_hash: firstResult.blob_id || '',
411
+ celestia_height: firstResult.celestia_height || 0,
412
+ namespace: firstResult.namespace || '',
413
+ confirmed: firstResult.celestia_height > 0
414
+ };
415
+
416
+ // Emit completion event
417
+ this.emit('transaction:confirmed', {
418
+ id: result.id,
419
+ status: 'confirmed',
420
+ block_height: result.block_height,
421
+ transaction_hash: result.transaction_hash,
422
+ celestia_height: result.celestia_height
423
+ });
424
+
425
+ return result;
426
+ } else {
427
+ throw new OnChainDBError('Task completed but no storage results found', 'STORE_ERROR');
428
+ }
429
+ } else {
430
+ // Return ticket info without waiting
431
+ return {
432
+ id: serverResult.ticket_id,
433
+ block_height: 0,
434
+ transaction_hash: '',
435
+ celestia_height: 0,
436
+ namespace: '',
437
+ confirmed: false,
438
+ ticket_id: serverResult.ticket_id
439
+ } as StoreResponse;
440
+ }
441
+ }
442
+
443
+ // Legacy response format (if server returns old format)
444
+ const firstResult = serverResult.results && serverResult.results[0];
445
+ if (!firstResult) {
446
+ throw new OnChainDBError('No results returned from server', 'STORE_ERROR');
447
+ }
448
+
449
+ // Transform server response to SDK format
450
+ const result: StoreResponse = {
451
+ id: firstResult.id,
452
+ block_height: firstResult.celestia_height || 0,
453
+ transaction_hash: firstResult.blob_id || '',
454
+ celestia_height: firstResult.celestia_height || 0,
455
+ namespace: firstResult.namespace || '',
456
+ confirmed: firstResult.celestia_height > 0
457
+ };
458
+
459
+ // Emit appropriate event based on height
460
+ if (result.block_height === 0) {
461
+ this.emit('transaction:pending', {
462
+ id: result.id,
463
+ status: 'pending',
464
+ block_height: result.block_height,
465
+ transaction_hash: result.transaction_hash
466
+ });
467
+ } else {
468
+ this.emit('transaction:confirmed', {
469
+ id: result.id,
470
+ status: 'confirmed',
471
+ block_height: result.block_height,
472
+ transaction_hash: result.transaction_hash,
473
+ celestia_height: result.celestia_height
474
+ });
475
+ }
476
+
477
+ console.log('Transaction result:', result);
478
+ if (waitForConfirmation && result.block_height === 0) {
479
+ return await this.waitForConfirmation(result.transaction_hash);
480
+ }
481
+
482
+ return result;
483
+ } catch (error) {
484
+ if ((error as AxiosError)?.response) {
485
+ const err = error as AxiosError;
486
+ if (err.response?.status === 402 && err?.response?.data) {
487
+ const v = await this.handleX402(err.response, paymentCallback, resolvedRequest, waitForConfirmation);
488
+
489
+ if (v) {
490
+ return v;
491
+ }
492
+ }
493
+ }
494
+
495
+ const dbError = error instanceof OnChainDBError ? error :
496
+ new OnChainDBError('Failed to store data', 'STORE_ERROR');
497
+ this.emit('error', dbError);
498
+ throw dbError;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Store data and return a promise that resolves when transaction is confirmed
504
+ *
505
+ * @param request - Store request
506
+ * @param paymentOptions - Payment configuration for broker fees
507
+ * @returns Promise resolving when transaction is confirmed on blockchain
508
+ */
509
+ async storeAndConfirm(
510
+ request: StoreRequest
511
+ ): Promise<StoreResponse> {
512
+ return this.store(request, undefined, true);
513
+ }
514
+
515
+ /**
516
+ * Wait for transaction confirmation
517
+ *
518
+ * @param transactionHash - Transaction hash to monitor
519
+ * @param maxWaitTime - Maximum wait time in milliseconds (default: 5 minutes)
520
+ * @returns Promise resolving to confirmed transaction
521
+ */
522
+ async waitForConfirmation(transactionHash: string, maxWaitTime: number = 300000): Promise<StoreResponse> {
523
+ const startTime = Date.now();
524
+ const pollInterval = 3000; // Poll every 3 seconds (same as server)
525
+
526
+ console.log(`🔄 Waiting for transaction ${transactionHash} confirmation...`);
527
+
528
+ while (Date.now() - startTime < maxWaitTime) {
529
+ try {
530
+ // Query Celestia RPC directly to check transaction status
531
+ const rpcUrl = 'https://celestia-mocha-rpc.publicnode.com:443'; // Default testnet RPC
532
+ const txUrl = `${rpcUrl}/tx?hash=0x${transactionHash}`;
533
+
534
+ console.log(`🔍 Checking transaction status: attempt ${Math.floor((Date.now() - startTime) / pollInterval) + 1}`);
535
+
536
+ const response = await axios.get(txUrl);
537
+
538
+ if (response.data?.result && response.data.result !== null) {
539
+ const txResult = response.data.result;
540
+ const height = parseInt(txResult.height);
541
+
542
+ if (height > 0) {
543
+ console.log(`✅ Transaction ${transactionHash} confirmed at height ${height}`);
544
+
545
+ const confirmedTx: StoreResponse = {
546
+ id: transactionHash, // Use transaction hash as ID for confirmation
547
+ namespace: '', // Will be filled by actual usage
548
+ block_height: height,
549
+ transaction_hash: transactionHash,
550
+ celestia_height: height,
551
+ confirmed: true
552
+ };
553
+
554
+ this.emit('transaction:confirmed', {
555
+ id: transactionHash,
556
+ status: 'confirmed',
557
+ block_height: height,
558
+ transaction_hash: transactionHash
559
+ });
560
+ return confirmedTx;
561
+ }
562
+ }
563
+
564
+ // Still pending, wait and retry
565
+ console.log(`⏳ Transaction still pending, waiting ${pollInterval}ms...`);
566
+ this.emit('transaction:pending', {
567
+ id: transactionHash,
568
+ status: 'pending',
569
+ block_height: 0,
570
+ transaction_hash: transactionHash
571
+ });
572
+ await this.sleep(pollInterval);
573
+
574
+ } catch (error) {
575
+ // For 404 or other errors, the transaction might not be confirmed yet
576
+ if (Date.now() - startTime >= maxWaitTime) {
577
+ throw new TransactionError(
578
+ `Transaction confirmation timeout after ${maxWaitTime}ms`,
579
+ transactionHash
580
+ );
581
+ }
582
+
583
+ // Wait and retry for temporary errors
584
+ console.log(`⚠️ Error checking transaction (will retry): ${error}`);
585
+ await this.sleep(pollInterval);
586
+ }
587
+ }
588
+
589
+ throw new TransactionError(
590
+ `Transaction confirmation timeout after ${maxWaitTime}ms`,
591
+ transactionHash
592
+ );
593
+ }
594
+
595
+ /**
596
+ * Create an index on a collection field
597
+ *
598
+ * @param request - Index creation request
599
+ * @returns Index creation response
600
+ */
601
+ async createIndex(request: IndexRequest): Promise<IndexResponse> {
602
+ try {
603
+ // Use app-specific index creation endpoint
604
+ const appId = this.config.appId || 'default';
605
+ const response = await this.http.post<IndexResponse>(`/api/apps/${appId}/indexes`, request);
606
+ return response.data;
607
+ } catch (error) {
608
+ throw error instanceof OnChainDBError ? error :
609
+ new OnChainDBError('Failed to create index', 'INDEX_ERROR');
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Create a collection with schema-defined indexes
615
+ *
616
+ * This is the recommended way to set up a new collection. It creates all
617
+ * necessary indexes in a single call, including base fields if enabled.
618
+ *
619
+ * @param schema - Collection schema definition
620
+ * @returns Result with created indexes and any warnings
621
+ *
622
+ * @example
623
+ * ```typescript
624
+ * // Create a users collection with indexes
625
+ * const result = await db.createCollection({
626
+ * name: 'users',
627
+ * fields: {
628
+ * email: { type: 'string', index: true, unique: true },
629
+ * username: { type: 'string', index: true },
630
+ * age: { type: 'number' },
631
+ * isActive: { type: 'boolean', index: true }
632
+ * },
633
+ * useBaseFields: true // adds id, createdAt, updatedAt, deletedAt indexes
634
+ * });
635
+ *
636
+ * console.log('Created indexes:', result.indexes);
637
+ * // Now you can store and query data efficiently
638
+ * ```
639
+ */
640
+ async createCollection(schema: SimpleCollectionSchema): Promise<CreateCollectionResult> {
641
+ const appId = this.config.appId;
642
+ if (!appId) {
643
+ throw new ValidationError('appId must be configured to create collections');
644
+ }
645
+
646
+ const result: CreateCollectionResult = {
647
+ collection: schema.name,
648
+ indexes: [],
649
+ success: true,
650
+ warnings: []
651
+ };
652
+
653
+ // Merge base fields if enabled (default: true)
654
+ const allFields: Record<string, SimpleFieldDefinition> = {};
655
+
656
+ if (schema.useBaseFields !== false) {
657
+ Object.assign(allFields, OnChainDBClient.BASE_FIELDS);
658
+ }
659
+
660
+ Object.assign(allFields, schema.fields);
661
+
662
+ // Create indexes only for fields marked with index: true
663
+ for (const [fieldName, fieldDef] of Object.entries(allFields)) {
664
+ // Skip fields not marked for indexing
665
+ if (!fieldDef.index) {
666
+ continue;
667
+ }
668
+
669
+ try {
670
+ // Map field type to index type
671
+ const indexType = fieldDef.indexType || this.getDefaultIndexType(fieldDef.type);
672
+
673
+ const indexRequest: any = {
674
+ name: `${schema.name}_${fieldName}_idx`,
675
+ collection: schema.name,
676
+ field_name: fieldName,
677
+ index_type: indexType,
678
+ store_values: true,
679
+ };
680
+
681
+ // Add read pricing if specified
682
+ if (fieldDef.readPricing) {
683
+ indexRequest.read_price_config = {
684
+ pricing_model: fieldDef.readPricing.pricePerKb ? 'per_kb' : 'per_access',
685
+ price_per_access_tia: fieldDef.readPricing.pricePerAccess,
686
+ price_per_kb_tia: fieldDef.readPricing.pricePerKb
687
+ };
688
+ }
689
+
690
+ const response = await this.http.post(`/api/apps/${appId}/indexes`, indexRequest);
691
+
692
+ // Check if it was an update vs create
693
+ const wasUpdated = response.data?.updated === true;
694
+ const hasWarning = response.data?._warning;
695
+
696
+ result.indexes.push({
697
+ field: fieldName,
698
+ type: indexType,
699
+ status: wasUpdated ? 'updated' : 'created'
700
+ });
701
+
702
+ if (hasWarning) {
703
+ result.warnings!.push(`${fieldName}: ${hasWarning}`);
704
+ }
705
+
706
+ } catch (error) {
707
+ const errorMsg = error instanceof Error ? error.message : String(error);
708
+ result.indexes.push({
709
+ field: fieldName,
710
+ type: fieldDef.indexType || 'btree',
711
+ status: 'failed',
712
+ error: errorMsg
713
+ });
714
+ result.success = false;
715
+ }
716
+ }
717
+
718
+ return result;
719
+ }
720
+
721
+ /**
722
+ * Sync collection schema - applies diff on indexes
723
+ *
724
+ * Compares the provided schema with existing indexes and:
725
+ * - Creates missing indexes for new fields with index: true
726
+ * - Removes indexes for fields no longer in schema or without index: true
727
+ * - Leaves unchanged indexes intact
728
+ *
729
+ * @param schema - Updated collection schema definition
730
+ * @returns Result with created, removed, and unchanged indexes
731
+ *
732
+ * @example
733
+ * ```typescript
734
+ * // Initial schema
735
+ * await db.createCollection({
736
+ * name: 'users',
737
+ * fields: {
738
+ * email: { type: 'string', index: true },
739
+ * username: { type: 'string', index: true }
740
+ * }
741
+ * });
742
+ *
743
+ * // Later, sync with updated schema (add age index, remove username index)
744
+ * const result = await db.syncCollection({
745
+ * name: 'users',
746
+ * fields: {
747
+ * email: { type: 'string', index: true },
748
+ * username: { type: 'string' }, // index removed
749
+ * age: { type: 'number', index: true } // new index
750
+ * }
751
+ * });
752
+ *
753
+ * console.log('Created:', result.created); // [{ field: 'age', type: 'number' }]
754
+ * console.log('Removed:', result.removed); // [{ field: 'username', type: 'string' }]
755
+ * ```
756
+ */
757
+ async syncCollection(schema: SimpleCollectionSchema): Promise<SyncCollectionResult> {
758
+ const appId = this.config.appId;
759
+ if (!appId) {
760
+ throw new ValidationError('appId must be configured to sync collections');
761
+ }
762
+
763
+ const result: SyncCollectionResult = {
764
+ collection: schema.name,
765
+ created: [],
766
+ removed: [],
767
+ unchanged: [],
768
+ success: true,
769
+ errors: []
770
+ };
771
+
772
+ // Get existing indexes for this collection
773
+ let existingIndexes: Array<{ field_name: string; index_type: string; name: string }> = [];
774
+ try {
775
+ const response = await this.http.get(`/api/apps/${appId}/collections/${schema.name}/indexes`);
776
+ existingIndexes = response.data?.indexes || response.data || [];
777
+ } catch (error) {
778
+ // Collection might not exist yet, that's okay
779
+ existingIndexes = [];
780
+ }
781
+
782
+ // Build map of existing indexes by field name
783
+ const existingByField = new Map<string, { type: string; name: string }>();
784
+ for (const idx of existingIndexes) {
785
+ existingByField.set(idx.field_name, {type: idx.index_type, name: idx.name});
786
+ }
787
+
788
+ // Merge base fields if enabled (default: true)
789
+ const allFields: Record<string, SimpleFieldDefinition> = {};
790
+ if (schema.useBaseFields !== false) {
791
+ Object.assign(allFields, OnChainDBClient.BASE_FIELDS);
792
+ }
793
+ Object.assign(allFields, schema.fields);
794
+
795
+ // Build set of desired indexed fields
796
+ const desiredIndexedFields = new Set<string>();
797
+ for (const [fieldName, fieldDef] of Object.entries(allFields)) {
798
+ if (fieldDef.index) {
799
+ desiredIndexedFields.add(fieldName);
800
+ }
801
+ }
802
+
803
+ // Find indexes to create (in desired but not existing)
804
+ for (const fieldName of desiredIndexedFields) {
805
+ if (!existingByField.has(fieldName)) {
806
+ const fieldDef = allFields[fieldName];
807
+ const indexType = fieldDef.indexType || this.getDefaultIndexType(fieldDef.type);
808
+
809
+ try {
810
+ const indexRequest: any = {
811
+ name: `${schema.name}_${fieldName}_idx`,
812
+ collection: schema.name,
813
+ field_name: fieldName,
814
+ index_type: indexType,
815
+ store_values: true,
816
+ };
817
+
818
+ // Add unique constraint if specified
819
+ if (fieldDef.unique) {
820
+ indexRequest.unique_constraint = true;
821
+ }
822
+
823
+ if (fieldDef.readPricing) {
824
+ indexRequest.read_price_config = {
825
+ pricing_model: fieldDef.readPricing.pricePerKb ? 'per_kb' : 'per_access',
826
+ price_per_access_tia: fieldDef.readPricing.pricePerAccess,
827
+ price_per_kb_tia: fieldDef.readPricing.pricePerKb
828
+ };
829
+ }
830
+
831
+ await this.http.post(`/api/apps/${appId}/indexes`, indexRequest);
832
+
833
+ result.created.push({
834
+ field: fieldName,
835
+ type: indexType
836
+ });
837
+ } catch (error) {
838
+ const errorMsg = error instanceof Error ? error.message : String(error);
839
+ result.errors!.push(`Failed to create index on ${fieldName}: ${errorMsg}`);
840
+ result.success = false;
841
+ }
842
+ }
843
+ }
844
+
845
+ // Find indexes to remove (existing but not in desired)
846
+ for (const [fieldName, existing] of existingByField) {
847
+ if (!desiredIndexedFields.has(fieldName)) {
848
+ try {
849
+ // Index ID format: {collection}_{field_name}_index
850
+ const indexId = `${schema.name}_${fieldName}_index`;
851
+ await this.http.delete(`/api/apps/${appId}/indexes/${indexId}`);
852
+ result.removed.push({
853
+ field: fieldName,
854
+ type: existing.type
855
+ });
856
+ } catch (error) {
857
+ const errorMsg = error instanceof Error ? error.message : String(error);
858
+ result.errors!.push(`Failed to remove index on ${fieldName}: ${errorMsg}`);
859
+ result.success = false;
860
+ }
861
+ }
862
+ }
863
+
864
+ // Track unchanged indexes
865
+ for (const [fieldName, existing] of existingByField) {
866
+ if (desiredIndexedFields.has(fieldName)) {
867
+ result.unchanged.push({
868
+ field: fieldName,
869
+ type: existing.type
870
+ });
871
+ }
872
+ }
873
+
874
+ return result;
875
+ }
876
+
877
+ /**
878
+ * Get collection info including indexes
879
+ *
880
+ * @param collection - Collection name
881
+ * @returns Collection information
882
+ */
883
+ async getCollectionInfo(collection: string): Promise<{ indexes: string[]; recordCount?: number }> {
884
+ const appId = this.config.appId;
885
+ if (!appId) {
886
+ throw new ValidationError('appId must be configured');
887
+ }
888
+
889
+ try {
890
+ const response = await this.http.get(`/api/apps/${appId}/collections/${collection}`);
891
+ return response.data;
892
+ } catch (error) {
893
+ throw error instanceof OnChainDBError ? error :
894
+ new OnChainDBError('Failed to get collection info', 'COLLECTION_ERROR');
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Create a relation between collections with automatic join optimization
900
+ *
901
+ * Creates a one-to-many relation and automatically:
902
+ * - Creates hash indexes on both parent and child fields for fast joins
903
+ * - Stores join information in index configs for query optimization
904
+ * - Enables efficient relationship queries using hash-based joins
905
+ *
906
+ * @param request - Relation creation request
907
+ * @returns Relation creation response with join information
908
+ *
909
+ * @example
910
+ * ```typescript
911
+ * // Create a user -> tweets relation (one user has many tweets)
912
+ * const userTweetsRelation = await db.createRelation({
913
+ * parent_collection: 'users',
914
+ * parent_field: 'address', // User's unique field
915
+ * child_collection: 'tweets',
916
+ * child_field: 'author' // Foreign key in tweets
917
+ * });
918
+ *
919
+ * // Create self-referential relations (tweets -> quote tweets)
920
+ * const quoteRelation = await db.createRelation({
921
+ * parent_collection: 'tweets',
922
+ * parent_field: 'id',
923
+ * child_collection: 'tweets',
924
+ * child_field: 'quote_tweet_id' // References parent tweet
925
+ * });
926
+ *
927
+ * // After creating relations, queries automatically benefit from:
928
+ * // - Hash indexes for O(1) lookups
929
+ * // - Join optimization in the query engine
930
+ * // - Efficient relationship traversal
931
+ * ```
932
+ */
933
+ async createRelation(request: RelationRequest): Promise<RelationResponse> {
934
+ try {
935
+ const appId = this.config.appId || 'default';
936
+ const response = await this.http.post<RelationResponse>(`/api/apps/${appId}/relations`, request);
937
+ return response.data;
938
+ } catch (error) {
939
+ throw error instanceof OnChainDBError ? error :
940
+ new OnChainDBError('Failed to create relation', 'RELATION_ERROR');
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Get health status of OnChainDB service
946
+ *
947
+ * @returns Health check response
948
+ */
949
+ async health(): Promise<{ status: string; version?: string }> {
950
+ try {
951
+ const response = await this.http.get('/');
952
+ return response.data;
953
+ } catch (error) {
954
+ throw new OnChainDBError('Health check failed', 'HEALTH_ERROR');
955
+ }
956
+ }
957
+
958
+ /**
959
+ * Get pricing quote for an operation before executing it
960
+ *
961
+ * Use this to estimate costs before committing to a store operation,
962
+ * especially useful for large files or high-volume scenarios.
963
+ *
964
+ * @param request - Pricing quote request with app_id, operation type, size, and collection
965
+ * @returns Pricing quote with detailed cost breakdown
966
+ *
967
+ * @example
968
+ * ```typescript
969
+ * const quote = await db.getPricingQuote({
970
+ * app_id: 'my_app',
971
+ * operation_type: 'write',
972
+ * size_kb: 50,
973
+ * collection: 'users',
974
+ * monthly_volume_kb: 1000
975
+ * });
976
+ *
977
+ * console.log(`Total cost: ${quote.total_cost_utia} utia`);
978
+ * console.log(`Indexing costs:`, quote.indexing_costs_utia);
979
+ * ```
980
+ */
981
+ async getPricingQuote(request: PricingQuoteRequest): Promise<PricingQuoteResponse> {
982
+ try {
983
+ const response = await this.http.post('/api/pricing/quote', request);
984
+ return response.data;
985
+ } catch (error) {
986
+ throw error instanceof OnChainDBError ? error :
987
+ new OnChainDBError('Failed to get pricing quote', 'PRICING_QUOTE_ERROR');
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Execute a simple query with basic filtering
993
+ *
994
+ * @param request - Simple query request
995
+ * @returns Query response with records
996
+ *
997
+ * @example
998
+ * ```typescript
999
+ * const tweets = await db.query({
1000
+ * collection: 'tweets',
1001
+ * filters: { author: 'alice' },
1002
+ * limit: 10
1003
+ * });
1004
+ * ```
1005
+ */
1006
+ async query(request: {
1007
+ collection: string;
1008
+ filters?: Record<string, any>;
1009
+ limit?: number;
1010
+ offset?: number;
1011
+ sort?: string[];
1012
+ }): Promise<QueryResponse> {
1013
+ try {
1014
+ const queryBuilder = this.queryBuilder();
1015
+
1016
+ // Set collection using root building
1017
+ const root = this.resolveRoot(request);
1018
+
1019
+ // Add filters if provided
1020
+ if (request.filters) {
1021
+ queryBuilder.find(builder => {
1022
+ const conditions = Object.entries(request.filters!).map(([field, value]) =>
1023
+ LogicalOperator.Condition(builder.field(field).equals(value))
1024
+ );
1025
+ return conditions.length === 1 ? conditions[0] : LogicalOperator.And(conditions);
1026
+ });
1027
+ }
1028
+
1029
+ // Set limit and offset
1030
+ if (request.limit) {
1031
+ queryBuilder.limit(request.limit);
1032
+ }
1033
+
1034
+ if (request.offset) {
1035
+ queryBuilder.offset(request.offset);
1036
+ }
1037
+
1038
+ // Execute the query
1039
+ return await queryBuilder.execute();
1040
+
1041
+ } catch (error) {
1042
+ throw error instanceof OnChainDBError ? error :
1043
+ new OnChainDBError('Failed to execute query', 'QUERY_ERROR');
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Create a fluent query builder instance
1049
+ *
1050
+ * @returns OnChainQueryBuilder instance for building complex queries
1051
+ *
1052
+ * @example
1053
+ * ```typescript
1054
+ * const results = await db.queryBuilder()
1055
+ * .find(builder =>
1056
+ * LogicalOperator.And([
1057
+ * LogicalOperator.Condition(builder.field('status').equals('published')),
1058
+ * LogicalOperator.Condition(builder.field('author').equals('alice'))
1059
+ * ])
1060
+ * )
1061
+ * .select(selection =>
1062
+ * selection.field('title').field('content')
1063
+ * )
1064
+ * .limit(10)
1065
+ * .execute();
1066
+ * ```
1067
+ */
1068
+ queryBuilder(): QueryBuilder {
1069
+ // Wrap Axios instance in AxiosHttpClient for proper x402 support
1070
+ const {AxiosHttpClient} = require('./query-sdk/adapters/HttpClientAdapter');
1071
+ const httpClient = new AxiosHttpClient(this.http);
1072
+ return new QueryBuilder(httpClient, this.config.endpoint, this.config.appId);
1073
+ }
1074
+
1075
+ /**
1076
+ * Create a batch operations instance for this client
1077
+ *
1078
+ * @returns BatchOperations instance
1079
+ *
1080
+ * @example
1081
+ * ```typescript
1082
+ * const batch = db.batch();
1083
+ * const results = await batch.store([...], { concurrency: 5 });
1084
+ * ```
1085
+ */
1086
+ batch() {
1087
+ const {BatchOperations} = require('./batch');
1088
+ return new BatchOperations(this);
1089
+ }
1090
+
1091
+ /**
1092
+ * Find a single document by query (Prisma-style findUnique)
1093
+ * Returns the latest record by metadata (updatedAt or createdAt) if multiple matches.
1094
+ *
1095
+ * @param collection - Collection name to search in
1096
+ * @param where - Query conditions as key-value pairs
1097
+ * @returns Promise resolving to document or null if not found
1098
+ *
1099
+ * @example
1100
+ * ```typescript
1101
+ * const user = await client.findUnique('users', { email: 'alice@example.com' });
1102
+ * if (user) {
1103
+ * console.log('Found user:', user);
1104
+ * }
1105
+ * ```
1106
+ */
1107
+ async findUnique<T extends Record<string, any>>(
1108
+ collection: string,
1109
+ where: Record<string, any>
1110
+ ): Promise<T | null> {
1111
+ try {
1112
+ let queryBuilder = this.queryBuilder().collection(collection);
1113
+
1114
+ // Add each where condition
1115
+ for (const [field, value] of Object.entries(where)) {
1116
+ queryBuilder = queryBuilder.whereField(field).equals(value);
1117
+ }
1118
+
1119
+ // Execute query and return the latest record by metadata
1120
+ return await queryBuilder.selectAll().executeUnique<T>();
1121
+ } catch (error) {
1122
+ console.error(`findUnique error for ${collection}:`, error);
1123
+ return null;
1124
+ }
1125
+ }
1126
+
1127
+ // ===== PRISMA-LIKE CRUD OPERATIONS =====
1128
+
1129
+ /**
1130
+ * Find multiple documents by query (Prisma-style findMany)
1131
+ *
1132
+ * @param collection - Collection name to search in
1133
+ * @param where - Query conditions as key-value pairs
1134
+ * @param options - Query options (limit, offset, sort)
1135
+ * @returns Promise resolving to array of documents
1136
+ *
1137
+ * @example
1138
+ * ```typescript
1139
+ * const users = await client.findMany('users',
1140
+ * { active: true },
1141
+ * { limit: 10, sort: { field: 'createdAt', order: 'desc' } }
1142
+ * );
1143
+ * ```
1144
+ */
1145
+ async findMany<T>(
1146
+ collection: string,
1147
+ where: Record<string, any> = {},
1148
+ options: {
1149
+ limit?: number;
1150
+ offset?: number;
1151
+ sort?: { field: string; order: 'asc' | 'desc' };
1152
+ } = {}
1153
+ ): Promise<T[]> {
1154
+ try {
1155
+ let queryBuilder = this.queryBuilder().collection(collection);
1156
+
1157
+ // Add where conditions
1158
+ for (const [field, value] of Object.entries(where)) {
1159
+ queryBuilder = queryBuilder.whereField(field).equals(value);
1160
+ }
1161
+
1162
+ // Add limit
1163
+ if (options.limit) {
1164
+ queryBuilder = queryBuilder.limit(options.limit);
1165
+ }
1166
+
1167
+ // Add offset
1168
+ if (options.offset) {
1169
+ queryBuilder = queryBuilder.offset(options.offset);
1170
+ }
1171
+
1172
+ const result = await queryBuilder.selectAll().execute();
1173
+
1174
+ if (!result.records) {
1175
+ return [];
1176
+ }
1177
+
1178
+ // Apply sorting if specified (client-side for now)
1179
+ let records = result.records as T[];
1180
+ if (options.sort) {
1181
+ records = records.sort((a: any, b: any) => {
1182
+ const aVal = a[options.sort!.field];
1183
+ const bVal = b[options.sort!.field];
1184
+
1185
+ if (options.sort!.order === 'asc') {
1186
+ return aVal > bVal ? 1 : -1;
1187
+ } else {
1188
+ return aVal < bVal ? 1 : -1;
1189
+ }
1190
+ });
1191
+ }
1192
+
1193
+ return records;
1194
+ } catch (error) {
1195
+ console.error(`findMany error for ${collection}:`, error);
1196
+ return [];
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Create a new document (Prisma-style create)
1202
+ *
1203
+ * @param collection - Collection name
1204
+ * @param data - Document data (without id, createdAt, updatedAt)
1205
+ * @param paymentCallback - x402 payment callback for handling payment
1206
+ * @param options - Optional settings (idGenerator)
1207
+ * @returns Promise resolving to created document
1208
+ *
1209
+ * @example
1210
+ * ```typescript
1211
+ * const user = await client.createDocument('users',
1212
+ * { email: 'alice@example.com', name: 'Alice' },
1213
+ * async (quote) => {
1214
+ * const txHash = await signAndBroadcastPayment(quote);
1215
+ * return { txHash, network: quote.network, sender: userAddress, chainType: quote.chainType, paymentMethod: quote.paymentMethod };
1216
+ * }
1217
+ * );
1218
+ * ```
1219
+ */
1220
+ async createDocument<T extends Record<string, any>>(
1221
+ collection: string,
1222
+ data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>,
1223
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>,
1224
+ options?: {
1225
+ idGenerator?: () => string;
1226
+ }
1227
+ ): Promise<T> {
1228
+ const document: any = {
1229
+ id: options?.idGenerator ? options.idGenerator() : this.generateId(),
1230
+ ...data,
1231
+ createdAt: new Date().toISOString(),
1232
+ updatedAt: new Date().toISOString(),
1233
+ };
1234
+
1235
+ // Use the store method with x402 payment callback
1236
+ await this.store({
1237
+ collection,
1238
+ data: [document],
1239
+ }, paymentCallback);
1240
+
1241
+ return document as T;
1242
+ }
1243
+
1244
+ /**
1245
+ * Update an existing document (Prisma-style update)
1246
+ *
1247
+ * @param collection - Collection name
1248
+ * @param where - Query conditions to find document
1249
+ * @param data - Partial data to update
1250
+ * @param paymentCallback - x402 payment callback for handling payment
1251
+ * @returns Promise resolving to updated document or null if not found
1252
+ *
1253
+ * @example
1254
+ * ```typescript
1255
+ * const updated = await client.updateDocument('users',
1256
+ * { email: 'alice@example.com' },
1257
+ * { name: 'Alice Smith' },
1258
+ * async (quote) => {
1259
+ * const txHash = await signAndBroadcastPayment(quote);
1260
+ * return { txHash, network: quote.network, sender: userAddress, chainType: quote.chainType, paymentMethod: quote.paymentMethod };
1261
+ * }
1262
+ * );
1263
+ * ```
1264
+ */
1265
+ async updateDocument<T extends Record<string, any>>(
1266
+ collection: string,
1267
+ where: Record<string, any>,
1268
+ data: Partial<T>,
1269
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>
1270
+ ): Promise<T | null> {
1271
+ // Fetch current document
1272
+ const current = await this.findUnique<T>(collection, where);
1273
+
1274
+ if (!current) {
1275
+ return null;
1276
+ }
1277
+
1278
+ // Create updated document
1279
+ const updated: any = {
1280
+ ...current,
1281
+ ...data,
1282
+ updatedAt: new Date().toISOString(),
1283
+ };
1284
+
1285
+ // Store updated version with x402 payment callback
1286
+ await this.store({
1287
+ collection,
1288
+ data: [updated],
1289
+ }, paymentCallback);
1290
+
1291
+ return updated as T;
1292
+ }
1293
+
1294
+ /**
1295
+ * Upsert a document (Prisma-style upsert - create or update)
1296
+ *
1297
+ * @param collection - Collection name
1298
+ * @param where - Query conditions to find document
1299
+ * @param create - Data for creating new document
1300
+ * @param update - Data for updating existing document
1301
+ * @param paymentCallback - x402 payment callback for handling payment
1302
+ * @param options - Optional settings (idGenerator)
1303
+ * @returns Promise resolving to created/updated document
1304
+ *
1305
+ * @example
1306
+ * ```typescript
1307
+ * const user = await client.upsertDocument('users',
1308
+ * { email: 'alice@example.com' },
1309
+ * { email: 'alice@example.com', name: 'Alice', active: true },
1310
+ * { active: true },
1311
+ * async (quote) => {
1312
+ * const txHash = await signAndBroadcastPayment(quote);
1313
+ * return { txHash, network: quote.network, sender: userAddress, chainType: quote.chainType, paymentMethod: quote.paymentMethod };
1314
+ * }
1315
+ * );
1316
+ * ```
1317
+ */
1318
+ async upsertDocument<T extends Record<string, any>>(
1319
+ collection: string,
1320
+ where: Record<string, any>,
1321
+ create: Omit<T, 'id' | 'createdAt' | 'updatedAt'>,
1322
+ update: Partial<T>,
1323
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>,
1324
+ options?: {
1325
+ idGenerator?: () => string;
1326
+ }
1327
+ ): Promise<T> {
1328
+ const existing = await this.findUnique<T>(collection, where);
1329
+
1330
+ if (existing) {
1331
+ return (await this.updateDocument<T>(collection, where, update, paymentCallback))!;
1332
+ } else {
1333
+ return await this.createDocument<T>(collection, create, paymentCallback, options);
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Soft delete a document by marking it as deleted
1339
+ *
1340
+ * @param collection - Collection name
1341
+ * @param where - Query conditions to find document
1342
+ * @param paymentCallback - x402 payment callback for handling payment
1343
+ * @returns Promise resolving to true if deleted, false if not found
1344
+ *
1345
+ * @example
1346
+ * ```typescript
1347
+ * const deleted = await client.deleteDocument('users',
1348
+ * { email: 'alice@example.com' },
1349
+ * async (quote) => {
1350
+ * const txHash = await signAndBroadcastPayment(quote);
1351
+ * return { txHash, network: quote.network, sender: userAddress, chainType: quote.chainType, paymentMethod: quote.paymentMethod };
1352
+ * }
1353
+ * );
1354
+ * ```
1355
+ */
1356
+ async deleteDocument<T extends Record<string, any>>(
1357
+ collection: string,
1358
+ where: Record<string, any>,
1359
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>
1360
+ ): Promise<boolean> {
1361
+ const existing = await this.findUnique<T>(collection, where);
1362
+
1363
+ if (!existing) {
1364
+ return false;
1365
+ }
1366
+
1367
+ // Soft delete by marking
1368
+ const deleted: any = {
1369
+ ...existing,
1370
+ deleted: true,
1371
+ updatedAt: new Date().toISOString(),
1372
+ };
1373
+
1374
+ // Store deleted version with x402 payment callback
1375
+ await this.store({
1376
+ collection,
1377
+ data: [deleted],
1378
+ }, paymentCallback);
1379
+
1380
+ return true;
1381
+ }
1382
+
1383
+ /**
1384
+ * Count documents matching criteria
1385
+ * Uses server-side aggregation via QueryBuilder for efficiency.
1386
+ *
1387
+ * @param collection - Collection name
1388
+ * @param where - Query conditions
1389
+ * @returns Promise resolving to count
1390
+ *
1391
+ * @example
1392
+ * ```typescript
1393
+ * const activeUsers = await client.countDocuments('users', { active: true });
1394
+ * console.log(`Active users: ${activeUsers}`);
1395
+ * ```
1396
+ */
1397
+ async countDocuments(collection: string, where: Record<string, any> = {}): Promise<number> {
1398
+ try {
1399
+ let queryBuilder = this.queryBuilder().collection(collection);
1400
+
1401
+ // Add where conditions
1402
+ for (const [field, value] of Object.entries(where)) {
1403
+ queryBuilder = queryBuilder.whereField(field).equals(value);
1404
+ }
1405
+
1406
+ return await queryBuilder.count();
1407
+ } catch (error) {
1408
+ console.error(`countDocuments error for ${collection}:`, error);
1409
+ return 0;
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Generate a unique ID for documents (simple base62 implementation)
1415
+ * Override this if you want to use a different ID generation strategy
1416
+ *
1417
+ * @returns Unique ID string
1418
+ */
1419
+ generateId(): string {
1420
+ const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
1421
+ let id = '';
1422
+ for (let i = 0; i < 24; i++) {
1423
+ id += chars[Math.floor(Math.random() * chars.length)];
1424
+ }
1425
+ return id;
1426
+ }
1427
+
1428
+ /**
1429
+ * Get task status by ticket ID
1430
+ * @param ticketId - The ticket ID returned from async store operation
1431
+ * @returns Promise resolving to task status information
1432
+ */
1433
+ async getTaskStatus(ticketId: string): Promise<TaskInfo> {
1434
+ try {
1435
+ const response = await this.http.get(`/task/${ticketId}`);
1436
+ return response.data;
1437
+ } catch (error) {
1438
+ throw error instanceof OnChainDBError ? error :
1439
+ new OnChainDBError('Failed to get task status', 'TASK_STATUS_ERROR');
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * Poll task status until completion
1445
+ * @param ticketId - The ticket ID to monitor
1446
+ * @param pollInterval - Polling interval in milliseconds (default: 2000ms)
1447
+ * @param maxWaitTime - Maximum wait time in milliseconds (default: 10 minutes)
1448
+ * @returns Promise resolving when task completes
1449
+ */
1450
+ async waitForTaskCompletion(ticketId: string, pollInterval: number = 2000, maxWaitTime: number = 600000): Promise<TaskInfo> {
1451
+ const startTime = Date.now();
1452
+
1453
+ console.log(`🔄 Waiting for task ${ticketId} to complete...`);
1454
+
1455
+ while (Date.now() - startTime < maxWaitTime) {
1456
+ try {
1457
+ const taskInfo = await this.getTaskStatus(ticketId);
1458
+
1459
+ // Log task status with more detail
1460
+ if (typeof taskInfo.status === 'object') {
1461
+ console.log(`📊 Task ${ticketId} status:`, JSON.stringify(taskInfo.status));
1462
+ } else {
1463
+ console.log(`📊 Task ${ticketId} status: ${taskInfo.status}`);
1464
+ }
1465
+
1466
+ // Check if task is completed
1467
+ if (taskInfo.status === 'Completed') {
1468
+ console.log(`✅ Task ${ticketId} completed successfully`);
1469
+ return taskInfo;
1470
+ }
1471
+
1472
+ // Check if task failed
1473
+ if (typeof taskInfo.status === 'object' && 'Failed' in taskInfo.status) {
1474
+ const error = (taskInfo.status as { Failed: { error: string } }).Failed.error;
1475
+ console.error(`🚫 Task ${ticketId} failed: ${error}`);
1476
+ throw new OnChainDBError(`Task failed: ${error}`, 'TASK_FAILED');
1477
+ }
1478
+
1479
+ // Check for any other error-like statuses
1480
+ if (typeof taskInfo.status === 'string' && taskInfo.status.toLowerCase().includes('error')) {
1481
+ console.error(`🚫 Task ${ticketId} has error status: ${taskInfo.status}`);
1482
+ throw new OnChainDBError(`Task error: ${taskInfo.status}`, 'TASK_FAILED');
1483
+ }
1484
+
1485
+ // Task still in progress, wait and check again
1486
+ await this.sleep(pollInterval);
1487
+
1488
+ } catch (error) {
1489
+ console.error(`❌ Error polling task ${ticketId}:`, error);
1490
+
1491
+ // Check if this is a permanent error (like 404, 400, etc.) that shouldn't be retried
1492
+ if (error instanceof OnChainDBError) {
1493
+ // OnChainDB errors with specific codes should stop polling
1494
+ if (error.code === 'TASK_FAILED' || error.statusCode === 404 || error.statusCode === 400) {
1495
+ console.error(`🚫 Stopping polling due to permanent error: ${error.message}`);
1496
+ throw error;
1497
+ }
1498
+ }
1499
+
1500
+ // For network/temporary errors, check if we've exceeded max wait time
1501
+ if (Date.now() - startTime >= maxWaitTime) {
1502
+ throw new OnChainDBError(
1503
+ `Task completion timeout after ${maxWaitTime}ms. Last error: ${error instanceof Error ? error.message : String(error)}`,
1504
+ 'TASK_TIMEOUT'
1505
+ );
1506
+ }
1507
+
1508
+ // For temporary errors (network issues, 5xx), wait and retry
1509
+ console.warn(`⚠️ Temporary error polling task ${ticketId}, retrying in ${pollInterval}ms...`);
1510
+ await this.sleep(pollInterval);
1511
+ }
1512
+ }
1513
+
1514
+ throw new OnChainDBError(
1515
+ `Task completion timeout after ${maxWaitTime}ms`,
1516
+ 'TASK_TIMEOUT'
1517
+ );
1518
+ }
1519
+
1520
+ /**
1521
+ * Upload a blob (binary file) to OnChainDB with optional custom metadata fields
1522
+ *
1523
+ * Supports images, videos, documents, and any binary data up to 2MB.
1524
+ * Automatically handles multipart/form-data upload and returns a ticket for tracking.
1525
+ *
1526
+ * @param request - Blob upload request with file, metadata, and payment details
1527
+ * @returns Promise resolving to upload response with ticket_id and blob_id
1528
+ *
1529
+ * @example
1530
+ * ```typescript
1531
+ * // Browser upload with File object
1532
+ * const file = document.querySelector('input[type="file"]').files[0];
1533
+ *
1534
+ * const uploadResult = await client.uploadBlob({
1535
+ * collection: 'avatars',
1536
+ * blob: file,
1537
+ * metadata: {
1538
+ * uploaded_by: 'alice',
1539
+ * is_primary: true
1540
+ * }
1541
+ * }, async (quote) => {
1542
+ * const txHash = await signAndBroadcastPayment(quote);
1543
+ * return { txHash, network: quote.network, sender: userAddress, chainType: quote.chainType, paymentMethod: quote.paymentMethod };
1544
+ * });
1545
+ *
1546
+ * console.log('Blob ID:', uploadResult.blob_id);
1547
+ * console.log('Track upload:', uploadResult.ticket_id);
1548
+ *
1549
+ * // Wait for upload completion
1550
+ * const task = await client.waitForTaskCompletion(uploadResult.ticket_id);
1551
+ * console.log('Upload complete!', task);
1552
+ * ```
1553
+ */
1554
+ async uploadBlob(
1555
+ request: UploadBlobRequest,
1556
+ paymentCallback?: (quote: X402Quote) => Promise<X402PaymentResult>
1557
+ ): Promise<UploadBlobResponse> {
1558
+ try {
1559
+ const appId = this.config.appId;
1560
+ if (!appId) {
1561
+ throw new ValidationError('appId must be configured to upload blobs');
1562
+ }
1563
+
1564
+ // Create FormData for multipart upload
1565
+ const formData = new FormData();
1566
+
1567
+ // Append blob file
1568
+ formData.append('blob', request.blob);
1569
+
1570
+ // Append metadata as JSON string
1571
+ if (request.metadata) {
1572
+ formData.append('metadata', JSON.stringify(request.metadata));
1573
+ }
1574
+
1575
+ // First request without payment to get 402 if payment required
1576
+ const uploadWithHeaders = async (headers: Record<string, string> = {}) => {
1577
+ return await this.http.post<UploadBlobResponse>(
1578
+ `/api/apps/${appId}/blobs/${request.collection}`,
1579
+ formData,
1580
+ {
1581
+ headers: {
1582
+ 'Content-Type': 'multipart/form-data',
1583
+ 'X-App-Key': this.config.appKey,
1584
+ ...headers
1585
+ }
1586
+ }
1587
+ );
1588
+ };
1589
+
1590
+ try {
1591
+ const response = await uploadWithHeaders();
1592
+ return response.data;
1593
+ } catch (error) {
1594
+ const err = error as AxiosError;
1595
+ if (err.response?.status === 402 && err.response?.data) {
1596
+ // Handle x402 payment required
1597
+ console.log('[x402] Blob upload requires payment');
1598
+
1599
+ const x402Response = parseX402Response(err.response.data);
1600
+ const requirement = selectPaymentOption(x402Response.accepts);
1601
+ const quote = requirementToQuote(requirement, x402Response.accepts);
1602
+
1603
+ if (!paymentCallback) {
1604
+ throw new OnChainDBError(
1605
+ 'Payment required but no payment callback provided',
1606
+ 'PAYMENT_REQUIRED',
1607
+ 402,
1608
+ quote
1609
+ );
1610
+ }
1611
+
1612
+ const payment = await paymentCallback(quote);
1613
+ const x402Payload = buildPaymentPayload(requirement, payment);
1614
+ const encodedPayment = encodePaymentHeader(x402Payload);
1615
+
1616
+ // Retry with X-PAYMENT header
1617
+ const retryResponse = await uploadWithHeaders({
1618
+ 'X-PAYMENT': encodedPayment
1619
+ });
1620
+
1621
+ return retryResponse.data;
1622
+ }
1623
+ throw error;
1624
+ }
1625
+ } catch (error) {
1626
+ throw error instanceof OnChainDBError ? error :
1627
+ new OnChainDBError('Failed to upload blob', 'BLOB_UPLOAD_ERROR');
1628
+ }
1629
+ }
1630
+
1631
+ /**
1632
+ * Retrieve a blob (binary file) from OnChainDB by blob_id
1633
+ *
1634
+ * Returns the raw blob data with proper Content-Type headers.
1635
+ * Can be used to serve images, videos, documents, etc.
1636
+ *
1637
+ * @param request - Blob retrieval request with collection and blob_id
1638
+ * @returns Promise resolving to Blob object (browser) or Buffer (Node.js)
1639
+ *
1640
+ * @example
1641
+ * ```typescript
1642
+ * // Retrieve and display image in browser
1643
+ * const blob = await client.retrieveBlob({
1644
+ * collection: 'avatars',
1645
+ * blob_id: 'blob_abc123'
1646
+ * });
1647
+ *
1648
+ * // Create object URL for displaying image
1649
+ * const imageUrl = URL.createObjectURL(blob);
1650
+ * document.querySelector('img').src = imageUrl;
1651
+ *
1652
+ * // Retrieve and save file in Node.js
1653
+ * const buffer = await client.retrieveBlob({
1654
+ * collection: 'documents',
1655
+ * blob_id: 'blob_xyz789'
1656
+ * });
1657
+ *
1658
+ * fs.writeFileSync('./downloaded-file.pdf', buffer);
1659
+ * ```
1660
+ */
1661
+ async retrieveBlob(request: RetrieveBlobRequest): Promise<Blob | Buffer> {
1662
+ try {
1663
+ const appId = this.config.appId;
1664
+ if (!appId) {
1665
+ throw new ValidationError('appId must be configured to retrieve blobs');
1666
+ }
1667
+
1668
+ // Retrieve blob via GET endpoint
1669
+ const response = await this.http.get(
1670
+ `/api/apps/${appId}/blobs/${request.collection}/${request.blob_id}`,
1671
+ {
1672
+ responseType: 'arraybuffer',
1673
+ headers: {
1674
+ 'X-App-Key': this.config.appKey
1675
+ }
1676
+ }
1677
+ );
1678
+
1679
+ // Return as Blob in browser, Buffer in Node.js
1680
+ if (typeof (global as any).window !== 'undefined' && typeof Blob !== 'undefined') {
1681
+ // Browser environment
1682
+ const contentType = response.headers['content-type'] || 'application/octet-stream';
1683
+ return new Blob([response.data], {type: contentType});
1684
+ } else {
1685
+ // Node.js environment
1686
+ return Buffer.from(response.data);
1687
+ }
1688
+ } catch (error) {
1689
+ throw error instanceof OnChainDBError ? error :
1690
+ new OnChainDBError('Failed to retrieve blob', 'BLOB_RETRIEVAL_ERROR');
1691
+ }
1692
+ }
1693
+
1694
+ /**
1695
+ * Query blob metadata using the standard query interface
1696
+ *
1697
+ * Returns metadata about blobs without downloading the actual binary data.
1698
+ * Useful for listing, filtering, and searching blobs by their metadata.
1699
+ *
1700
+ * @param collection - Blob collection name
1701
+ * @param where - Query conditions for filtering blobs
1702
+ * @returns Promise resolving to array of blob metadata records
1703
+ *
1704
+ * @example
1705
+ * ```typescript
1706
+ * // Query all blobs by user
1707
+ * const userBlobs = await client.queryBlobMetadata('avatars', {
1708
+ * user_address: 'celestia1abc...'
1709
+ * });
1710
+ *
1711
+ * // Query blobs by content type
1712
+ * const images = await client.queryBlobMetadata('uploads', {
1713
+ * content_type: { $regex: 'image/' }
1714
+ * });
1715
+ *
1716
+ * // Query recent blobs
1717
+ * const recentBlobs = await client.queryBlobMetadata('files', {
1718
+ * uploaded_at: { $gte: '2024-01-01T00:00:00Z' }
1719
+ * });
1720
+ *
1721
+ * // Access blob metadata
1722
+ * for (const blob of userBlobs) {
1723
+ * console.log('Blob ID:', blob.blob_id);
1724
+ * console.log('Size:', blob.size_bytes);
1725
+ * console.log('Type:', blob.content_type);
1726
+ * console.log('Custom fields:', blob);
1727
+ * }
1728
+ * ```
1729
+ */
1730
+ async queryBlobMetadata(
1731
+ collection: string,
1732
+ where: Record<string, any> = {}
1733
+ ): Promise<BlobMetadata[]> {
1734
+ return await this.findMany<BlobMetadata>(collection, where);
1735
+ }
1736
+
1737
+ /**
1738
+ * Get default index type based on field type
1739
+ */
1740
+ private getDefaultIndexType(fieldType: string): string {
1741
+ switch (fieldType) {
1742
+ case 'string':
1743
+ return 'string';
1744
+ case 'number':
1745
+ return 'number';
1746
+ case 'boolean':
1747
+ return 'boolean';
1748
+ case 'date':
1749
+ return 'date';
1750
+ default:
1751
+ return 'string';
1752
+ }
1753
+ }
1754
+
1755
+ private validateStoreRequest(request: StoreRequest): void {
1756
+ // Validate that either root or collection is provided
1757
+ if (!request.root && !request.collection) {
1758
+ throw new ValidationError('Either root or collection must be provided');
1759
+ }
1760
+
1761
+ if (request.root && typeof request.root !== 'string') {
1762
+ throw new ValidationError('Root must be a valid string in format "app::collection"');
1763
+ }
1764
+
1765
+ if (request.collection && typeof request.collection !== 'string') {
1766
+ throw new ValidationError('Collection must be a valid string');
1767
+ }
1768
+
1769
+ if (!request.data || !Array.isArray(request.data)) {
1770
+ throw new ValidationError('Data must be an array of objects');
1771
+ }
1772
+
1773
+ if (request.data.length === 0) {
1774
+ throw new ValidationError('Data array cannot be empty');
1775
+ }
1776
+
1777
+ // Validate each data item
1778
+ for (const item of request.data) {
1779
+ if (!item || typeof item !== 'object') {
1780
+ throw new ValidationError('Each data item must be a valid object');
1781
+ }
1782
+ }
1783
+
1784
+ // Validate total data size (reasonable limit)
1785
+ const dataSize = JSON.stringify(request.data).length;
1786
+ if (dataSize > 5 * 1024 * 1024) { // 5MB limit for batch
1787
+ throw new ValidationError('Total data size exceeds 5MB limit');
1788
+ }
1789
+ }
1790
+
1791
+ private handleHttpError(error: AxiosError): OnChainDBError {
1792
+ console.error(error);
1793
+ if (error.response) {
1794
+ const statusCode = error.response.status;
1795
+ const message = (error.response.data as any)?.error || error.message;
1796
+
1797
+
1798
+ if (statusCode >= 400 && statusCode < 500) {
1799
+ return new ValidationError(message, error.response.data);
1800
+ }
1801
+
1802
+ return new OnChainDBError(
1803
+ message,
1804
+ 'HTTP_ERROR',
1805
+ statusCode,
1806
+ error.response.data
1807
+ );
1808
+ }
1809
+
1810
+ if (error.request) {
1811
+ return new OnChainDBError(
1812
+ 'Network error - could not reach OnChainDB service',
1813
+ 'NETWORK_ERROR'
1814
+ );
1815
+ }
1816
+
1817
+ return new OnChainDBError(error.message, 'UNKNOWN_ERROR');
1818
+ }
1819
+
1820
+ private sleep(ms: number): Promise<void> {
1821
+ return new Promise(resolve => setTimeout(resolve, ms));
1822
+ }
1823
+
1824
+ /**
1825
+ * Build root string from collection name using configured appId
1826
+ *
1827
+ * @param collection - Collection name
1828
+ * @returns Full root string in format "appId::collection" or just collection for system ops
1829
+ */
1830
+ private buildRoot(collection: string): string {
1831
+ if (!this.config.appId) {
1832
+ return collection; // System operation or no appId configured
1833
+ }
1834
+ return `${this.config.appId}::${collection}`;
1835
+ }
1836
+
1837
+ /**
1838
+ * Resolve root parameter from request, building it if needed
1839
+ *
1840
+ * @param request - Store or Query request
1841
+ * @returns Resolved root string
1842
+ */
1843
+ private resolveRoot(request: { root?: string; collection?: string }): string {
1844
+ if (request.root) {
1845
+ return request.root; // Explicit root takes precedence
1846
+ }
1847
+
1848
+ if (request.collection) {
1849
+ return this.buildRoot(request.collection); // Build from collection + appId
1850
+ }
1851
+
1852
+ throw new ValidationError('Either root or collection must be provided');
1853
+ }
1854
+
1855
+
1856
+ }