appflare 0.0.9 → 0.0.10

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.
@@ -13,6 +13,8 @@ export const buildHandlerFileContent = (
13
13
  ): string => {
14
14
  const findFn = `find${params.pascalName}`;
15
15
  const findOneFn = `findOne${params.pascalName}`;
16
+ const sumFn = `sum${params.pascalName}`;
17
+ const avgFn = `avg${params.pascalName}`;
16
18
  const insertFn = `insert${params.pascalName}`;
17
19
  const updateFn = `update${params.pascalName}`;
18
20
  const deleteFn = `delete${params.pascalName}`;
@@ -26,7 +28,9 @@ import { z } from "zod";
26
28
  import {
27
29
  internalMutation,
28
30
  internalQuery,
31
+ type AppflareInclude,
29
32
  type EditableDoc,
33
+ type Doc,
30
34
  type Id,
31
35
  type QuerySort,
32
36
  type QueryWhere,
@@ -40,6 +44,8 @@ export const ${findFn} = internalQuery({
40
44
  .optional(),
41
45
  limit: z.number().int().nonnegative().optional(),
42
46
  offset: z.number().int().nonnegative().optional(),
47
+ include: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
48
+ .optional(),
43
49
  },
44
50
  handler: async (ctx, args) => {
45
51
  return ctx.db[${JSON.stringify(params.tableName)} as any].findMany({
@@ -47,6 +53,7 @@ export const ${findFn} = internalQuery({
47
53
  orderBy: args.sort as any,
48
54
  skip: args.offset,
49
55
  take: args.limit,
56
+ include: args.include as any,
50
57
  });
51
58
  },
52
59
  });
@@ -58,6 +65,8 @@ export const ${findOneFn} = internalQuery({
58
65
  sort: z.custom<QuerySort<${JSON.stringify(params.tableName)}>>()
59
66
  .optional(),
60
67
  offset: z.number().int().nonnegative().optional(),
68
+ include: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
69
+ .optional(),
61
70
  },
62
71
  handler: async (ctx, args) => {
63
72
  return ctx.db[${JSON.stringify(params.tableName)} as any].findFirst({
@@ -65,6 +74,45 @@ export const ${findOneFn} = internalQuery({
65
74
  orderBy: args.sort as any,
66
75
  skip: args.offset,
67
76
  take: 1,
77
+ include: args.include as any,
78
+ });
79
+ },
80
+ });
81
+
82
+ export const ${sumFn} = internalQuery({
83
+ args: {
84
+ fields: z.array(z.string()).min(1),
85
+ where: z.custom<QueryWhere<${JSON.stringify(params.tableName)}>>()
86
+ .optional(),
87
+ groupBy: z.union([z.string(), z.array(z.string())]).optional(),
88
+ populate: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
89
+ .optional(),
90
+ },
91
+ handler: async (ctx, args) => {
92
+ return ctx.db[${JSON.stringify(params.tableName)} as any].aggregate({
93
+ where: args.where as any,
94
+ groupBy: args.groupBy as any,
95
+ sum: args.fields as any,
96
+ populate: args.populate as any,
97
+ });
98
+ },
99
+ });
100
+
101
+ export const ${avgFn} = internalQuery({
102
+ args: {
103
+ fields: z.array(z.string()).min(1),
104
+ where: z.custom<QueryWhere<${JSON.stringify(params.tableName)}>>()
105
+ .optional(),
106
+ groupBy: z.union([z.string(), z.array(z.string())]).optional(),
107
+ populate: z.custom<AppflareInclude<Doc<${JSON.stringify(params.tableName)}>>>()
108
+ .optional(),
109
+ },
110
+ handler: async (ctx, args) => {
111
+ return ctx.db[${JSON.stringify(params.tableName)} as any].aggregate({
112
+ where: args.where as any,
113
+ groupBy: args.groupBy as any,
114
+ avg: args.fields as any,
115
+ populate: args.populate as any,
68
116
  });
69
117
  },
70
118
  });
@@ -121,11 +169,13 @@ export const ${deleteFn} = internalMutation({
121
169
  export const buildExportLine = (params: ExportLineParams): string => {
122
170
  const findFn = `find${params.pascalName}`;
123
171
  const findOneFn = `findOne${params.pascalName}`;
172
+ const sumFn = `sum${params.pascalName}`;
173
+ const avgFn = `avg${params.pascalName}`;
124
174
  const insertFn = `insert${params.pascalName}`;
125
175
  const updateFn = `update${params.pascalName}`;
126
176
  const deleteFn = `delete${params.pascalName}`;
127
177
 
128
- return `export { ${findFn}, ${findOneFn}, ${insertFn}, ${updateFn}, ${deleteFn} } from "./${params.tableName}";`;
178
+ return `export { ${findFn}, ${findOneFn}, ${sumFn}, ${avgFn}, ${insertFn}, ${updateFn}, ${deleteFn} } from "./${params.tableName}";`;
129
179
  };
130
180
 
131
181
  export const buildIndexFileContent = (
@@ -9,6 +9,11 @@ type ExtractIdTableName<T> = NonNil<T> extends Id<infer TTable>
9
9
  ? ExtractIdTableName<TItem>
10
10
  : never;
11
11
 
12
+ type NumericKeys<T> =
13
+ {
14
+ [K in keyof T]: NonNil<T[K]> extends number | bigint ? K : never;
15
+ }[keyof T] & string;
16
+
12
17
  type PopulateValue<T> = T extends Id<infer TTable>
13
18
  ? (TTable extends TableNames ? Doc<TTable> : never)
14
19
  : T extends Array<infer TItem>
@@ -52,6 +57,100 @@ export type QuerySort<TableName extends TableNames> =
52
57
  | Record<string, SortDirection>
53
58
  | Array<[string, SortDirection]>;
54
59
 
60
+ type GeoCoordinates = readonly [number, number];
61
+
62
+ export type GeoPoint = {
63
+ type: "Point";
64
+ coordinates: GeoCoordinates;
65
+ };
66
+
67
+ type GeoPointInput = GeoPoint | GeoCoordinates;
68
+
69
+ const GEO_EARTH_RADIUS_METERS = 6_378_100;
70
+
71
+ const __geoNormalizePoint = (point: GeoPointInput): GeoPoint =>
72
+ Array.isArray(point)
73
+ ? { type: "Point", coordinates: [point[0], point[1]] }
74
+ : point;
75
+
76
+ export const geo = {
77
+ point(lng: number, lat: number): GeoPoint {
78
+ return { type: "Point", coordinates: [lng, lat] };
79
+ },
80
+ near(
81
+ field: string,
82
+ point: GeoPointInput,
83
+ options: { maxDistanceMeters?: number; minDistanceMeters?: number } = {}
84
+ ): Record<string, unknown> {
85
+ const $near: Record<string, unknown> = {
86
+ $geometry: __geoNormalizePoint(point),
87
+ };
88
+ if (options.maxDistanceMeters !== undefined)
89
+ $near.$maxDistance = options.maxDistanceMeters;
90
+ if (options.minDistanceMeters !== undefined)
91
+ $near.$minDistance = options.minDistanceMeters;
92
+ return { [field]: { $near } };
93
+ },
94
+ withinRadius(
95
+ field: string,
96
+ center: GeoPointInput,
97
+ radiusMeters: number
98
+ ): Record<string, unknown> {
99
+ return {
100
+ [field]: {
101
+ $geoWithin: {
102
+ $centerSphere: [
103
+ __geoNormalizePoint(center).coordinates,
104
+ radiusMeters / GEO_EARTH_RADIUS_METERS,
105
+ ],
106
+ },
107
+ },
108
+ };
109
+ },
110
+ withinBox(
111
+ field: string,
112
+ southwest: GeoPointInput,
113
+ northeast: GeoPointInput
114
+ ): Record<string, unknown> {
115
+ return {
116
+ [field]: {
117
+ $geoWithin: {
118
+ $box: [
119
+ __geoNormalizePoint(southwest).coordinates,
120
+ __geoNormalizePoint(northeast).coordinates,
121
+ ],
122
+ },
123
+ },
124
+ };
125
+ },
126
+ withinPolygon(
127
+ field: string,
128
+ polygon: ReadonlyArray<GeoPointInput>
129
+ ): Record<string, unknown> {
130
+ return {
131
+ [field]: {
132
+ $geoWithin: {
133
+ $polygon: polygon.map((p) =>
134
+ __geoNormalizePoint(p).coordinates
135
+ ),
136
+ },
137
+ },
138
+ };
139
+ },
140
+ intersects(
141
+ field: string,
142
+ geometry: { type: string; coordinates: unknown }
143
+ ): Record<string, unknown> {
144
+ return {
145
+ [field]: {
146
+ $geoIntersects: {
147
+ $geometry: geometry,
148
+ },
149
+ },
150
+ };
151
+ },
152
+ };
153
+
55
154
  type SelectedKeys<TDoc, TSelect> =
56
155
  TSelect extends readonly (infer TKey)[]
57
156
  ? Extract<TKey, Keys<TDoc>>
@@ -70,14 +169,70 @@ export type AppflareSelect<TDoc> =
70
169
  | ReadonlyArray<Keys<TDoc>>
71
170
  | Partial<Record<Keys<TDoc>, boolean>>;
72
171
 
172
+ type PopulatedDocForKey<TDoc, TKey extends Keys<TDoc>> =
173
+ NonNil<PopulateValue<TDoc[TKey]>> extends Array<infer TItem>
174
+ ? NonNil<TItem>
175
+ : NonNil<PopulateValue<TDoc[TKey]>>;
176
+
177
+ type AggregateSpec<TDoc> = {
178
+ count?: boolean;
179
+ sum?: ReadonlyArray<NumericKeys<TDoc>>;
180
+ avg?: ReadonlyArray<NumericKeys<TDoc>>;
181
+ };
182
+
183
+ type AppflareIncludeRecord<TDoc> = {
184
+ [K in PopulatableKeys<TDoc>]?:
185
+ | boolean
186
+ | {
187
+ aggregate?: AggregateSpec<PopulatedDocForKey<TDoc, K>>;
188
+ includeDocs?: boolean;
189
+ };
190
+ };
191
+
73
192
  export type AppflareInclude<TDoc> =
74
193
  | ReadonlyArray<PopulatableKeys<TDoc>>
75
- | Partial<Record<PopulatableKeys<TDoc>, boolean>>;
76
-
77
- type AppflareResultDoc<TDoc, TSelect, TInclude> = WithSelected<
78
- WithPopulatedMany<TDoc, IncludedKeys<TDoc, TInclude>>,
79
- SelectedKeys<TDoc, TSelect>
80
- >;
194
+ | AppflareIncludeRecord<TDoc>;
195
+
196
+ type ExtractAggregateSpec<T> = T extends { aggregate?: infer TSpec } ? TSpec : never;
197
+
198
+ type AggregateResultFromSpec<TDoc, TSpec> = (TSpec extends { count: true }
199
+ ? { count: number }
200
+ : {}) &
201
+ (TSpec extends { sum: infer TSum }
202
+ ? TSum extends ReadonlyArray<infer K>
203
+ ? { [P in Extract<K, NumericKeys<TDoc>> as \`sum_\${P}\`]: number }
204
+ : {}
205
+ : {}) &
206
+ (TSpec extends { avg: infer TAvg }
207
+ ? TAvg extends ReadonlyArray<infer K>
208
+ ? { [P in Extract<K, NumericKeys<TDoc>> as \`avg_\${P}\`]: number }
209
+ : {}
210
+ : {});
211
+
212
+ type AggregateMapForInclude<TDoc, TInclude> = TInclude extends ReadonlyArray<any>
213
+ ? {}
214
+ : {
215
+ [K in keyof TInclude as ExtractAggregateSpec<TInclude[K]> extends never
216
+ ? never
217
+ : K]: AggregateResultFromSpec<
218
+ PopulatedDocForKey<TDoc, Extract<K, Keys<TDoc>>>,
219
+ ExtractAggregateSpec<TInclude[K]>
220
+ >;
221
+ };
222
+
223
+ type WithAggregatesForInclude<TDoc, TInclude> = keyof AggregateMapForInclude<
224
+ TDoc,
225
+ TInclude
226
+ > extends never
227
+ ? {}
228
+ : { _aggregates: AggregateMapForInclude<TDoc, TInclude> };
229
+
230
+ type AppflareResultDoc<TDoc, TSelect, TInclude> =
231
+ WithSelected<
232
+ WithPopulatedMany<TDoc, IncludedKeys<TDoc, TInclude>>,
233
+ SelectedKeys<TDoc, TSelect>
234
+ > &
235
+ WithAggregatesForInclude<TDoc, TInclude>;
81
236
 
82
237
  export type AppflareFindManyArgs<TableName extends TableNames> = {
83
238
  where?: QueryWhere<TableName>;
@@ -130,6 +285,55 @@ export type AppflareCountArgs<TableName extends TableNames> = {
130
285
  where?: QueryWhere<TableName>;
131
286
  };
132
287
 
288
+ type AggregateGroupInput<TDoc> =
289
+ | ReadonlyArray<keyof TDoc & string>
290
+ | (keyof TDoc & string);
291
+
292
+ type NormalizeGroupInput<TDoc, TGroup> =
293
+ TGroup extends readonly (infer K)[]
294
+ ? ReadonlyArray<Extract<K, keyof TDoc & string>>
295
+ : TGroup extends string
296
+ ? ReadonlyArray<Extract<TGroup, keyof TDoc & string>>
297
+ : ReadonlyArray<never>;
298
+
299
+ type AggregateId<TDoc, TGroup extends ReadonlyArray<keyof TDoc & string>> =
300
+ TGroup extends readonly []
301
+ ? null
302
+ : TGroup extends readonly [infer K]
303
+ ? TDoc[Extract<K, keyof TDoc>]
304
+ : { [K in TGroup[number]]: TDoc[K] };
305
+
306
+ type AggregateResult<
307
+ TDoc,
308
+ TGroup extends ReadonlyArray<keyof TDoc & string>,
309
+ TSum extends ReadonlyArray<NumericKeys<TDoc>>,
310
+ TAvg extends ReadonlyArray<NumericKeys<TDoc>>,
311
+ > = {
312
+ _id: AggregateId<TDoc, TGroup>;
313
+ } & (TSum[number] extends never ? {} : { [K in TSum[number] as \`sum_\${K}\`]: number }) &
314
+ (TAvg[number] extends never ? {} : { [K in TAvg[number] as \`avg_\${K}\`]: number });
315
+
316
+ export type AppflareAggregateArgs<
317
+ TableName extends TableNames,
318
+ TGroup = AggregateGroupInput<TableDocMap[TableName]>,
319
+ TSum extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
320
+ NumericKeys<TableDocMap[TableName]>
321
+ >,
322
+ TAvg extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
323
+ NumericKeys<TableDocMap[TableName]>
324
+ >,
325
+ > = {
326
+ where?: QueryWhere<TableName>;
327
+ groupBy?: TGroup;
328
+ sum?: TSum;
329
+ avg?: TAvg;
330
+ /**
331
+ * Populate aggregated group keys that are references to other tables.
332
+ * Only keys present in groupBy are eligible for populate.
333
+ */
334
+ populate?: AppflareInclude<TableDocMap[TableName]>;
335
+ };
336
+
133
337
  export type AppflareTableClient<TableName extends TableNames> = {
134
338
  findMany<TSelect = AppflareSelect<TableDocMap[TableName]>, TInclude = never>(
135
339
  args?: AppflareFindManyArgs<TableName> & {
@@ -197,6 +401,26 @@ export type AppflareTableClient<TableName extends TableNames> = {
197
401
  >;
198
402
  deleteMany(args?: AppflareDeleteManyArgs<TableName>): Promise<{ count: number }>;
199
403
  count(args?: AppflareCountArgs<TableName>): Promise<number>;
404
+ aggregate<
405
+ TGroup = AggregateGroupInput<TableDocMap[TableName]>,
406
+ TSum extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
407
+ NumericKeys<TableDocMap[TableName]>
408
+ >,
409
+ TAvg extends ReadonlyArray<NumericKeys<TableDocMap[TableName]>> = ReadonlyArray<
410
+ NumericKeys<TableDocMap[TableName]>
411
+ >,
412
+ >(
413
+ args: AppflareAggregateArgs<TableName, TGroup, TSum, TAvg>
414
+ ): Promise<
415
+ Array<
416
+ AggregateResult<
417
+ TableDocMap[TableName],
418
+ NormalizeGroupInput<TableDocMap[TableName], TGroup>,
419
+ TSum,
420
+ TAvg
421
+ >
422
+ >
423
+ >;
200
424
  };
201
425
 
202
426
  export type AppflareModelMap = {
@@ -37,6 +37,12 @@ function renderField(fieldName: string, schema: any): string {
37
37
  function renderType(schema: any): { tsType: string; optional: boolean } {
38
38
  const def = schema?._def;
39
39
  const typeName: string | undefined = def?.typeName ?? def?.type;
40
+ const description: string | undefined =
41
+ schema?.description ?? def?.description;
42
+
43
+ if (description === "geo:point") {
44
+ return { tsType: "GeoPoint", optional: false };
45
+ }
40
46
 
41
47
  if (typeName === "ZodOptional" || typeName === "optional") {
42
48
  const inner = def?.innerType ?? def?.schema ?? schema?._def?.innerType;
package/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./lib/db";
2
2
  export * from "./lib/values";
3
+ export * from "./lib/location";
3
4
  export { AppflareConfig } from "./cli/utils/utils";
package/lib/README.md CHANGED
@@ -19,10 +19,16 @@ Docs for the shared library helpers in `lib/`
19
19
 
20
20
  - Primitives: `v.string()`, `v.number()`, `v.boolean()`, `v.date()`.
21
21
  - Relations/ids: `v.id(table)` creates a string with an ObjectId regex and a `ref:<table>` description for downstream typing.
22
+ - Location: `v.point()`/`v.location()` yields a GeoJSON Point schema for geospatial queries.
22
23
  - Collections/objects: `v.array(item)`, `v.object(shape)`.
23
24
  - Modifiers: `v.optional(schema)`, `v.nullable(schema)`, `v.union(...schemas)`, `v.literal(value)`.
24
25
  - Misc: `v.buffer()` (string placeholder), `v.any()`, `v.unknown()`.
25
26
 
27
+ **Location helpers** ([packages/appflare/lib/location.ts](packages/appflare/lib/location.ts))
28
+
29
+ - Builders: `point(lng, lat)` returns a GeoJSON Point; `geo.point` is the same alias.
30
+ - Queries: `geo.near("location", pointLngLat, { maxDistanceMeters })`, `geo.withinRadius("location", center, radiusMeters)`, `geo.withinBox(...)`, `geo.withinPolygon(...)`, `geo.intersects(...)` produce Mongo-ready filters you can pass to `where`.
31
+
26
32
  **Example: define a schema**
27
33
 
28
34
  ```ts
@@ -35,6 +41,7 @@ export default defineSchema({
35
41
  email: v.string(),
36
42
  age: v.number().optional(),
37
43
  orgId: v.id("orgs"),
44
+ location: v.point(),
38
45
  }),
39
46
  orgs: defineTable({
40
47
  name: v.string(),
@@ -0,0 +1,110 @@
1
+ import { z } from "zod";
2
+
3
+ export type GeoCoordinates = readonly [number, number];
4
+
5
+ export type GeoPoint = {
6
+ type: "Point";
7
+ coordinates: GeoCoordinates;
8
+ };
9
+
10
+ export const geoPointSchema = z
11
+ .object({
12
+ type: z.literal("Point"),
13
+ coordinates: z.tuple([z.number(), z.number()]),
14
+ })
15
+ .describe("geo:point");
16
+
17
+ export const EARTH_RADIUS_METERS = 6_378_100;
18
+
19
+ const normalizePoint = (point: GeoPoint | GeoCoordinates): GeoPoint =>
20
+ Array.isArray(point)
21
+ ? { type: "Point", coordinates: [point[0], point[1]] }
22
+ : (point as GeoPoint);
23
+
24
+ export const point = (lng: number, lat: number): GeoPoint => ({
25
+ type: "Point",
26
+ coordinates: [lng, lat],
27
+ });
28
+
29
+ export type NearQueryOptions = {
30
+ maxDistanceMeters?: number;
31
+ minDistanceMeters?: number;
32
+ };
33
+
34
+ export const near = (
35
+ field: string,
36
+ geometry: GeoPoint | GeoCoordinates,
37
+ options: NearQueryOptions = {}
38
+ ): Record<string, unknown> => {
39
+ const $near: Record<string, unknown> = {
40
+ $geometry: normalizePoint(geometry),
41
+ };
42
+ if (options.maxDistanceMeters !== undefined) {
43
+ $near.$maxDistance = options.maxDistanceMeters;
44
+ }
45
+ if (options.minDistanceMeters !== undefined) {
46
+ $near.$minDistance = options.minDistanceMeters;
47
+ }
48
+ return { [field]: { $near } };
49
+ };
50
+
51
+ export const withinRadius = (
52
+ field: string,
53
+ center: GeoPoint | GeoCoordinates,
54
+ radiusMeters: number
55
+ ): Record<string, unknown> => ({
56
+ [field]: {
57
+ $geoWithin: {
58
+ $centerSphere: [
59
+ normalizePoint(center).coordinates,
60
+ radiusMeters / EARTH_RADIUS_METERS,
61
+ ],
62
+ },
63
+ },
64
+ });
65
+
66
+ export const withinBox = (
67
+ field: string,
68
+ southwest: GeoPoint | GeoCoordinates,
69
+ northeast: GeoPoint | GeoCoordinates
70
+ ): Record<string, unknown> => ({
71
+ [field]: {
72
+ $geoWithin: {
73
+ $box: [
74
+ normalizePoint(southwest).coordinates,
75
+ normalizePoint(northeast).coordinates,
76
+ ],
77
+ },
78
+ },
79
+ });
80
+
81
+ export const withinPolygon = (
82
+ field: string,
83
+ polygon: ReadonlyArray<GeoPoint | GeoCoordinates>
84
+ ): Record<string, unknown> => ({
85
+ [field]: {
86
+ $geoWithin: {
87
+ $polygon: polygon.map((p) => normalizePoint(p).coordinates),
88
+ },
89
+ },
90
+ });
91
+
92
+ export const intersects = (
93
+ field: string,
94
+ geometry: { type: string; coordinates: unknown }
95
+ ): Record<string, unknown> => ({
96
+ [field]: {
97
+ $geoIntersects: {
98
+ $geometry: geometry,
99
+ },
100
+ },
101
+ });
102
+
103
+ export const geo = {
104
+ point,
105
+ near,
106
+ withinRadius,
107
+ withinBox,
108
+ withinPolygon,
109
+ intersects,
110
+ };
package/lib/values.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { z } from "zod";
2
+ import { geoPointSchema } from "./location";
2
3
 
3
4
  export const v = {
4
5
  string: () => z.string(),
5
6
  number: () => z.number(),
6
7
  boolean: () => z.boolean(),
7
8
  date: () => z.date(),
9
+ point: () => geoPointSchema,
10
+ location: () => geoPointSchema,
8
11
  id: (table: string) =>
9
12
  z
10
13
  .string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appflare",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "bin": {
5
5
  "appflare": "./cli/index.ts"
6
6
  },
@@ -8,6 +8,7 @@
8
8
  ".": "./index.ts",
9
9
  "./db": "./lib/db.ts",
10
10
  "./values": "./lib/values.ts",
11
+ "./location": "./lib/location.ts",
11
12
  "./*": "./*.ts",
12
13
  "./react/*": "./react/*.ts",
13
14
  "./react": "./react/index.ts",
@@ -110,6 +110,7 @@ export function createMongoDbContext<
110
110
 
111
111
  function toKeyList(arg: unknown): string[] | undefined {
112
112
  if (!arg) return undefined;
113
+ if (typeof arg === "string" || typeof arg === "number") return [String(arg)];
113
114
  if (Array.isArray(arg)) return arg.map((k) => String(k));
114
115
  if (typeof arg === "object") {
115
116
  return Object.entries(arg as Record<string, unknown>)
@@ -130,7 +131,26 @@ function createAppflareTableClient<
130
131
  getCollection: (table: string) => Collection<Document>;
131
132
  }): AppflareTableClient<TableName, TTableDocMap> {
132
133
  const selectKeys = (select: unknown) => toKeyList(select);
133
- const includeKeys = (include: unknown) => toKeyList(include);
134
+
135
+ const toArray = <T>(value: T | T[] | undefined): T[] => {
136
+ if (value === undefined) return [];
137
+ return Array.isArray(value) ? value : [value];
138
+ };
139
+
140
+ const stringifyAggregateValue = (value: unknown): unknown => {
141
+ if (value instanceof ObjectId) return value.toHexString();
142
+ if (Array.isArray(value)) return value.map(stringifyAggregateValue);
143
+ if (value && typeof value === "object") {
144
+ const out: Record<string, unknown> = {};
145
+ for (const [key, val] of Object.entries(
146
+ value as Record<string, unknown>
147
+ )) {
148
+ out[key] = stringifyAggregateValue(val);
149
+ }
150
+ return out;
151
+ }
152
+ return value;
153
+ };
134
154
 
135
155
  const buildQuery = (args?: {
136
156
  where?: any;
@@ -149,8 +169,7 @@ function createAppflareTableClient<
149
169
  if (args?.take !== undefined) q = q.limit(args.take);
150
170
  const s = selectKeys(args?.select);
151
171
  if (s?.length) q = q.select(s as any);
152
- const i = includeKeys(args?.include);
153
- if (i?.length) q = q.populate(i as any);
172
+ if (args?.include) q = q.populate(args.include as any);
154
173
  return q;
155
174
  };
156
175
 
@@ -235,5 +254,72 @@ function createAppflareTableClient<
235
254
  const filter = normalizeIdFilter(toMongoFilter(args?.where ?? {}));
236
255
  return coll.countDocuments(filter ?? {});
237
256
  },
257
+ aggregate: async (args) => {
258
+ const coll = params.getCollection(params.table as string);
259
+ const pipeline: Document[] = [];
260
+
261
+ const normalizedWhere = args.where
262
+ ? normalizeIdFilter(
263
+ normalizeRefFields(
264
+ params.table as string,
265
+ args.where as Record<string, unknown>,
266
+ params.refs
267
+ ) as any
268
+ )
269
+ : undefined;
270
+ if (normalizedWhere && Object.keys(normalizedWhere).length > 0) {
271
+ pipeline.push({ $match: normalizedWhere });
272
+ }
273
+
274
+ const sumFields = toArray(args.sum).filter(Boolean) as string[];
275
+ const avgFields = toArray(args.avg).filter(Boolean) as string[];
276
+ if (sumFields.length === 0 && avgFields.length === 0) {
277
+ throw new Error("aggregate requires at least one sum or avg field");
278
+ }
279
+
280
+ const groupKeys = toArray(args.groupBy).filter(Boolean) as string[];
281
+ const groupId =
282
+ groupKeys.length === 0
283
+ ? null
284
+ : groupKeys.length === 1
285
+ ? `$${groupKeys[0]}`
286
+ : Object.fromEntries(groupKeys.map((k) => [k, `$${k}`]));
287
+
288
+ const groupStage: Record<string, unknown> = { _id: groupId };
289
+ for (const field of sumFields) {
290
+ groupStage[`sum_${field}`] = { $sum: `$${field}` };
291
+ }
292
+ for (const field of avgFields) {
293
+ groupStage[`avg_${field}`] = { $avg: `$${field}` };
294
+ }
295
+ pipeline.push({ $group: groupStage });
296
+
297
+ const tableRefs = params.refs.get(params.table as string) ?? new Map();
298
+ const populateKeys = toKeyList(args.populate) ?? [];
299
+ for (const key of populateKeys) {
300
+ if (!groupKeys.includes(key)) continue;
301
+ const target = tableRefs.get(key);
302
+ if (!target) continue;
303
+ const useRootId = groupKeys.length === 1 && groupKeys[0] === key;
304
+ const localField = useRootId ? "_id" : `_id.${key}`;
305
+ pipeline.push({
306
+ $lookup: {
307
+ from: target,
308
+ localField,
309
+ foreignField: "_id",
310
+ as: key,
311
+ },
312
+ });
313
+ pipeline.push({
314
+ $unwind: {
315
+ path: `$${key}`,
316
+ preserveNullAndEmptyArrays: true,
317
+ },
318
+ });
319
+ }
320
+
321
+ const results = await coll.aggregate(pipeline as any).toArray();
322
+ return results.map((doc) => stringifyAggregateValue(doc)) as any;
323
+ },
238
324
  };
239
325
  }
@@ -7,11 +7,18 @@ export async function applyPopulate(params: {
7
7
  docs: Array<Record<string, unknown>>;
8
8
  currentTable: string;
9
9
  populateKeys: string[];
10
+ aggregate?: Record<
11
+ string,
12
+ { count?: boolean; sum?: string[]; avg?: string[] }
13
+ >;
14
+ includeDocs?: Record<string, boolean>;
10
15
  selectedKeys: string[] | undefined;
11
16
  refs: SchemaRefMap;
12
17
  getCollection: (table: string) => Collection<Document>;
13
18
  }) {
14
19
  const tableRefs = params.refs.get(params.currentTable) ?? new Map();
20
+ const aggregateConfig = params.aggregate ?? {};
21
+ const includeDocsConfig = params.includeDocs ?? {};
15
22
 
16
23
  const ensureStringId = (val: unknown): string | null => {
17
24
  if (!val) return null;
@@ -60,10 +67,53 @@ export async function applyPopulate(params: {
60
67
  return byId;
61
68
  };
62
69
 
70
+ const computeAggregates = (
71
+ value: unknown,
72
+ spec: { count?: boolean; sum?: string[]; avg?: string[] }
73
+ ): Record<string, number> | null => {
74
+ const items = Array.isArray(value)
75
+ ? value
76
+ : value === undefined || value === null
77
+ ? []
78
+ : [value];
79
+
80
+ const out: Record<string, number> = {};
81
+ const numericSum = (field: string) => {
82
+ let total = 0;
83
+ let found = 0;
84
+ for (const item of items) {
85
+ if (!item || typeof item !== "object") continue;
86
+ const raw = (item as Record<string, unknown>)[field];
87
+ if (typeof raw === "number" || typeof raw === "bigint") {
88
+ total += Number(raw);
89
+ found += 1;
90
+ }
91
+ }
92
+ return { total, found };
93
+ };
94
+
95
+ if (spec.count) out.count = items.length;
96
+
97
+ for (const field of spec.sum ?? []) {
98
+ const { total, found } = numericSum(String(field));
99
+ if (found > 0) out[`sum_${String(field)}`] = total;
100
+ }
101
+
102
+ for (const field of spec.avg ?? []) {
103
+ const { total, found } = numericSum(String(field));
104
+ if (found > 0) out[`avg_${String(field)}`] = total / found;
105
+ }
106
+
107
+ return Object.keys(out).length ? out : null;
108
+ };
109
+
63
110
  for (const key of params.populateKeys) {
64
111
  const forwardTarget = tableRefs.get(key);
65
112
  const backwardCandidates = reverseRefs.get(params.currentTable) ?? [];
66
113
  const backward = backwardCandidates.find((c) => c.table === key);
114
+ const includeDocs = includeDocsConfig[key] ?? true;
115
+ let valueById: Map<string, Record<string, unknown>[]> | undefined;
116
+ let handled = false;
67
117
 
68
118
  // Prefer forward populate via $lookup when the table carries the ref.
69
119
  if (forwardTarget) {
@@ -94,33 +144,37 @@ export async function applyPopulate(params: {
94
144
  .toArray()) as Array<Record<string, unknown>>;
95
145
 
96
146
  const byId = buildLookupMap(populated);
97
-
98
- for (const doc of params.docs) {
99
- const docId = ensureStringId(doc._id);
100
- if (!docId) continue;
101
- const populatedDocs = byId.get(docId) ?? [];
102
- const currentValue = doc[key];
103
-
104
- if (Array.isArray(currentValue)) {
105
- const byRelId = new Map<string, Record<string, unknown>>();
106
- for (const rel of populatedDocs) {
107
- const relId = ensureStringId(rel._id);
108
- if (relId) byRelId.set(relId, rel);
147
+ valueById = byId;
148
+ handled = true;
149
+
150
+ if (includeDocs) {
151
+ for (const doc of params.docs) {
152
+ const docId = ensureStringId(doc._id);
153
+ if (!docId) continue;
154
+ const populatedDocs = byId.get(docId) ?? [];
155
+ const currentValue = doc[key];
156
+
157
+ if (Array.isArray(currentValue)) {
158
+ const byRelId = new Map<string, Record<string, unknown>>();
159
+ for (const rel of populatedDocs) {
160
+ const relId = ensureStringId(rel._id);
161
+ if (relId) byRelId.set(relId, rel);
162
+ }
163
+ doc[key] = currentValue
164
+ .map((v) => {
165
+ const relId = ensureStringId(v);
166
+ return relId ? (byRelId.get(relId) ?? null) : null;
167
+ })
168
+ .filter((v): v is Record<string, unknown> => Boolean(v));
169
+ continue;
109
170
  }
110
- doc[key] = currentValue
111
- .map((v) => {
112
- const relId = ensureStringId(v);
113
- return relId ? (byRelId.get(relId) ?? null) : null;
114
- })
115
- .filter((v): v is Record<string, unknown> => Boolean(v));
116
- continue;
117
- }
118
171
 
119
- const relId = ensureStringId(currentValue);
120
- doc[key] = relId
121
- ? (populatedDocs.find((v) => ensureStringId(v._id) === relId) ??
122
- currentValue)
123
- : (populatedDocs[0] ?? currentValue);
172
+ const relId = ensureStringId(currentValue);
173
+ doc[key] = relId
174
+ ? (populatedDocs.find((v) => ensureStringId(v._id) === relId) ??
175
+ currentValue)
176
+ : (populatedDocs[0] ?? currentValue);
177
+ }
124
178
  }
125
179
 
126
180
  continue;
@@ -144,16 +198,36 @@ export async function applyPopulate(params: {
144
198
  },
145
199
  { $project: { _id: 1, __pop: 1 } },
146
200
  ];
147
-
148
201
  const populated = (await coll
149
202
  .aggregate(pipeline as any)
150
203
  .toArray()) as Array<Record<string, unknown>>;
204
+ if (!handled && backward) {
205
+ const grouped = buildLookupMap(populated);
206
+ valueById = grouped;
207
+
208
+ if (includeDocs) {
209
+ for (const doc of params.docs) {
210
+ const id = ensureStringId(doc._id);
211
+ if (!id) continue;
212
+ doc[key] = grouped.get(id) ?? [];
213
+ }
214
+ }
215
+ }
216
+ }
151
217
 
152
- const grouped = buildLookupMap(populated);
218
+ const aggregateSpec = aggregateConfig[key];
219
+ if (aggregateSpec) {
153
220
  for (const doc of params.docs) {
154
- const id = ensureStringId(doc._id);
155
- if (!id) continue;
156
- doc[key] = grouped.get(id) ?? [];
221
+ const docId = ensureStringId(doc._id);
222
+ if (!docId) continue;
223
+ const aggregateSource = valueById?.get(docId) ?? doc[key];
224
+ const aggregated = computeAggregates(aggregateSource, aggregateSpec);
225
+ if (!aggregated) continue;
226
+ const existing = (doc as any)._aggregates as
227
+ | Record<string, unknown>
228
+ | undefined;
229
+ const target = existing ?? ((doc as any)._aggregates = {});
230
+ target[key] = aggregated;
157
231
  }
158
232
  }
159
233
  }
@@ -19,6 +19,41 @@ export function createQueryBuilder<
19
19
  let offset: number | undefined;
20
20
  let selectedKeys: string[] | undefined;
21
21
  let populateKeys: string[] = [];
22
+ let populateAggregates: Record<
23
+ string,
24
+ {
25
+ count?: boolean;
26
+ sum?: string[];
27
+ avg?: string[];
28
+ }
29
+ > = {};
30
+ let populateIncludeDocs: Record<string, boolean> = {};
31
+
32
+ const normalizeAggregate = (
33
+ value: unknown
34
+ ): { count?: boolean; sum?: string[]; avg?: string[] } | undefined => {
35
+ if (!value || typeof value !== "object") return undefined;
36
+ const spec = value as Record<string, unknown>;
37
+ const out: { count?: boolean; sum?: string[]; avg?: string[] } = {};
38
+ if (spec.count) out.count = true;
39
+ if (Array.isArray(spec.sum)) out.sum = spec.sum.map((k) => String(k));
40
+ if (Array.isArray(spec.avg)) out.avg = spec.avg.map((k) => String(k));
41
+ return Object.keys(out).length ? out : undefined;
42
+ };
43
+
44
+ const addPopulateKey = (
45
+ key: string,
46
+ opts?: {
47
+ aggregate?: { count?: boolean; sum?: string[]; avg?: string[] };
48
+ includeDocs?: boolean;
49
+ }
50
+ ) => {
51
+ const ks = String(key);
52
+ if (!populateKeys.includes(ks)) populateKeys.push(ks);
53
+ if (opts?.aggregate) populateAggregates[ks] = opts.aggregate;
54
+ if (opts?.includeDocs !== undefined)
55
+ populateIncludeDocs[ks] = opts.includeDocs;
56
+ };
22
57
 
23
58
  const api: MongoDbQuery<any, any, any> = {
24
59
  where(next) {
@@ -50,11 +85,28 @@ export function createQueryBuilder<
50
85
  return api;
51
86
  },
52
87
  populate(arg: any) {
53
- const keys = Array.isArray(arg) ? arg : [arg];
54
- for (const k of keys) {
55
- const ks = String(k);
56
- if (!populateKeys.includes(ks)) populateKeys.push(ks);
88
+ if (Array.isArray(arg)) {
89
+ for (const k of arg) addPopulateKey(k as any);
90
+ return api;
57
91
  }
92
+
93
+ if (arg && typeof arg === "object") {
94
+ for (const [key, value] of Object.entries(
95
+ arg as Record<string, unknown>
96
+ )) {
97
+ if (!value) continue;
98
+ const includeObj =
99
+ typeof value === "object" && !Array.isArray(value)
100
+ ? (value as Record<string, unknown>)
101
+ : undefined;
102
+ const aggregate = normalizeAggregate(includeObj?.aggregate);
103
+ const includeDocs = includeObj?.includeDocs;
104
+ addPopulateKey(key, { aggregate, includeDocs });
105
+ }
106
+ return api;
107
+ }
108
+
109
+ if (arg !== undefined) addPopulateKey(arg as any);
58
110
  return api;
59
111
  },
60
112
  async find() {
@@ -80,6 +132,8 @@ export function createQueryBuilder<
80
132
  docs,
81
133
  currentTable: params.table as string,
82
134
  populateKeys,
135
+ aggregate: populateAggregates,
136
+ includeDocs: populateIncludeDocs,
83
137
  selectedKeys,
84
138
  refs: params.refs,
85
139
  getCollection: params.getCollection,
@@ -35,12 +35,87 @@ export type AppflareSelect<TDoc> =
35
35
  | ReadonlyArray<Keys<TDoc>>
36
36
  | Partial<Record<Keys<TDoc>, boolean>>;
37
37
 
38
+ type PopulatedDocForKey<
39
+ TDoc,
40
+ TTableDocMap extends Record<string, TableDocBase>,
41
+ TKey extends Keys<TDoc>,
42
+ > =
43
+ NonNil<PopulateValue<TDoc[TKey], TTableDocMap>> extends Array<infer TItem>
44
+ ? NonNil<TItem>
45
+ : NonNil<PopulateValue<TDoc[TKey], TTableDocMap>>;
46
+
47
+ type AggregateSpec<TDoc> = {
48
+ count?: boolean;
49
+ sum?: ReadonlyArray<NumericKeys<TDoc>>;
50
+ avg?: ReadonlyArray<NumericKeys<TDoc>>;
51
+ };
52
+
53
+ type AggregateSpecForKey<
54
+ TDoc,
55
+ TTableDocMap extends Record<string, TableDocBase>,
56
+ TKey extends Keys<TDoc>,
57
+ > = AggregateSpec<PopulatedDocForKey<TDoc, TTableDocMap, TKey>>;
58
+
59
+ type AppflareIncludeRecord<
60
+ TDoc,
61
+ TTableDocMap extends Record<string, TableDocBase>,
62
+ > = {
63
+ [K in PopulatableKeys<TDoc, TTableDocMap>]?:
64
+ | boolean
65
+ | {
66
+ aggregate?: AggregateSpecForKey<TDoc, TTableDocMap, K>;
67
+ includeDocs?: boolean;
68
+ };
69
+ };
70
+
38
71
  export type AppflareInclude<
39
72
  TDoc,
40
73
  TTableDocMap extends Record<string, TableDocBase>,
41
74
  > =
42
75
  | ReadonlyArray<PopulatableKeys<TDoc, TTableDocMap>>
43
- | Partial<Record<PopulatableKeys<TDoc, TTableDocMap>, boolean>>;
76
+ | AppflareIncludeRecord<TDoc, TTableDocMap>;
77
+
78
+ type ExtractAggregateSpec<T> = T extends { aggregate?: infer TSpec }
79
+ ? TSpec
80
+ : never;
81
+
82
+ type AggregateResultFromSpec<TDoc, TSpec> = (TSpec extends { count: true }
83
+ ? { count: number }
84
+ : {}) &
85
+ (TSpec extends { sum: infer TSum }
86
+ ? TSum extends ReadonlyArray<infer K>
87
+ ? { [P in Extract<K, NumericKeys<TDoc>> as `sum_${P}`]: number }
88
+ : {}
89
+ : {}) &
90
+ (TSpec extends { avg: infer TAvg }
91
+ ? TAvg extends ReadonlyArray<infer K>
92
+ ? { [P in Extract<K, NumericKeys<TDoc>> as `avg_${P}`]: number }
93
+ : {}
94
+ : {});
95
+
96
+ type AggregateMapForInclude<
97
+ TDoc,
98
+ TTableDocMap extends Record<string, TableDocBase>,
99
+ TInclude,
100
+ > =
101
+ TInclude extends ReadonlyArray<any>
102
+ ? {}
103
+ : {
104
+ [K in keyof TInclude as ExtractAggregateSpec<TInclude[K]> extends never
105
+ ? never
106
+ : K]: AggregateResultFromSpec<
107
+ PopulatedDocForKey<TDoc, TTableDocMap, Extract<K, Keys<TDoc>>>,
108
+ ExtractAggregateSpec<TInclude[K]>
109
+ >;
110
+ };
111
+
112
+ type WithAggregatesForInclude<
113
+ TDoc,
114
+ TTableDocMap extends Record<string, TableDocBase>,
115
+ TInclude,
116
+ > = keyof AggregateMapForInclude<TDoc, TTableDocMap, TInclude> extends never
117
+ ? {}
118
+ : { _aggregates: AggregateMapForInclude<TDoc, TTableDocMap, TInclude> };
44
119
 
45
120
  export type AppflareResultDoc<
46
121
  TDoc,
@@ -54,7 +129,8 @@ export type AppflareResultDoc<
54
129
  TTableDocMap
55
130
  >,
56
131
  SelectedKeys<TDoc, TSelect>
57
- >;
132
+ > &
133
+ WithAggregatesForInclude<TDoc, TTableDocMap, TInclude>;
58
134
 
59
135
  export type SortDirection = "asc" | "desc";
60
136
 
@@ -78,6 +154,11 @@ export type ExtractIdTableName<T> =
78
154
  ? ExtractIdTableName<TItem>
79
155
  : never;
80
156
 
157
+ export type NumericKeys<T> = {
158
+ [K in keyof T]: NonNil<T[K]> extends number | bigint ? K : never;
159
+ }[keyof T] &
160
+ string;
161
+
81
162
  export type PopulateValue<
82
163
  T,
83
164
  TTableDocMap extends Record<string, TableDocBase>,
@@ -166,6 +247,18 @@ export type MongoDbQuery<
166
247
  TTableDocMap,
167
248
  WithPopulatedMany<TResultDoc, TKeys[number], TTableDocMap>
168
249
  >;
250
+ populate<TInclude extends AppflareInclude<TResultDoc, TTableDocMap>>(
251
+ include: TInclude
252
+ ): MongoDbQuery<
253
+ TableName,
254
+ TTableDocMap,
255
+ WithPopulatedMany<
256
+ TResultDoc,
257
+ IncludedKeys<TResultDoc, TInclude, TTableDocMap>,
258
+ TTableDocMap
259
+ > &
260
+ WithAggregatesForInclude<TResultDoc, TTableDocMap, TInclude>
261
+ >;
169
262
 
170
263
  find(): Promise<Array<TResultDoc>>;
171
264
  findOne(): Promise<TResultDoc | null>;
@@ -310,6 +403,59 @@ export type AppflareCountArgs<
310
403
  where?: QueryWhere<TTableDocMap[TableName]>;
311
404
  };
312
405
 
406
+ type AggregateGroupInput<TDoc> =
407
+ | ReadonlyArray<keyof TDoc & string>
408
+ | (keyof TDoc & string);
409
+
410
+ type NormalizeGroupInput<TDoc, TGroup> = TGroup extends readonly (infer K)[]
411
+ ? ReadonlyArray<Extract<K, keyof TDoc & string>>
412
+ : TGroup extends string
413
+ ? ReadonlyArray<Extract<TGroup, keyof TDoc & string>>
414
+ : ReadonlyArray<never>;
415
+
416
+ type AggregateId<
417
+ TDoc,
418
+ TGroup extends ReadonlyArray<keyof TDoc & string>,
419
+ > = TGroup extends readonly []
420
+ ? null
421
+ : TGroup extends readonly [infer K]
422
+ ? TDoc[Extract<K, keyof TDoc>]
423
+ : { [K in TGroup[number]]: TDoc[K] };
424
+
425
+ type AggregateResult<
426
+ TDoc,
427
+ TGroup extends ReadonlyArray<keyof TDoc & string>,
428
+ TSum extends ReadonlyArray<NumericKeys<TDoc>>,
429
+ TAvg extends ReadonlyArray<NumericKeys<TDoc>>,
430
+ > = {
431
+ _id: AggregateId<TDoc, TGroup>;
432
+ } & (TSum[number] extends never
433
+ ? {}
434
+ : { [K in TSum[number] as `sum_${K}`]: number }) &
435
+ (TAvg[number] extends never
436
+ ? {}
437
+ : { [K in TAvg[number] as `avg_${K}`]: number });
438
+
439
+ export type AppflareAggregateArgs<
440
+ TableName extends string,
441
+ TTableDocMap extends Record<TableName, TableDocBase>,
442
+ TGroup = AggregateGroupInput<TTableDocMap[TableName]>,
443
+ TSum extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
444
+ ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
445
+ TAvg extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
446
+ ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
447
+ > = {
448
+ where?: QueryWhere<TTableDocMap[TableName]>;
449
+ groupBy?: TGroup;
450
+ sum?: TSum;
451
+ avg?: TAvg;
452
+ /**
453
+ * Populate aggregated group keys that are references to other tables.
454
+ * Only keys present in groupBy are eligible for populate.
455
+ */
456
+ populate?: AppflareInclude<TTableDocMap[TableName], TTableDocMap>;
457
+ };
458
+
313
459
  export type AppflareTableClient<
314
460
  TableName extends string,
315
461
  TTableDocMap extends Record<TableName, TableDocBase>,
@@ -394,6 +540,24 @@ export type AppflareTableClient<
394
540
  args?: AppflareDeleteManyArgs<TableName, TTableDocMap>
395
541
  ): Promise<{ count: number }>;
396
542
  count(args?: AppflareCountArgs<TableName, TTableDocMap>): Promise<number>;
543
+ aggregate<
544
+ TGroup = AggregateGroupInput<TTableDocMap[TableName]>,
545
+ TSum extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
546
+ ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
547
+ TAvg extends ReadonlyArray<NumericKeys<TTableDocMap[TableName]>> =
548
+ ReadonlyArray<NumericKeys<TTableDocMap[TableName]>>,
549
+ >(
550
+ args: AppflareAggregateArgs<TableName, TTableDocMap, TGroup, TSum, TAvg>
551
+ ): Promise<
552
+ Array<
553
+ AggregateResult<
554
+ TTableDocMap[TableName],
555
+ NormalizeGroupInput<TTableDocMap[TableName], TGroup>,
556
+ TSum,
557
+ TAvg
558
+ >
559
+ >
560
+ >;
397
561
  };
398
562
 
399
563
  export type AppflareModelMap<