@onchaindb/sdk 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.gitignore +1 -0
  3. package/README.md +0 -1
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +0 -5
  6. package/dist/client.js.map +1 -1
  7. package/dist/database.d.ts +46 -8
  8. package/dist/database.d.ts.map +1 -1
  9. package/dist/database.js +32 -9
  10. package/dist/database.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/query-sdk/NestedBuilders.d.ts +3 -2
  16. package/dist/query-sdk/NestedBuilders.d.ts.map +1 -1
  17. package/dist/query-sdk/NestedBuilders.js +7 -4
  18. package/dist/query-sdk/NestedBuilders.js.map +1 -1
  19. package/dist/query-sdk/QueryBuilder.d.ts +17 -15
  20. package/dist/query-sdk/QueryBuilder.d.ts.map +1 -1
  21. package/dist/query-sdk/QueryBuilder.js +126 -190
  22. package/dist/query-sdk/QueryBuilder.js.map +1 -1
  23. package/dist/query-sdk/index.d.ts +24 -1
  24. package/dist/query-sdk/index.d.ts.map +1 -1
  25. package/dist/query-sdk/index.js.map +1 -1
  26. package/dist/query-sdk/operators.d.ts +3 -2
  27. package/dist/query-sdk/operators.d.ts.map +1 -1
  28. package/dist/query-sdk/operators.js +7 -4
  29. package/dist/query-sdk/operators.js.map +1 -1
  30. package/dist/types.d.ts +17 -13
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js.map +1 -1
  33. package/package.json +1 -1
  34. package/skills.md +5 -5
  35. package/src/client.ts +0 -6
  36. package/src/database.ts +159 -26
  37. package/src/index.ts +23 -10
  38. package/src/query-sdk/NestedBuilders.ts +14 -4
  39. package/src/query-sdk/QueryBuilder.ts +212 -235
  40. package/src/query-sdk/index.ts +19 -1
  41. package/src/query-sdk/operators.ts +15 -4
  42. package/src/query-sdk/tests/FieldConditionBuilder.test.ts +4 -2
  43. package/src/query-sdk/tests/NestedBuilders.test.ts +4 -3
  44. package/src/types.ts +26 -17
  45. package/.DS_Store +0 -0
@@ -3,7 +3,7 @@ import {QueryValue} from './index';
3
3
  import {FieldConditionBuilder, LogicalOperator} from './operators';
4
4
  import {SelectionBuilder} from './SelectionBuilder';
5
5
  import {ConditionBuilder} from './ConditionBuilder';
6
- import {FieldMap, HttpClient} from "./index";
6
+ import {FieldMap, HttpClient, AggregateSpec} from "./index";
7
7
  import {AxiosError} from "axios";
8
8
  import {
9
9
  X402PaymentRequiredResponse,
@@ -22,13 +22,6 @@ export interface ServerJoinConfig {
22
22
  };
23
23
  }
24
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
25
 
33
26
  // Main query builder providing fluent API for building queries
34
27
  export class QueryBuilder {
@@ -37,15 +30,16 @@ export class QueryBuilder {
37
30
  private fieldMap?: FieldMap;
38
31
  private limitValue?: number;
39
32
  private offsetValue?: number;
40
- private sortBy?: string;
33
+ private _sortField?: string;
41
34
  private sortDirection?: string;
42
35
  private includeHistoryValue?: boolean;
43
36
  private httpClient?: HttpClient;
44
37
  private serverUrl?: string;
45
38
  private collectionName: string | undefined;
46
39
  private app: string | undefined;
47
- private serverJoinConfigs: ServerJoinConfig[] = []; // Server-side JOINs (Scepter)
48
- private joinConfigs: JoinConfig[] = []; // DEPRECATED: Client-side JOINs
40
+ private serverJoinConfigs: ServerJoinConfig[] = [];
41
+ private _groupBy?: string[];
42
+ private _aggregate?: AggregateSpec;
49
43
 
50
44
  constructor(httpClient?: HttpClient, serverUrl?: string, app?: string) {
51
45
  this.httpClient = httpClient;
@@ -115,18 +109,6 @@ export class QueryBuilder {
115
109
  return new JoinBuilder(this, alias, model, true); // many = true
116
110
  }
117
111
 
118
- // DEPRECATED: Client-side join (uses Promise.all to fetch related data)
119
- // Use serverJoin(), joinWith(), joinOne(), or joinMany() instead for better performance
120
- join(parentCollection: string, parentField: string, childField: string, alias: string): QueryBuilder {
121
- console.warn('QueryBuilder.join() is deprecated. Use serverJoin(), joinOne(), or joinMany() for server-side JOINs.');
122
- this.joinConfigs.push({
123
- parentCollection,
124
- parentField,
125
- childField,
126
- alias
127
- });
128
- return this;
129
- }
130
112
 
131
113
  // Build selections using a builder function
132
114
  select(builderFn: (builder: SelectionBuilder) => SelectionBuilder): QueryBuilder {
@@ -162,29 +144,31 @@ export class QueryBuilder {
162
144
  return this;
163
145
  }
164
146
 
147
+ /** Alias for limit(). */
148
+ take(n: number): QueryBuilder { return this.limit(n); }
149
+
165
150
  // Set offset for results
166
151
  offset(offset: number): QueryBuilder {
167
152
  this.offsetValue = offset;
168
153
  return this;
169
154
  }
170
155
 
171
- // Set sorting fields
172
- orderBy(fields: string, direction?: "ASC" | "DESC"): QueryBuilder {
173
- this.sortBy = fields;
174
- switch (direction) {
175
- case "ASC":
176
- this.sortDirection = "ascending";
177
- break;
178
- case "DESC":
179
- this.sortDirection = "descending";
180
- break;
181
- default:
182
- this.sortDirection = "ascending";
183
- }
156
+ /** Alias for offset(). */
157
+ skip(n: number): QueryBuilder { return this.offset(n); }
184
158
 
159
+ // Set sort field and direction (accepts "asc"/"desc" or "ASC"/"DESC")
160
+ sortBy(field: string, direction?: "asc" | "desc" | "ASC" | "DESC"): QueryBuilder {
161
+ this._sortField = field;
162
+ const dir = direction?.toLowerCase();
163
+ this.sortDirection = dir === "desc" ? "descending" : "ascending";
185
164
  return this;
186
165
  }
187
166
 
167
+ // Alias kept for backward compatibility
168
+ orderBy(field: string, direction?: "ASC" | "DESC"): QueryBuilder {
169
+ return this.sortBy(field, direction);
170
+ }
171
+
188
172
  // Include all versions of records in query results (default: false, returns only latest versions)
189
173
  // When true, returns all historical versions of each record
190
174
  // When false or not called (default), returns only the most recent version of each record
@@ -202,18 +186,6 @@ export class QueryBuilder {
202
186
  throw new Error('Server URL is required for query execution');
203
187
  }
204
188
 
205
- // Server-side JOINs are embedded in the query structure and handled by the backend
206
- if (this.serverJoinConfigs.length > 0) {
207
- return this.executeSimpleQuery<T>();
208
- }
209
-
210
- // DEPRECATED: Client-side joins using Promise.all
211
- if (this.joinConfigs.length > 0) {
212
- console.warn('Using deprecated client-side JOINs. Consider migrating to serverJoin() for better performance.');
213
- return this.executeWithJoins<T>();
214
- }
215
-
216
- // No joins, execute as a normal query
217
189
  return this.executeSimpleQuery<T>();
218
190
  }
219
191
 
@@ -277,8 +249,12 @@ export class QueryBuilder {
277
249
  * ```
278
250
  */
279
251
  async count(): Promise<number> {
280
- const response = await this.execute();
281
- return response.records?.length || 0;
252
+ const clone = this.clone();
253
+ clone._setSelections({});
254
+ clone._setAggregate({ _count: { '$count': '*' } });
255
+ const response = await clone.execute();
256
+ const first = response.records?.[0] as any;
257
+ return first?._count ?? 0;
282
258
  }
283
259
 
284
260
  /**
@@ -296,13 +272,12 @@ export class QueryBuilder {
296
272
  * ```
297
273
  */
298
274
  async sumBy(field: string): Promise<number> {
299
- const response = await this.execute();
300
- if (!response.records) return 0;
301
-
302
- return response.records.reduce((sum: number, record: any) => {
303
- const value = record[field];
304
- return sum + (typeof value === 'number' ? value : 0);
305
- }, 0);
275
+ const clone = this.clone();
276
+ clone._setSelections({});
277
+ clone._setAggregate({ _result: { '$sum': field } });
278
+ const response = await clone.execute();
279
+ const first = response.records?.[0] as any;
280
+ return first?._result ?? 0;
306
281
  }
307
282
 
308
283
  /**
@@ -320,15 +295,12 @@ export class QueryBuilder {
320
295
  * ```
321
296
  */
322
297
  async avgBy(field: string): Promise<number> {
323
- const response = await this.execute();
324
- if (!response.records || response.records.length === 0) return 0;
325
-
326
- const sum = response.records.reduce((total: number, record: any) => {
327
- const value = record[field];
328
- return total + (typeof value === 'number' ? value : 0);
329
- }, 0);
330
-
331
- return sum / response.records.length;
298
+ const clone = this.clone();
299
+ clone._setSelections({});
300
+ clone._setAggregate({ _result: { '$avg': field } });
301
+ const response = await clone.execute();
302
+ const first = response.records?.[0] as any;
303
+ return first?._result ?? 0;
332
304
  }
333
305
 
334
306
  /**
@@ -345,14 +317,12 @@ export class QueryBuilder {
345
317
  * ```
346
318
  */
347
319
  async maxBy<T = any>(field: string): Promise<T | null> {
348
- const response = await this.execute();
349
- if (!response.records || response.records.length === 0) return null;
350
-
351
- return response.records.reduce((max: any, record: any) => {
352
- const value = record[field];
353
- if (max === null) return value;
354
- return value > max ? value : max;
355
- }, null);
320
+ const clone = this.clone();
321
+ clone._setSelections({});
322
+ clone._setAggregate({ _result: { '$max': field } });
323
+ const response = await clone.execute();
324
+ const first = response.records?.[0] as any;
325
+ return first?._result ?? null;
356
326
  }
357
327
 
358
328
  /**
@@ -369,14 +339,12 @@ export class QueryBuilder {
369
339
  * ```
370
340
  */
371
341
  async minBy<T = any>(field: string): Promise<T | null> {
372
- const response = await this.execute();
373
- if (!response.records || response.records.length === 0) return null;
374
-
375
- return response.records.reduce((min: any, record: any) => {
376
- const value = record[field];
377
- if (min === null) return value;
378
- return value < min ? value : min;
379
- }, null);
342
+ const clone = this.clone();
343
+ clone._setSelections({});
344
+ clone._setAggregate({ _result: { '$min': field } });
345
+ const response = await clone.execute();
346
+ const first = response.records?.[0] as any;
347
+ return first?._result ?? null;
380
348
  }
381
349
 
382
350
  /**
@@ -421,8 +389,46 @@ export class QueryBuilder {
421
389
  * ```
422
390
  */
423
391
  async countDistinct(field: string): Promise<number> {
424
- const distinctValues = await this.distinctBy(field);
425
- return distinctValues.length;
392
+ const clone = this.clone();
393
+ clone._setSelections({});
394
+ clone._setAggregate({ _result: { '$countDistinct': field } });
395
+ const response = await clone.execute();
396
+ const first = response.records?.[0] as any;
397
+ return first?._result ?? 0;
398
+ }
399
+
400
+ /**
401
+ * Run multiple aggregations in a single HTTP round-trip.
402
+ *
403
+ * Each key in `spec` becomes an output alias in the returned record.
404
+ * Supported operators: `$count`, `$sum`, `$avg`, `$max`, `$min`, `$countDistinct`.
405
+ * Use `'*'` as the field value for `$count`.
406
+ *
407
+ * @param spec - Map of output alias to `{ '$op': 'field' }` descriptor
408
+ * @returns Promise resolving to a single record containing all requested aggregation results
409
+ *
410
+ * @example
411
+ * ```typescript
412
+ * const stats = await db.queryBuilder()
413
+ * .collection('posts')
414
+ * .whereField('category').equals('technology')
415
+ * .runAggregate({
416
+ * total: { '$count': '*' },
417
+ * totalLikes: { '$sum': 'likes' },
418
+ * avgLikes: { '$avg': 'likes' },
419
+ * maxLikes: { '$max': 'likes' },
420
+ * minLikes: { '$min': 'likes' },
421
+ * uniqueAuthors: { '$countDistinct': 'author_id' }
422
+ * });
423
+ * // Returns: { total: 4, totalLikes: 203, avgLikes: 50.75, maxLikes: 67, minLikes: 38, uniqueAuthors: 2 }
424
+ * ```
425
+ */
426
+ async runAggregate(spec: AggregateSpec): Promise<Record<string, any>> {
427
+ const clone = this.clone();
428
+ clone._setSelections({});
429
+ clone._setAggregate(spec);
430
+ const response = await clone.execute();
431
+ return (response.records?.[0] as any) ?? {};
426
432
  }
427
433
 
428
434
  /**
@@ -510,7 +516,7 @@ export class QueryBuilder {
510
516
  ...queryValue,
511
517
  limit: this.limitValue,
512
518
  offset: this.offsetValue,
513
- sortBy: this.sortBy,
519
+ sortBy: this._sortField,
514
520
  sortDirection: this.sortDirection,
515
521
  root: `${this.app}::${this.collectionName}`
516
522
  };
@@ -528,7 +534,7 @@ export class QueryBuilder {
528
534
  ...this.buildQueryValue(),
529
535
  limit: this.limitValue,
530
536
  offset: this.offsetValue,
531
- sortBy: this.sortBy,
537
+ sortBy: this._sortField,
532
538
  sortDirection: this.sortDirection,
533
539
  };
534
540
  }
@@ -541,12 +547,13 @@ export class QueryBuilder {
541
547
  cloned.fieldMap = this.fieldMap ? {...this.fieldMap} : undefined;
542
548
  cloned.limitValue = this.limitValue;
543
549
  cloned.offsetValue = this.offsetValue;
544
- cloned.sortBy = this.sortBy;
550
+ cloned._sortField = this._sortField;
545
551
  cloned.sortDirection = this.sortDirection;
546
552
  cloned.includeHistoryValue = this.includeHistoryValue;
547
553
  cloned.collectionName = this.collectionName;
548
- cloned.serverJoinConfigs = [...this.serverJoinConfigs]; // Deep copy server join configs
549
- cloned.joinConfigs = [...this.joinConfigs]; // Deep copy client join configs (deprecated)
554
+ cloned.serverJoinConfigs = [...this.serverJoinConfigs];
555
+ cloned._groupBy = this._groupBy ? [...this._groupBy] : undefined;
556
+ cloned._aggregate = this._aggregate ? {...this._aggregate} : undefined;
550
557
  return cloned;
551
558
  }
552
559
 
@@ -555,6 +562,22 @@ export class QueryBuilder {
555
562
  this.serverJoinConfigs.push(config);
556
563
  }
557
564
 
565
+ // Internal helpers used by aggregation methods and GroupByQueryBuilder after cloning
566
+ _setSelections(select: SelectionMap): this {
567
+ this.selections = select;
568
+ return this;
569
+ }
570
+
571
+ _setGroupBy(fields: string[]): this {
572
+ this._groupBy = fields;
573
+ return this;
574
+ }
575
+
576
+ _setAggregate(agg: AggregateSpec): this {
577
+ this._aggregate = agg;
578
+ return this;
579
+ }
580
+
558
581
  // Execute a simple query without joins
559
582
  private async executeSimpleQuery<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
560
583
  const request = this.getQueryRequest();
@@ -601,73 +624,6 @@ export class QueryBuilder {
601
624
  }
602
625
  }
603
626
 
604
- // Execute query with joins using parallel queries
605
- private async executeWithJoins<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
606
- // First, get the main collection records
607
- const mainQuery = this.clone();
608
- mainQuery.joinConfigs = []; // Remove joins for main query
609
- const mainResults = await mainQuery.executeSimpleQuery();
610
-
611
- if (mainResults.records.length === 0) {
612
- return mainResults as QueryResponse<T>;
613
- }
614
-
615
- // For each join, execute parallel queries and merge results
616
- const joinResults: Record<string, any[]> = {};
617
-
618
- for (const joinConfig of this.joinConfigs) {
619
- // Get all unique values from the main results for the join key
620
- const joinKeys = [...new Set(
621
- mainResults.records
622
- .map(record => record[joinConfig.childField])
623
- .filter(key => key !== null && key !== undefined)
624
- )];
625
-
626
- if (joinKeys.length > 0) {
627
- // Query the joined collection
628
- const joinQuery = new QueryBuilder(this.httpClient, this.serverUrl, this.app);
629
- const joinResponse = await joinQuery
630
- .collection(joinConfig.parentCollection)
631
- .whereField(joinConfig.parentField).in(joinKeys)
632
- .selectAll()
633
- .executeSimpleQuery();
634
-
635
- joinResults[joinConfig.alias] = joinResponse.records;
636
- } else {
637
- joinResults[joinConfig.alias] = [];
638
- }
639
- }
640
-
641
- // Merge join results with main results
642
- const mergedRecords = mainResults.records.map(mainRecord => {
643
- const merged: any = {...mainRecord};
644
-
645
- for (const joinConfig of this.joinConfigs) {
646
- const joinKey = mainRecord[joinConfig.childField];
647
- const relatedRecords = joinResults[joinConfig.alias].filter(
648
- joinRecord => joinRecord[joinConfig.parentField] === joinKey
649
- );
650
-
651
- // For one-to-many relationships, return array; for many-to-one, return single object
652
- if (joinConfig.parentCollection === this.collectionName) {
653
- // Self-referential join (like replies to tweets)
654
- merged[joinConfig.alias] = relatedRecords;
655
- } else {
656
- // Cross-collection join
657
- merged[joinConfig.alias] = relatedRecords.length > 0 ? relatedRecords[0] : null;
658
- }
659
- }
660
-
661
- return merged;
662
- });
663
-
664
- return {
665
- records: mergedRecords,
666
- limit: mainResults.limit,
667
- page: mainResults.page,
668
- total: mainResults.total
669
- } as QueryResponse<T>;
670
- }
671
627
 
672
628
  // Build the query value object for HTTP requests
673
629
  private buildQueryValue(): QueryValue {
@@ -704,6 +660,21 @@ export class QueryBuilder {
704
660
  queryValue.include_history = this.includeHistoryValue;
705
661
  }
706
662
 
663
+ // Send fieldMap to Scepter so it can rename output fields server-side
664
+ if (this.fieldMap && Object.keys(this.fieldMap).length > 0) {
665
+ queryValue.field_map = this.fieldMap;
666
+ }
667
+
668
+ // Send group_by as an array so Scepter performs server-side grouping
669
+ if (this._groupBy && this._groupBy.length > 0) {
670
+ queryValue.group_by = this._groupBy;
671
+ }
672
+
673
+ // Send aggregate separately — never embed in select
674
+ if (this._aggregate && Object.keys(this._aggregate).length > 0) {
675
+ queryValue.aggregate = this._aggregate;
676
+ }
677
+
707
678
  return queryValue;
708
679
  }
709
680
  }
@@ -743,9 +714,14 @@ export class WhereClause {
743
714
  isLocalIp(): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).isLocalIp()); }
744
715
  isExternalIp(): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).isExternalIp()); }
745
716
  b64(value: string): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).b64(value)); }
746
- inDataset(dataset: string): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).inDataset(dataset)); }
717
+ /** Case-sensitive membership check (use .in() for case-insensitive). */
718
+ inDataset(values: string[]): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).inDataset(values)); }
747
719
  inCountry(countryCode: string): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).inCountry(countryCode)); }
748
- cidr(cidr: string): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).cidr(cidr)); }
720
+ /** Check if IP falls within CIDR range(s). Always uses "$cidr" alias to avoid Scepter stack overflow. */
721
+ cidr(ranges: string | string[]): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).cidr(ranges)); }
722
+ /** Matches if the field contains ANY of the given keywords (case-insensitive substring). */
723
+ keywords(keywords: string[]): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).keywords(keywords)); }
724
+ /** Exclusive range: matches records where min < field < max (both bounds excluded). */
749
725
  between(min: any, max: any): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).between(min, max)); }
750
726
  isTrue(): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).isTrue()); }
751
727
  isFalse(): QueryBuilder { return this.set(new FieldConditionBuilder(this.fieldName).isFalse()); }
@@ -914,119 +890,120 @@ export class GroupByQueryBuilder {
914
890
  ) {}
915
891
 
916
892
  /**
917
- * Count records in each group
893
+ * Count records in each group.
894
+ * Executes a server-side groupBy + $count aggregation.
918
895
  * @returns Promise resolving to map of group key -> count
919
896
  */
920
897
  async count(): Promise<Record<string, number>> {
921
- const response = await this.queryBuilder.execute();
922
- if (!response.records) return {};
923
-
924
- const groups: Record<string, number> = {};
925
- for (const record of response.records) {
926
- const key = this.getGroupKey(record);
927
- groups[key] = (groups[key] || 0) + 1;
928
- }
929
- return groups;
898
+ const clone = this.queryBuilder.clone();
899
+ clone._setGroupBy([this.groupByField]);
900
+ clone._setSelections({});
901
+ clone._setAggregate({ _count: { '$count': '*' } });
902
+ const response = await clone.execute();
903
+ return this.buildGroupMap<number>(response.records, '_count');
930
904
  }
931
905
 
932
906
  /**
933
- * Sum a numeric field within each group
907
+ * Sum a numeric field within each group.
908
+ * Executes a server-side groupBy + $sum aggregation.
934
909
  * @param field - The field name to sum
935
910
  * @returns Promise resolving to map of group key -> sum
936
911
  */
937
912
  async sumBy(field: string): Promise<Record<string, number>> {
938
- const response = await this.queryBuilder.execute();
939
- if (!response.records) return {};
940
-
941
- const groups: Record<string, number> = {};
942
- for (const record of response.records) {
943
- const key = this.getGroupKey(record);
944
- const value = (record as any)[field];
945
- groups[key] = (groups[key] || 0) + (typeof value === 'number' ? value : 0);
946
- }
947
- return groups;
913
+ const clone = this.queryBuilder.clone();
914
+ clone._setGroupBy([this.groupByField]);
915
+ clone._setSelections({});
916
+ clone._setAggregate({ _result: { '$sum': field } });
917
+ const response = await clone.execute();
918
+ return this.buildGroupMap<number>(response.records, '_result');
948
919
  }
949
920
 
950
921
  /**
951
- * Calculate average of a numeric field within each group
922
+ * Calculate average of a numeric field within each group.
923
+ * Executes a server-side groupBy + $avg aggregation.
952
924
  * @param field - The field name to average
953
925
  * @returns Promise resolving to map of group key -> average
954
926
  */
955
927
  async avgBy(field: string): Promise<Record<string, number>> {
956
- const response = await this.queryBuilder.execute();
957
- if (!response.records) return {};
958
-
959
- const groups: Record<string, { sum: number; count: number }> = {};
960
- for (const record of response.records) {
961
- const key = this.getGroupKey(record);
962
- const value = (record as any)[field];
963
- if (!groups[key]) {
964
- groups[key] = { sum: 0, count: 0 };
965
- }
966
- groups[key].sum += typeof value === 'number' ? value : 0;
967
- groups[key].count += 1;
968
- }
969
-
970
- const result: Record<string, number> = {};
971
- for (const [key, { sum, count }] of Object.entries(groups)) {
972
- result[key] = count > 0 ? sum / count : 0;
973
- }
974
- return result;
928
+ const clone = this.queryBuilder.clone();
929
+ clone._setGroupBy([this.groupByField]);
930
+ clone._setSelections({});
931
+ clone._setAggregate({ _result: { '$avg': field } });
932
+ const response = await clone.execute();
933
+ return this.buildGroupMap<number>(response.records, '_result');
975
934
  }
976
935
 
977
936
  /**
978
- * Find maximum value of a field within each group
937
+ * Find maximum value of a field within each group.
938
+ * Executes a server-side groupBy + $max aggregation.
979
939
  * @param field - The field name to find maximum
980
940
  * @returns Promise resolving to map of group key -> max value
981
941
  */
982
942
  async maxBy<T = any>(field: string): Promise<Record<string, T>> {
983
- const response = await this.queryBuilder.execute();
984
- if (!response.records) return {};
985
-
986
- const groups: Record<string, T> = {};
987
- for (const record of response.records) {
988
- const key = this.getGroupKey(record);
989
- const value = (record as any)[field] as T;
990
- if (!(key in groups) || value > (groups[key] as T)) {
991
- groups[key] = value;
992
- }
993
- }
994
- return groups;
943
+ const clone = this.queryBuilder.clone();
944
+ clone._setGroupBy([this.groupByField]);
945
+ clone._setSelections({});
946
+ clone._setAggregate({ _result: { '$max': field } });
947
+ const response = await clone.execute();
948
+ return this.buildGroupMap<T>(response.records, '_result');
995
949
  }
996
950
 
997
951
  /**
998
- * Find minimum value of a field within each group
952
+ * Find minimum value of a field within each group.
953
+ * Executes a server-side groupBy + $min aggregation.
999
954
  * @param field - The field name to find minimum
1000
955
  * @returns Promise resolving to map of group key -> min value
1001
956
  */
1002
957
  async minBy<T = any>(field: string): Promise<Record<string, T>> {
1003
- const response = await this.queryBuilder.execute();
1004
- if (!response.records) return {};
958
+ const clone = this.queryBuilder.clone();
959
+ clone._setGroupBy([this.groupByField]);
960
+ clone._setSelections({});
961
+ clone._setAggregate({ _result: { '$min': field } });
962
+ const response = await clone.execute();
963
+ return this.buildGroupMap<T>(response.records, '_result');
964
+ }
1005
965
 
1006
- const groups: Record<string, T> = {};
1007
- for (const record of response.records) {
1008
- const key = this.getGroupKey(record);
1009
- const value = (record as any)[field] as T;
1010
- if (!(key in groups) || value < (groups[key] as T)) {
1011
- groups[key] = value;
1012
- }
1013
- }
1014
- return groups;
966
+ /**
967
+ * Run multiple aggregations per group in a single HTTP round-trip.
968
+ *
969
+ * Each key in `spec` becomes an output alias on each returned record (alongside the group field).
970
+ * Supported operators: `$count`, `$sum`, `$avg`, `$max`, `$min`, `$countDistinct`.
971
+ *
972
+ * @param spec - Map of output alias to `{ '$op': 'field' }` descriptor
973
+ * @returns Promise resolving to an array of records, each containing the group field and all aggregation results
974
+ *
975
+ * @example
976
+ * ```typescript
977
+ * const rows = await db.queryBuilder()
978
+ * .collection('orders')
979
+ * .groupBy('category')
980
+ * .run({
981
+ * total: { '$count': '*' },
982
+ * revenue: { '$sum': 'amount' },
983
+ * avgOrder: { '$avg': 'amount' }
984
+ * });
985
+ * // Returns: [{ category: 'electronics', total: 12, revenue: 5000, avgOrder: 416.67 }, ...]
986
+ * ```
987
+ */
988
+ async run(spec: AggregateSpec): Promise<Record<string, any>[]> {
989
+ const clone = this.queryBuilder.clone();
990
+ clone._setGroupBy([this.groupByField]);
991
+ clone._setSelections({});
992
+ clone._setAggregate(spec);
993
+ const response = await clone.execute();
994
+ return (response.records as any[]) ?? [];
1015
995
  }
1016
996
 
1017
997
  /**
1018
- * Get the group key from a record, supporting nested field paths
998
+ * Build a Record<groupKey, aggValue> from the records returned by Scepter.
999
+ * Each record contains the group field and the aggregated value under aggKey.
1019
1000
  */
1020
- private getGroupKey(record: any): string {
1021
- // Support nested field paths (e.g., "user.country")
1022
- if (this.groupByField.includes('.')) {
1023
- const parts = this.groupByField.split('.');
1024
- let current = record;
1025
- for (const part of parts) {
1026
- current = current?.[part];
1027
- }
1028
- return String(current ?? 'null');
1001
+ private buildGroupMap<T>(records: any[], aggKey: string): Record<string, T> {
1002
+ const result: Record<string, T> = {};
1003
+ for (const record of records ?? []) {
1004
+ const key = String(record[this.groupByField] ?? 'null');
1005
+ result[key] = record[aggKey];
1029
1006
  }
1030
- return String(record[this.groupByField] ?? 'null');
1007
+ return result;
1031
1008
  }
1032
1009
  }
@@ -8,6 +8,12 @@ export interface QueryValue {
8
8
  find: any;
9
9
  select: any;
10
10
  include_history?: boolean;
11
+ /** Maps logical field names to one or more physical source fields. Values are arrays. */
12
+ field_map?: Record<string, string[]>;
13
+ /** Group-by fields for server-side aggregation. Pass as an array, e.g. ["category"]. */
14
+ group_by?: string[];
15
+ /** Server-side aggregation operations. Keys are output aliases, values are typed AggregateOp objects. */
16
+ aggregate?: AggregateSpec;
11
17
  }
12
18
 
13
19
  export type QueryRequest = QueryValue & {
@@ -24,7 +30,19 @@ export interface QueryResponse<T = any> {
24
30
 
25
31
  // Selection types
26
32
  export type SelectionMap = Record<string, any>;
27
- export type FieldMap = Record<string, string>;
33
+ /** Maps logical field names to one or more physical source fields. */
34
+ export type FieldMap = Record<string, string[]>;
35
+
36
+ // Aggregation operation types — mirrors Rust's AggregateOp untagged enum (query_input.rs)
37
+ export type AggCountOp = { '$count': string }; // '*' counts all records; or a field name
38
+ export type AggSumOp = { '$sum': string };
39
+ export type AggAvgOp = { '$avg': string };
40
+ export type AggMaxOp = { '$max': string };
41
+ export type AggMinOp = { '$min': string };
42
+ export type AggCountDistinctOp = { '$countDistinct': string };
43
+ export type AggregateOp = AggCountOp | AggSumOp | AggAvgOp | AggMaxOp | AggMinOp | AggCountDistinctOp;
44
+ /** Maps output alias names to aggregation operations. */
45
+ export type AggregateSpec = Record<string, AggregateOp>;
28
46
 
29
47
  // Operator values - expanded to match Rust SDK
30
48
  export type Val = string | number | boolean | null | Val[] | Date | RegExp;