@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
@@ -0,0 +1,1191 @@
1
+ import {QueryRequest, QueryResponse, QueryValue, SelectionMap, Val, PaymentRequiredError} from '../index';
2
+ import {FieldConditionBuilder, LogicalOperator} from './operators';
3
+ import {SelectionBuilder} from './SelectionBuilder';
4
+ import {ConditionBuilder} from './ConditionBuilder';
5
+ import {FieldMap, HttpClient} from "./index";
6
+ import {AxiosError} from "axios";
7
+ import {
8
+ X402PaymentRequirement,
9
+ X402PaymentRequiredResponse,
10
+ encodePaymentHeader,
11
+ requirementToQuote,
12
+ } from '../x402';
13
+
14
+ // Interface for server-side join configuration (Scepter format)
15
+ export interface ServerJoinConfig {
16
+ alias: string; // Field name for joined data
17
+ model: string; // Target collection to join with
18
+ many?: boolean; // Explicit control over result type: true=array, false=single object, undefined=default (array)
19
+ resolve: { // Sub-query to resolve joined data
20
+ find?: any; // Filter conditions for join
21
+ select?: SelectionMap; // Fields to select from joined collection
22
+ };
23
+ }
24
+
25
+ // DEPRECATED: Client-side join configuration (kept for backward compatibility)
26
+ export interface JoinConfig {
27
+ parentCollection: string;
28
+ parentField: string;
29
+ childField: string;
30
+ alias: string;
31
+ }
32
+
33
+ // Main query builder providing fluent API for building queries
34
+ export class QueryBuilder {
35
+ private findConditions?: LogicalOperator;
36
+ private selections?: SelectionMap;
37
+ private fieldMap?: FieldMap;
38
+ private limitValue?: number;
39
+ private offsetValue?: number;
40
+ private sortBy?: string;
41
+ private sortDirection?: string;
42
+ private includeHistoryValue?: boolean;
43
+ private httpClient?: HttpClient;
44
+ private serverUrl?: string;
45
+ private collectionName: string | undefined;
46
+ private app: string | undefined;
47
+ private serverJoinConfigs: ServerJoinConfig[] = []; // Server-side JOINs (Scepter)
48
+ private joinConfigs: JoinConfig[] = []; // DEPRECATED: Client-side JOINs
49
+
50
+ constructor(httpClient?: HttpClient, serverUrl?: string, app?: string) {
51
+ this.httpClient = httpClient;
52
+ this.serverUrl = serverUrl;
53
+ this.app = app;
54
+ }
55
+
56
+ static new(httpClient?: HttpClient, serverUrl?: string, app?: string): QueryBuilder {
57
+ return new QueryBuilder(httpClient, serverUrl, app);
58
+ }
59
+
60
+ // Set HTTP client and server URL (for method chaining)
61
+ withHttpClient(httpClient: HttpClient): QueryBuilder {
62
+ this.httpClient = httpClient;
63
+ return this;
64
+ }
65
+
66
+ withServerUrl(serverUrl: string): QueryBuilder {
67
+ this.serverUrl = serverUrl;
68
+ return this;
69
+ }
70
+
71
+ // Build find conditions using a builder function
72
+ find(builderFn: (builder: ConditionBuilder) => LogicalOperator): QueryBuilder {
73
+ const conditionBuilder = new ConditionBuilder();
74
+ this.findConditions = builderFn(conditionBuilder);
75
+ return this;
76
+ }
77
+
78
+ collection(s: string): QueryBuilder {
79
+ this.collectionName = s;
80
+ return this;
81
+ }
82
+
83
+ // Simple field condition (equivalent to Rust's where_field)
84
+ whereField(fieldName: string): WhereClause {
85
+ return new WhereClause(this, fieldName);
86
+ }
87
+
88
+ // Server-side JOIN with Scepter query engine (RECOMMENDED)
89
+ // This generates a JOIN query that the backend executes recursively
90
+ serverJoin(alias: string, model: string, resolve: { find?: any; select?: SelectionMap }): QueryBuilder {
91
+ this.serverJoinConfigs.push({
92
+ alias,
93
+ model,
94
+ resolve
95
+ });
96
+ return this;
97
+ }
98
+
99
+ // Fluent JOIN builder for constructing server-side JOINs (default behavior - returns array)
100
+ joinWith(alias: string, model: string): JoinBuilder {
101
+ return new JoinBuilder(this, alias, model);
102
+ }
103
+
104
+ // One-to-one JOIN: Returns a single object (or null if no match)
105
+ // Use this when you expect at most one related record (e.g., user has one profile)
106
+ joinOne(alias: string, model: string): JoinBuilder {
107
+ return new JoinBuilder(this, alias, model, false); // many = false
108
+ }
109
+
110
+ // One-to-many JOIN: Returns an array of objects (or empty array if no matches)
111
+ // Use this when you expect multiple related records (e.g., user has many tweets)
112
+ joinMany(alias: string, model: string): JoinBuilder {
113
+ return new JoinBuilder(this, alias, model, true); // many = true
114
+ }
115
+
116
+ // DEPRECATED: Client-side join (uses Promise.all to fetch related data)
117
+ // Use serverJoin(), joinWith(), joinOne(), or joinMany() instead for better performance
118
+ join(parentCollection: string, parentField: string, childField: string, alias: string): QueryBuilder {
119
+ console.warn('QueryBuilder.join() is deprecated. Use serverJoin(), joinOne(), or joinMany() for server-side JOINs.');
120
+ this.joinConfigs.push({
121
+ parentCollection,
122
+ parentField,
123
+ childField,
124
+ alias
125
+ });
126
+ return this;
127
+ }
128
+
129
+ // Build selections using a builder function
130
+ select(builderFn: (builder: SelectionBuilder) => SelectionBuilder): QueryBuilder {
131
+ const selectionBuilder = new SelectionBuilder();
132
+ builderFn(selectionBuilder);
133
+ this.selections = selectionBuilder.build();
134
+ return this;
135
+ }
136
+
137
+ // Select specific fields by name
138
+ selectFields(fields: string[]): QueryBuilder {
139
+ const selectionBuilder = new SelectionBuilder();
140
+ selectionBuilder.fields(fields);
141
+ this.selections = selectionBuilder.build();
142
+ return this;
143
+ }
144
+
145
+ // Select all fields (no field filtering)
146
+ selectAll(): QueryBuilder {
147
+ this.selections = SelectionBuilder.all();
148
+ return this;
149
+ }
150
+
151
+ // Set field mapping for the query
152
+ withFieldMap(fieldMap: FieldMap): QueryBuilder {
153
+ this.fieldMap = fieldMap;
154
+ return this;
155
+ }
156
+
157
+ // Set limit for results
158
+ limit(limit: number): QueryBuilder {
159
+ this.limitValue = limit;
160
+ return this;
161
+ }
162
+
163
+ // Set offset for results
164
+ offset(offset: number): QueryBuilder {
165
+ this.offsetValue = offset;
166
+ return this;
167
+ }
168
+
169
+ // Set sorting fields
170
+ orderBy(fields: string, direction?: "ASC" | "DESC"): QueryBuilder {
171
+ this.sortBy = fields;
172
+ switch (direction) {
173
+ case "ASC":
174
+ this.sortDirection = "ascending";
175
+ break;
176
+ case "DESC":
177
+ this.sortDirection = "descending";
178
+ break;
179
+ default:
180
+ this.sortDirection = "ascending";
181
+ }
182
+
183
+ return this;
184
+ }
185
+
186
+ // Include all versions of records in query results (default: false, returns only latest versions)
187
+ // When true, returns all historical versions of each record
188
+ // When false or not called (default), returns only the most recent version of each record
189
+ includeHistory(include: boolean = true): QueryBuilder {
190
+ this.includeHistoryValue = include;
191
+ return this;
192
+ }
193
+
194
+ // Execute the query via HTTP with join support
195
+ async execute<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
196
+ if (!this.httpClient) {
197
+ throw new Error('HTTP client is required for query execution');
198
+ }
199
+ if (!this.serverUrl) {
200
+ throw new Error('Server URL is required for query execution');
201
+ }
202
+
203
+ // Server-side JOINs are embedded in the query structure and handled by the backend
204
+ if (this.serverJoinConfigs.length > 0) {
205
+ return this.executeSimpleQuery<T>();
206
+ }
207
+
208
+ // DEPRECATED: Client-side joins using Promise.all
209
+ if (this.joinConfigs.length > 0) {
210
+ console.warn('Using deprecated client-side JOINs. Consider migrating to serverJoin() for better performance.');
211
+ return this.executeWithJoins<T>();
212
+ }
213
+
214
+ // No joins, execute as a normal query
215
+ return this.executeSimpleQuery<T>();
216
+ }
217
+
218
+ /**
219
+ * Execute the query and return only the latest record by metadata (updatedAt or createdAt).
220
+ * This is the server-side implementation - it runs the find query, sorts by metadata
221
+ * timestamp descending, and returns only the first (latest) record.
222
+ *
223
+ * @returns Promise resolving to the latest matching record or null if none found
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * const latestUser = await db.queryBuilder()
228
+ * .collection('users')
229
+ * .whereField('email').equals('alice@example.com')
230
+ * .executeUnique<User>();
231
+ * ```
232
+ */
233
+ async executeUnique<T extends Record<string, any>>(): Promise<T | null> {
234
+ const response = await this.execute<T>();
235
+
236
+ if (!response.records || response.records.length === 0) {
237
+ return null;
238
+ }
239
+
240
+ // Sort by metadata timestamp (updatedAt first, then createdAt) descending
241
+ const sortedRecords = [...response.records].sort((a: any, b: any) => {
242
+ const getTimestamp = (record: any): string | null => {
243
+ return record.updatedAt || record.updated_at ||
244
+ record.createdAt || record.created_at || null;
245
+ };
246
+
247
+ const tsA = getTimestamp(a);
248
+ const tsB = getTimestamp(b);
249
+
250
+ // Sort descending (latest first)
251
+ if (tsB && tsA) return tsB.localeCompare(tsA);
252
+ if (tsB && !tsA) return -1;
253
+ if (!tsB && tsA) return 1;
254
+ return 0;
255
+ });
256
+
257
+ return sortedRecords[0] as T;
258
+ }
259
+
260
+ // ===== AGGREGATION METHODS =====
261
+ // These methods execute server-side aggregations via the query builder
262
+
263
+ /**
264
+ * Count the number of records matching the query conditions.
265
+ * Executes server-side for efficiency.
266
+ *
267
+ * @returns Promise resolving to the count of matching records
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * const activeUsers = await db.queryBuilder()
272
+ * .collection('users')
273
+ * .whereField('active').equals(true)
274
+ * .count();
275
+ * ```
276
+ */
277
+ async count(): Promise<number> {
278
+ const response = await this.execute();
279
+ return response.records?.length || 0;
280
+ }
281
+
282
+ /**
283
+ * Sum values of a numeric field for records matching the query.
284
+ *
285
+ * @param field - The field name to sum
286
+ * @returns Promise resolving to the sum
287
+ *
288
+ * @example
289
+ * ```typescript
290
+ * const totalRevenue = await db.queryBuilder()
291
+ * .collection('orders')
292
+ * .whereField('status').equals('completed')
293
+ * .sumBy('amount');
294
+ * ```
295
+ */
296
+ async sumBy(field: string): Promise<number> {
297
+ const response = await this.execute();
298
+ if (!response.records) return 0;
299
+
300
+ return response.records.reduce((sum: number, record: any) => {
301
+ const value = record[field];
302
+ return sum + (typeof value === 'number' ? value : 0);
303
+ }, 0);
304
+ }
305
+
306
+ /**
307
+ * Calculate average of a numeric field for records matching the query.
308
+ *
309
+ * @param field - The field name to average
310
+ * @returns Promise resolving to the average (or 0 if no records)
311
+ *
312
+ * @example
313
+ * ```typescript
314
+ * const avgPrice = await db.queryBuilder()
315
+ * .collection('products')
316
+ * .whereField('category').equals('electronics')
317
+ * .avgBy('price');
318
+ * ```
319
+ */
320
+ async avgBy(field: string): Promise<number> {
321
+ const response = await this.execute();
322
+ if (!response.records || response.records.length === 0) return 0;
323
+
324
+ const sum = response.records.reduce((total: number, record: any) => {
325
+ const value = record[field];
326
+ return total + (typeof value === 'number' ? value : 0);
327
+ }, 0);
328
+
329
+ return sum / response.records.length;
330
+ }
331
+
332
+ /**
333
+ * Find maximum value of a field for records matching the query.
334
+ *
335
+ * @param field - The field name to find maximum
336
+ * @returns Promise resolving to the maximum value or null if no records
337
+ *
338
+ * @example
339
+ * ```typescript
340
+ * const highestPrice = await db.queryBuilder()
341
+ * .collection('products')
342
+ * .maxBy('price');
343
+ * ```
344
+ */
345
+ async maxBy<T = any>(field: string): Promise<T | null> {
346
+ const response = await this.execute();
347
+ if (!response.records || response.records.length === 0) return null;
348
+
349
+ return response.records.reduce((max: any, record: any) => {
350
+ const value = record[field];
351
+ if (max === null) return value;
352
+ return value > max ? value : max;
353
+ }, null);
354
+ }
355
+
356
+ /**
357
+ * Find minimum value of a field for records matching the query.
358
+ *
359
+ * @param field - The field name to find minimum
360
+ * @returns Promise resolving to the minimum value or null if no records
361
+ *
362
+ * @example
363
+ * ```typescript
364
+ * const lowestPrice = await db.queryBuilder()
365
+ * .collection('products')
366
+ * .minBy('price');
367
+ * ```
368
+ */
369
+ async minBy<T = any>(field: string): Promise<T | null> {
370
+ const response = await this.execute();
371
+ if (!response.records || response.records.length === 0) return null;
372
+
373
+ return response.records.reduce((min: any, record: any) => {
374
+ const value = record[field];
375
+ if (min === null) return value;
376
+ return value < min ? value : min;
377
+ }, null);
378
+ }
379
+
380
+ /**
381
+ * Get distinct values of a field for records matching the query.
382
+ *
383
+ * @param field - The field name to get distinct values
384
+ * @returns Promise resolving to array of distinct values
385
+ *
386
+ * @example
387
+ * ```typescript
388
+ * const categories = await db.queryBuilder()
389
+ * .collection('products')
390
+ * .distinctBy('category');
391
+ * ```
392
+ */
393
+ async distinctBy<T = any>(field: string): Promise<T[]> {
394
+ const response = await this.execute();
395
+ if (!response.records) return [];
396
+
397
+ const uniqueValues = new Set<T>();
398
+ for (const record of response.records) {
399
+ const value = (record as any)[field];
400
+ if (value !== undefined && value !== null) {
401
+ uniqueValues.add(value);
402
+ }
403
+ }
404
+
405
+ return Array.from(uniqueValues);
406
+ }
407
+
408
+ /**
409
+ * Count distinct values of a field for records matching the query.
410
+ *
411
+ * @param field - The field name to count distinct values
412
+ * @returns Promise resolving to the count of distinct values
413
+ *
414
+ * @example
415
+ * ```typescript
416
+ * const uniqueCategories = await db.queryBuilder()
417
+ * .collection('products')
418
+ * .countDistinct('category');
419
+ * ```
420
+ */
421
+ async countDistinct(field: string): Promise<number> {
422
+ const distinctValues = await this.distinctBy(field);
423
+ return distinctValues.length;
424
+ }
425
+
426
+ /**
427
+ * Start a grouped aggregation query.
428
+ * Returns a GroupByBuilder for specifying the aggregation operation.
429
+ *
430
+ * @param field - The field name to group by
431
+ * @returns GroupByQueryBuilder for chaining aggregation methods
432
+ *
433
+ * @example
434
+ * ```typescript
435
+ * const salesByCategory = await db.queryBuilder()
436
+ * .collection('orders')
437
+ * .groupBy('category')
438
+ * .sumBy('amount');
439
+ *
440
+ * // Returns: { "electronics": 5000, "clothing": 3000, ... }
441
+ * ```
442
+ */
443
+ groupBy(field: string): GroupByQueryBuilder {
444
+ return new GroupByQueryBuilder(this, field);
445
+ }
446
+
447
+ // Execute query with payment proof (for paid queries)
448
+ async executeWithPayment<T extends Record<string, any>>(
449
+ quoteId: string,
450
+ paymentProof: string,
451
+ network?: string // Optional network selection
452
+ ): Promise<QueryResponse<T>> {
453
+ if (!this.httpClient) {
454
+ throw new Error('HTTP client is required for query execution');
455
+ }
456
+ if (!this.serverUrl) {
457
+ throw new Error('Server URL is required for query execution');
458
+ }
459
+
460
+ // Create x402 payment payload using proper types
461
+ const x402Payload = {
462
+ x402Version: 1 as const,
463
+ scheme: 'exact' as const,
464
+ network: network || 'mocha-4',
465
+ quoteId: quoteId,
466
+ payload: {
467
+ txHash: paymentProof.toLowerCase(),
468
+ sender: 'unknown', // Will be extracted from tx by backend
469
+ timestamp: Math.floor(Date.now() / 1000)
470
+ }
471
+ };
472
+
473
+ // Encode using x402 utility
474
+ const encodedPayment = encodePaymentHeader(x402Payload);
475
+
476
+ const request = this.getQueryRequest();
477
+
478
+ try {
479
+ const response = await this.httpClient.post(
480
+ `${this.serverUrl}/list`,
481
+ request,
482
+ {
483
+ 'Content-Type': 'application/json',
484
+ 'X-PAYMENT': encodedPayment // NEW: x402 header
485
+ }
486
+ );
487
+
488
+ // Log X-PAYMENT-RESPONSE header if present
489
+ if (response?.headers && response.headers['x-payment-response']) {
490
+ console.log('✅ Payment confirmed:', JSON.parse(response.headers['x-payment-response']));
491
+ }
492
+
493
+ return response?.data as QueryResponse<T> || {
494
+ records: [],
495
+ total: 0,
496
+ page: 0,
497
+ limit: 0
498
+ } as QueryResponse<T>;
499
+ } catch (error) {
500
+ throw new Error(`Paid query execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
501
+ }
502
+ }
503
+
504
+ getQueryRequest(): QueryRequest {
505
+ const queryValue = this.buildQueryValue();
506
+
507
+ return {
508
+ ...queryValue,
509
+ limit: this.limitValue,
510
+ offset: this.offsetValue,
511
+ sortBy: this.sortBy,
512
+ root: `${this.app}::${this.collectionName}`
513
+ };
514
+ }
515
+
516
+ // Check if the query is valid (has required components)
517
+ isValid(): boolean {
518
+ // A query is valid if it has either find conditions or selections
519
+ return this.findConditions !== undefined || this.selections !== undefined;
520
+ }
521
+
522
+ // Build raw query JSON (useful for debugging)
523
+ buildRawQuery(): QueryRequest {
524
+ return {
525
+ ...this.buildQueryValue(),
526
+ limit: this.limitValue,
527
+ offset: this.offsetValue,
528
+ sortBy: this.sortBy
529
+ };
530
+ }
531
+
532
+ // Clone the query builder
533
+ clone(): QueryBuilder {
534
+ const cloned = new QueryBuilder(this.httpClient, this.serverUrl, this.app);
535
+ cloned.findConditions = this.findConditions;
536
+ cloned.selections = this.selections ? {...this.selections} : undefined;
537
+ cloned.fieldMap = this.fieldMap ? {...this.fieldMap} : undefined;
538
+ cloned.limitValue = this.limitValue;
539
+ cloned.offsetValue = this.offsetValue;
540
+ cloned.sortBy = this.sortBy;
541
+ cloned.includeHistoryValue = this.includeHistoryValue;
542
+ cloned.collectionName = this.collectionName;
543
+ cloned.serverJoinConfigs = [...this.serverJoinConfigs]; // Deep copy server join configs
544
+ cloned.joinConfigs = [...this.joinConfigs]; // Deep copy client join configs (deprecated)
545
+ return cloned;
546
+ }
547
+
548
+ // Internal method to add a server join config (used by JoinBuilder)
549
+ _addServerJoin(config: ServerJoinConfig): void {
550
+ this.serverJoinConfigs.push(config);
551
+ }
552
+
553
+ // Execute a simple query without joins
554
+ private async executeSimpleQuery<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
555
+ const request = this.getQueryRequest();
556
+
557
+ try {
558
+ const response = await this.httpClient!.post(
559
+ `${this.serverUrl}/list`,
560
+ request,
561
+ {'Content-Type': 'application/json'}
562
+ );
563
+
564
+ // x402: Check for 402 Payment Required
565
+ if (response?.status === 402 && response?.data) {
566
+ const x402Response = response.data as X402PaymentRequiredResponse;
567
+ const requirement = x402Response.accepts?.[0];
568
+ if (requirement) {
569
+ const quote = requirementToQuote(requirement, x402Response.accepts);
570
+ throw new PaymentRequiredError('Payment required for this query', quote);
571
+ }
572
+ }
573
+
574
+ // Normal response with records
575
+ return response?.data as QueryResponse<T> || {
576
+ records: [],
577
+ limit: 0,
578
+ page: 0,
579
+ total: 0,
580
+ } as QueryResponse<T>;
581
+ } catch (error: any) {
582
+ // Re-throw PaymentRequiredError as-is
583
+ if (error instanceof PaymentRequiredError) {
584
+ throw error;
585
+ }
586
+ const response = (error as AxiosError)?.response;
587
+ if (response?.status === 402 && response?.data) {
588
+ const x402Response = response.data as X402PaymentRequiredResponse;
589
+ const requirement = x402Response.accepts?.[0];
590
+ if (requirement) {
591
+ const quote = requirementToQuote(requirement, x402Response.accepts);
592
+ throw new PaymentRequiredError('Payment required for this query', quote);
593
+ }
594
+ }
595
+ throw new Error(`Query execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
596
+ }
597
+ }
598
+
599
+ // Execute query with joins using parallel queries
600
+ private async executeWithJoins<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
601
+ // First, get the main collection records
602
+ const mainQuery = this.clone();
603
+ mainQuery.joinConfigs = []; // Remove joins for main query
604
+ const mainResults = await mainQuery.executeSimpleQuery();
605
+
606
+ if (mainResults.records.length === 0) {
607
+ return mainResults as QueryResponse<T>;
608
+ }
609
+
610
+ // For each join, execute parallel queries and merge results
611
+ const joinResults: Record<string, any[]> = {};
612
+
613
+ for (const joinConfig of this.joinConfigs) {
614
+ // Get all unique values from the main results for the join key
615
+ const joinKeys = [...new Set(
616
+ mainResults.records
617
+ .map(record => record[joinConfig.childField])
618
+ .filter(key => key !== null && key !== undefined)
619
+ )];
620
+
621
+ if (joinKeys.length > 0) {
622
+ // Query the joined collection
623
+ const joinQuery = new QueryBuilder(this.httpClient, this.serverUrl, this.app);
624
+ const joinResponse = await joinQuery
625
+ .collection(joinConfig.parentCollection)
626
+ .whereField(joinConfig.parentField).in(joinKeys)
627
+ .selectAll()
628
+ .executeSimpleQuery();
629
+
630
+ joinResults[joinConfig.alias] = joinResponse.records;
631
+ } else {
632
+ joinResults[joinConfig.alias] = [];
633
+ }
634
+ }
635
+
636
+ // Merge join results with main results
637
+ const mergedRecords = mainResults.records.map(mainRecord => {
638
+ const merged: any = {...mainRecord};
639
+
640
+ for (const joinConfig of this.joinConfigs) {
641
+ const joinKey = mainRecord[joinConfig.childField];
642
+ const relatedRecords = joinResults[joinConfig.alias].filter(
643
+ joinRecord => joinRecord[joinConfig.parentField] === joinKey
644
+ );
645
+
646
+ // For one-to-many relationships, return array; for many-to-one, return single object
647
+ if (joinConfig.parentCollection === this.collectionName) {
648
+ // Self-referential join (like replies to tweets)
649
+ merged[joinConfig.alias] = relatedRecords;
650
+ } else {
651
+ // Cross-collection join
652
+ merged[joinConfig.alias] = relatedRecords.length > 0 ? relatedRecords[0] : null;
653
+ }
654
+ }
655
+
656
+ return merged;
657
+ });
658
+
659
+ return {
660
+ records: mergedRecords,
661
+ limit: mainResults.limit,
662
+ page: mainResults.page,
663
+ total: mainResults.total
664
+ } as QueryResponse<T>;
665
+ }
666
+
667
+ // Build the query value object for HTTP requests
668
+ private buildQueryValue(): QueryValue {
669
+ let find = this.findConditions ? this.findConditions.toComposable() : {};
670
+ const select = this.selections || SelectionBuilder.all();
671
+
672
+ // Add server-side JOINs to the find conditions
673
+ // JOINs are just additional fields in the find map, they should NOT be wrapped in "and"
674
+ if (this.serverJoinConfigs.length > 0) {
675
+ for (const join of this.serverJoinConfigs) {
676
+ // Scepter JOIN format (untagged enum): { "alias": { "resolve": {...}, "model": "collection", "many": true/false } }
677
+ // The "join" key is NOT needed because QueryInner::Join uses #[serde(untagged)]
678
+ const joinConfig: any = {
679
+ resolve: join.resolve,
680
+ model: join.model
681
+ };
682
+
683
+ // Only include 'many' field if explicitly set
684
+ if (join.many !== undefined) {
685
+ joinConfig.many = join.many;
686
+ }
687
+
688
+ find[join.alias] = joinConfig;
689
+ }
690
+ }
691
+
692
+ const queryValue: QueryValue = {
693
+ find,
694
+ select
695
+ };
696
+
697
+ // Add include_history if explicitly set
698
+ if (this.includeHistoryValue !== undefined) {
699
+ queryValue.include_history = this.includeHistoryValue;
700
+ }
701
+
702
+ return queryValue;
703
+ }
704
+ }
705
+
706
+ // Helper class for building where clauses with method chaining
707
+ export class WhereClause {
708
+ constructor(
709
+ private queryBuilder: QueryBuilder,
710
+ private fieldName: string
711
+ ) {
712
+ }
713
+
714
+ equals(value: Val): QueryBuilder {
715
+ const condition = new FieldConditionBuilder(this.fieldName).equals(value);
716
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
717
+ return this.queryBuilder;
718
+ }
719
+
720
+ notEquals(value: Val): QueryBuilder {
721
+ const condition = new FieldConditionBuilder(this.fieldName).notEquals(value);
722
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
723
+ return this.queryBuilder;
724
+ }
725
+
726
+ greaterThan(value: Val): QueryBuilder {
727
+ const condition = new FieldConditionBuilder(this.fieldName).greaterThan(value);
728
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
729
+ return this.queryBuilder;
730
+ }
731
+
732
+ greaterThanOrEqual(value: Val): QueryBuilder {
733
+ const condition = new FieldConditionBuilder(this.fieldName).greaterThanOrEqual(value);
734
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
735
+ return this.queryBuilder;
736
+ }
737
+
738
+ lessThan(value: Val): QueryBuilder {
739
+ const condition = new FieldConditionBuilder(this.fieldName).lessThan(value);
740
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
741
+ return this.queryBuilder;
742
+ }
743
+
744
+ lessThanOrEqual(value: Val): QueryBuilder {
745
+ const condition = new FieldConditionBuilder(this.fieldName).lessThanOrEqual(value);
746
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
747
+ return this.queryBuilder;
748
+ }
749
+
750
+ contains(value: string): QueryBuilder {
751
+ const condition = new FieldConditionBuilder(this.fieldName).contains(value);
752
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
753
+ return this.queryBuilder;
754
+ }
755
+
756
+ startsWith(value: string): QueryBuilder {
757
+ const condition = new FieldConditionBuilder(this.fieldName).startsWith(value);
758
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
759
+ return this.queryBuilder;
760
+ }
761
+
762
+ endsWith(value: string): QueryBuilder {
763
+ const condition = new FieldConditionBuilder(this.fieldName).endsWith(value);
764
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
765
+ return this.queryBuilder;
766
+ }
767
+
768
+ in(values: Val[]): QueryBuilder {
769
+ const condition = new FieldConditionBuilder(this.fieldName).in(values);
770
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
771
+ return this.queryBuilder;
772
+ }
773
+
774
+ notIn(values: Val[]): QueryBuilder {
775
+ const condition = new FieldConditionBuilder(this.fieldName).notIn(values);
776
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
777
+ return this.queryBuilder;
778
+ }
779
+
780
+ exists(): QueryBuilder {
781
+ const condition = new FieldConditionBuilder(this.fieldName).exists();
782
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
783
+ return this.queryBuilder;
784
+ }
785
+
786
+ notExists(): QueryBuilder {
787
+ const condition = new FieldConditionBuilder(this.fieldName).notExists();
788
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
789
+ return this.queryBuilder;
790
+ }
791
+
792
+ isNull(): QueryBuilder {
793
+ const condition = new FieldConditionBuilder(this.fieldName).isNull();
794
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
795
+ return this.queryBuilder;
796
+ }
797
+
798
+ isNotNull(): QueryBuilder {
799
+ const condition = new FieldConditionBuilder(this.fieldName).isNotNull();
800
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
801
+ return this.queryBuilder;
802
+ }
803
+
804
+ regExpMatches(pattern: string): QueryBuilder {
805
+ const condition = new FieldConditionBuilder(this.fieldName).regExpMatches(pattern);
806
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
807
+ return this.queryBuilder;
808
+ }
809
+
810
+ includesCaseInsensitive(value: string): QueryBuilder {
811
+ const condition = new FieldConditionBuilder(this.fieldName).includesCaseInsensitive(value);
812
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
813
+ return this.queryBuilder;
814
+ }
815
+
816
+ startsWithCaseInsensitive(value: string): QueryBuilder {
817
+ const condition = new FieldConditionBuilder(this.fieldName).startsWithCaseInsensitive(value);
818
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
819
+ return this.queryBuilder;
820
+ }
821
+
822
+ endsWithCaseInsensitive(value: string): QueryBuilder {
823
+ const condition = new FieldConditionBuilder(this.fieldName).endsWithCaseInsensitive(value);
824
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
825
+ return this.queryBuilder;
826
+ }
827
+
828
+ // ===== IP OPERATORS =====
829
+
830
+ isLocalIp(): QueryBuilder {
831
+ const condition = new FieldConditionBuilder(this.fieldName).isLocalIp();
832
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
833
+ return this.queryBuilder;
834
+ }
835
+
836
+ isExternalIp(): QueryBuilder {
837
+ const condition = new FieldConditionBuilder(this.fieldName).isExternalIp();
838
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
839
+ return this.queryBuilder;
840
+ }
841
+
842
+ // ===== MISC OPERATORS =====
843
+
844
+ b64(value: string): QueryBuilder {
845
+ const condition = new FieldConditionBuilder(this.fieldName).b64(value);
846
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
847
+ return this.queryBuilder;
848
+ }
849
+
850
+ inDataset(dataset: string): QueryBuilder {
851
+ const condition = new FieldConditionBuilder(this.fieldName).inDataset(dataset);
852
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
853
+ return this.queryBuilder;
854
+ }
855
+
856
+ inCountry(countryCode: string): QueryBuilder {
857
+ const condition = new FieldConditionBuilder(this.fieldName).inCountry(countryCode);
858
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
859
+ return this.queryBuilder;
860
+ }
861
+
862
+ cidr(cidr: string): QueryBuilder {
863
+ const condition = new FieldConditionBuilder(this.fieldName).cidr(cidr);
864
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
865
+ return this.queryBuilder;
866
+ }
867
+
868
+ // ===== BETWEEN OPERATOR =====
869
+
870
+ between(min: any, max: any): QueryBuilder {
871
+ const condition = new FieldConditionBuilder(this.fieldName).between(min, max);
872
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
873
+ return this.queryBuilder;
874
+ }
875
+
876
+ // ===== CONVENIENCE METHODS =====
877
+
878
+ isTrue(): QueryBuilder {
879
+ const condition = new FieldConditionBuilder(this.fieldName).isTrue();
880
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
881
+ return this.queryBuilder;
882
+ }
883
+
884
+ isFalse(): QueryBuilder {
885
+ const condition = new FieldConditionBuilder(this.fieldName).isFalse();
886
+ (this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
887
+ return this.queryBuilder;
888
+ }
889
+ }
890
+
891
+ // Fluent builder for constructing server-side JOINs
892
+ export class JoinBuilder<TParent extends QueryBuilder | JoinBuilder<any> = QueryBuilder> {
893
+ private alias: string;
894
+ private model: string;
895
+ private findConditions: any = {};
896
+ private selections?: SelectionMap;
897
+ private parentBuilder: TParent;
898
+ private many?: boolean; // Explicit control over result type
899
+ private nestedJoins: ServerJoinConfig[] = []; // Support nested JOINs
900
+
901
+ constructor(parentBuilder: TParent, alias: string, model: string, many?: boolean) {
902
+ this.parentBuilder = parentBuilder;
903
+ this.alias = alias;
904
+ this.model = model;
905
+ this.many = many;
906
+ }
907
+
908
+ // Add filter conditions for the joined collection
909
+ on(builderFn: (builder: ConditionBuilder) => LogicalOperator): this {
910
+ const conditionBuilder = new ConditionBuilder();
911
+ this.findConditions = builderFn(conditionBuilder).toComposable();
912
+ return this;
913
+ }
914
+
915
+ // Add a simple equality condition (most common case)
916
+ onField(fieldName: string): JoinWhereClause<TParent> {
917
+ return new JoinWhereClause(this, fieldName);
918
+ }
919
+
920
+ // Select specific fields from the joined collection
921
+ selecting(builderFn: (builder: SelectionBuilder) => SelectionBuilder): this {
922
+ const selectionBuilder = new SelectionBuilder();
923
+ builderFn(selectionBuilder);
924
+ this.selections = selectionBuilder.build();
925
+ return this;
926
+ }
927
+
928
+ // Select specific fields by name
929
+ selectFields(fields: string[]): this {
930
+ const selectionBuilder = new SelectionBuilder();
931
+ selectionBuilder.fields(fields);
932
+ this.selections = selectionBuilder.build();
933
+ return this;
934
+ }
935
+
936
+ // Select all fields from joined collection
937
+ selectAll(): this {
938
+ this.selections = SelectionBuilder.all();
939
+ return this;
940
+ }
941
+
942
+ // Nested JOIN: one-to-one relationship within this JOIN
943
+ joinOne(alias: string, model: string): JoinBuilder<this> {
944
+ return new JoinBuilder(this, alias, model, false);
945
+ }
946
+
947
+ // Nested JOIN: one-to-many relationship within this JOIN
948
+ joinMany(alias: string, model: string): JoinBuilder<this> {
949
+ return new JoinBuilder(this, alias, model, true);
950
+ }
951
+
952
+ // Complete the JOIN and return to the parent builder
953
+ // Returns the parent builder's type (JoinBuilder for nested JOINs, QueryBuilder for top-level)
954
+ build(): TParent {
955
+ const joinConfig: ServerJoinConfig = {
956
+ alias: this.alias,
957
+ model: this.model,
958
+ many: this.many,
959
+ resolve: {
960
+ find: this.buildFindWithNestedJoins(),
961
+ select: this.selections || SelectionBuilder.all()
962
+ }
963
+ };
964
+
965
+ this.parentBuilder._addServerJoin(joinConfig);
966
+ return this.parentBuilder;
967
+ }
968
+
969
+ // Build find conditions with nested JOINs embedded
970
+ private buildFindWithNestedJoins(): any {
971
+ // Start with existing find conditions
972
+ const find = Object.keys(this.findConditions).length > 0 ? {...this.findConditions} : {};
973
+
974
+ // Add nested JOINs to the find map
975
+ if (this.nestedJoins.length > 0) {
976
+ for (const nestedJoin of this.nestedJoins) {
977
+ const joinConfig: any = {
978
+ resolve: nestedJoin.resolve,
979
+ model: nestedJoin.model
980
+ };
981
+
982
+ if (nestedJoin.many !== undefined) {
983
+ joinConfig.many = nestedJoin.many;
984
+ }
985
+
986
+ find[nestedJoin.alias] = joinConfig;
987
+ }
988
+ }
989
+
990
+ return Object.keys(find).length > 0 ? find : undefined;
991
+ }
992
+
993
+ // Internal method to set find conditions (used by JoinWhereClause)
994
+ _setFindConditions(conditions: any): void {
995
+ this.findConditions = conditions;
996
+ }
997
+
998
+ // Internal method to add nested joins (used by nested JoinBuilder)
999
+ _addServerJoin(config: ServerJoinConfig): void {
1000
+ this.nestedJoins.push(config);
1001
+ }
1002
+ }
1003
+
1004
+ // Helper class for building WHERE clauses in JOINs
1005
+ export class JoinWhereClause<TParent extends QueryBuilder | JoinBuilder<any> = QueryBuilder> {
1006
+ constructor(
1007
+ private joinBuilder: JoinBuilder<TParent>,
1008
+ private fieldName: string
1009
+ ) {
1010
+ }
1011
+
1012
+ equals(value: Val): JoinBuilder<TParent> {
1013
+ const condition = new FieldConditionBuilder(this.fieldName).equals(value);
1014
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1015
+ return this.joinBuilder;
1016
+ }
1017
+
1018
+ in(values: Val[]): JoinBuilder<TParent> {
1019
+ const condition = new FieldConditionBuilder(this.fieldName).in(values);
1020
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1021
+ return this.joinBuilder;
1022
+ }
1023
+
1024
+ greaterThan(value: Val): JoinBuilder<TParent> {
1025
+ const condition = new FieldConditionBuilder(this.fieldName).greaterThan(value);
1026
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1027
+ return this.joinBuilder;
1028
+ }
1029
+
1030
+ lessThan(value: Val): JoinBuilder<TParent> {
1031
+ const condition = new FieldConditionBuilder(this.fieldName).lessThan(value);
1032
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1033
+ return this.joinBuilder;
1034
+ }
1035
+
1036
+ isNull(): JoinBuilder<TParent> {
1037
+ const condition = new FieldConditionBuilder(this.fieldName).isNull();
1038
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1039
+ return this.joinBuilder;
1040
+ }
1041
+
1042
+ isNotNull(): JoinBuilder<TParent> {
1043
+ const condition = new FieldConditionBuilder(this.fieldName).isNotNull();
1044
+ this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
1045
+ return this.joinBuilder;
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Builder for grouped aggregation queries.
1051
+ * Allows aggregation operations to be performed on groups of records.
1052
+ *
1053
+ * @example
1054
+ * ```typescript
1055
+ * // Count users by country
1056
+ * const usersByCountry = await db.queryBuilder()
1057
+ * .collection('users')
1058
+ * .groupBy('country')
1059
+ * .count();
1060
+ *
1061
+ * // Sum order amounts by category
1062
+ * const salesByCategory = await db.queryBuilder()
1063
+ * .collection('orders')
1064
+ * .whereField('status').equals('completed')
1065
+ * .groupBy('category')
1066
+ * .sumBy('amount');
1067
+ * ```
1068
+ */
1069
+ export class GroupByQueryBuilder {
1070
+ constructor(
1071
+ private queryBuilder: QueryBuilder,
1072
+ private groupByField: string
1073
+ ) {}
1074
+
1075
+ /**
1076
+ * Count records in each group
1077
+ * @returns Promise resolving to map of group key -> count
1078
+ */
1079
+ async count(): Promise<Record<string, number>> {
1080
+ const response = await this.queryBuilder.execute();
1081
+ if (!response.records) return {};
1082
+
1083
+ const groups: Record<string, number> = {};
1084
+ for (const record of response.records) {
1085
+ const key = this.getGroupKey(record);
1086
+ groups[key] = (groups[key] || 0) + 1;
1087
+ }
1088
+ return groups;
1089
+ }
1090
+
1091
+ /**
1092
+ * Sum a numeric field within each group
1093
+ * @param field - The field name to sum
1094
+ * @returns Promise resolving to map of group key -> sum
1095
+ */
1096
+ async sumBy(field: string): Promise<Record<string, number>> {
1097
+ const response = await this.queryBuilder.execute();
1098
+ if (!response.records) return {};
1099
+
1100
+ const groups: Record<string, number> = {};
1101
+ for (const record of response.records) {
1102
+ const key = this.getGroupKey(record);
1103
+ const value = (record as any)[field];
1104
+ groups[key] = (groups[key] || 0) + (typeof value === 'number' ? value : 0);
1105
+ }
1106
+ return groups;
1107
+ }
1108
+
1109
+ /**
1110
+ * Calculate average of a numeric field within each group
1111
+ * @param field - The field name to average
1112
+ * @returns Promise resolving to map of group key -> average
1113
+ */
1114
+ async avgBy(field: string): Promise<Record<string, number>> {
1115
+ const response = await this.queryBuilder.execute();
1116
+ if (!response.records) return {};
1117
+
1118
+ const groups: Record<string, { sum: number; count: number }> = {};
1119
+ for (const record of response.records) {
1120
+ const key = this.getGroupKey(record);
1121
+ const value = (record as any)[field];
1122
+ if (!groups[key]) {
1123
+ groups[key] = { sum: 0, count: 0 };
1124
+ }
1125
+ groups[key].sum += typeof value === 'number' ? value : 0;
1126
+ groups[key].count += 1;
1127
+ }
1128
+
1129
+ const result: Record<string, number> = {};
1130
+ for (const [key, { sum, count }] of Object.entries(groups)) {
1131
+ result[key] = count > 0 ? sum / count : 0;
1132
+ }
1133
+ return result;
1134
+ }
1135
+
1136
+ /**
1137
+ * Find maximum value of a field within each group
1138
+ * @param field - The field name to find maximum
1139
+ * @returns Promise resolving to map of group key -> max value
1140
+ */
1141
+ async maxBy<T = any>(field: string): Promise<Record<string, T>> {
1142
+ const response = await this.queryBuilder.execute();
1143
+ if (!response.records) return {};
1144
+
1145
+ const groups: Record<string, T> = {};
1146
+ for (const record of response.records) {
1147
+ const key = this.getGroupKey(record);
1148
+ const value = (record as any)[field] as T;
1149
+ if (!(key in groups) || value > (groups[key] as T)) {
1150
+ groups[key] = value;
1151
+ }
1152
+ }
1153
+ return groups;
1154
+ }
1155
+
1156
+ /**
1157
+ * Find minimum value of a field within each group
1158
+ * @param field - The field name to find minimum
1159
+ * @returns Promise resolving to map of group key -> min value
1160
+ */
1161
+ async minBy<T = any>(field: string): Promise<Record<string, T>> {
1162
+ const response = await this.queryBuilder.execute();
1163
+ if (!response.records) return {};
1164
+
1165
+ const groups: Record<string, T> = {};
1166
+ for (const record of response.records) {
1167
+ const key = this.getGroupKey(record);
1168
+ const value = (record as any)[field] as T;
1169
+ if (!(key in groups) || value < (groups[key] as T)) {
1170
+ groups[key] = value;
1171
+ }
1172
+ }
1173
+ return groups;
1174
+ }
1175
+
1176
+ /**
1177
+ * Get the group key from a record, supporting nested field paths
1178
+ */
1179
+ private getGroupKey(record: any): string {
1180
+ // Support nested field paths (e.g., "user.country")
1181
+ if (this.groupByField.includes('.')) {
1182
+ const parts = this.groupByField.split('.');
1183
+ let current = record;
1184
+ for (const part of parts) {
1185
+ current = current?.[part];
1186
+ }
1187
+ return String(current ?? 'null');
1188
+ }
1189
+ return String(record[this.groupByField] ?? 'null');
1190
+ }
1191
+ }