@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.
- package/.DS_Store +0 -0
- package/.claude/settings.local.json +8 -0
- package/.gitignore +5 -0
- package/.idea/.gitignore +5 -0
- package/.idea/compiler.xml +6 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/jsLinters/eslint.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +7 -0
- package/.idea/sdk.iml +12 -0
- package/.idea/vcs.xml +6 -0
- package/.idea/workspace.xml +257 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -3
- package/dist/client.js.map +1 -1
- package/dist/database.d.ts +0 -20
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +0 -40
- package/dist/database.js.map +1 -1
- package/dist/query-sdk/tests/setup.d.ts +16 -0
- package/dist/query-sdk/tests/setup.d.ts.map +1 -0
- package/dist/query-sdk/tests/setup.js +49 -0
- package/dist/query-sdk/tests/setup.js.map +1 -0
- package/examples/basic-usage.ts +136 -0
- package/examples/blob-upload-example.ts +140 -0
- package/examples/collection-schema-example.ts +304 -0
- package/examples/server-side-joins.ts +201 -0
- package/examples/tweet-self-joins-example.ts +352 -0
- package/package-lock.json +3823 -0
- package/package.json +1 -1
- package/skills.md +1096 -0
- package/src/.env +1 -0
- package/src/batch.d.ts +121 -0
- package/src/batch.js +205 -0
- package/src/batch.ts +257 -0
- package/src/client.ts +1856 -0
- package/src/database.d.ts +268 -0
- package/src/database.js +294 -0
- package/src/database.ts +695 -0
- package/src/index.d.ts +160 -0
- package/src/index.js +186 -0
- package/src/index.ts +253 -0
- package/src/query-sdk/ConditionBuilder.ts +103 -0
- package/src/query-sdk/FieldConditionBuilder.ts +2 -0
- package/src/query-sdk/NestedBuilders.ts +186 -0
- package/src/query-sdk/OnChainDB.ts +294 -0
- package/src/query-sdk/QueryBuilder.ts +1191 -0
- package/src/query-sdk/QueryResult.ts +375 -0
- package/src/query-sdk/README.md +866 -0
- package/src/query-sdk/SelectionBuilder.ts +94 -0
- package/src/query-sdk/adapters/HttpClientAdapter.ts +249 -0
- package/src/query-sdk/dist/ConditionBuilder.d.ts +22 -0
- package/src/query-sdk/dist/ConditionBuilder.js +90 -0
- package/src/query-sdk/dist/FieldConditionBuilder.d.ts +1 -0
- package/src/query-sdk/dist/FieldConditionBuilder.js +6 -0
- package/src/query-sdk/dist/NestedBuilders.d.ts +43 -0
- package/src/query-sdk/dist/NestedBuilders.js +144 -0
- package/src/query-sdk/dist/OnChainDB.d.ts +19 -0
- package/src/query-sdk/dist/OnChainDB.js +123 -0
- package/src/query-sdk/dist/QueryBuilder.d.ts +70 -0
- package/src/query-sdk/dist/QueryBuilder.js +295 -0
- package/src/query-sdk/dist/QueryResult.d.ts +52 -0
- package/src/query-sdk/dist/QueryResult.js +293 -0
- package/src/query-sdk/dist/SelectionBuilder.d.ts +20 -0
- package/src/query-sdk/dist/SelectionBuilder.js +80 -0
- package/src/query-sdk/dist/adapters/HttpClientAdapter.d.ts +27 -0
- package/src/query-sdk/dist/adapters/HttpClientAdapter.js +170 -0
- package/src/query-sdk/dist/index.d.ts +36 -0
- package/src/query-sdk/dist/index.js +27 -0
- package/src/query-sdk/dist/operators.d.ts +56 -0
- package/src/query-sdk/dist/operators.js +289 -0
- package/src/query-sdk/dist/tests/setup.d.ts +15 -0
- package/src/query-sdk/dist/tests/setup.js +46 -0
- package/src/query-sdk/index.ts +59 -0
- package/src/query-sdk/jest.config.js +25 -0
- package/src/query-sdk/operators.ts +335 -0
- package/src/query-sdk/package.json +46 -0
- package/src/query-sdk/tests/FieldConditionBuilder.test.ts +84 -0
- package/src/query-sdk/tests/LogicalOperator.test.ts +85 -0
- package/src/query-sdk/tests/NestedBuilders.test.ts +321 -0
- package/src/query-sdk/tests/QueryBuilder.test.ts +348 -0
- package/src/query-sdk/tests/QueryResult.test.ts +464 -0
- package/src/query-sdk/tests/aggregations.test.ts +653 -0
- package/src/query-sdk/tests/comprehensive.test.ts +279 -0
- package/src/query-sdk/tests/integration.test.ts +608 -0
- package/src/query-sdk/tests/operators.test.ts +327 -0
- package/src/query-sdk/tests/setup.ts +59 -0
- package/src/query-sdk/tests/unit.test.ts +794 -0
- package/src/query-sdk/tsconfig.json +26 -0
- package/src/query-sdk/yarn.lock +3092 -0
- package/src/types.d.ts +131 -0
- package/src/types.js +46 -0
- package/src/types.ts +534 -0
- package/src/x402/index.ts +12 -0
- package/src/x402/types.ts +250 -0
- package/src/x402/utils.ts +332 -0
- package/tsconfig.json +20 -0
- 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
|
+
}
|