@zodmon/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,29 +21,42 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  $: () => $,
24
+ $addToSet: () => $addToSet,
24
25
  $and: () => $and,
26
+ $avg: () => $avg,
27
+ $count: () => $count,
25
28
  $eq: () => $eq,
26
29
  $exists: () => $exists,
30
+ $first: () => $first,
27
31
  $gt: () => $gt,
28
32
  $gte: () => $gte,
29
33
  $in: () => $in,
34
+ $last: () => $last,
30
35
  $lt: () => $lt,
31
36
  $lte: () => $lte,
37
+ $max: () => $max,
38
+ $min: () => $min,
32
39
  $ne: () => $ne,
33
40
  $nin: () => $nin,
34
41
  $nor: () => $nor,
35
42
  $not: () => $not,
36
43
  $or: () => $or,
44
+ $push: () => $push,
37
45
  $regex: () => $regex,
46
+ $sum: () => $sum,
47
+ AggregatePipeline: () => AggregatePipeline,
38
48
  CollectionHandle: () => CollectionHandle,
39
49
  Database: () => Database,
40
50
  IndexBuilder: () => IndexBuilder,
41
51
  TypedFindCursor: () => TypedFindCursor,
42
52
  ZodmonNotFoundError: () => ZodmonNotFoundError,
43
53
  ZodmonValidationError: () => ZodmonValidationError,
54
+ aggregate: () => aggregate,
44
55
  checkUnindexedFields: () => checkUnindexedFields,
45
56
  collection: () => collection,
57
+ createAccumulatorBuilder: () => createAccumulatorBuilder,
46
58
  createClient: () => createClient,
59
+ createExpressionBuilder: () => createExpressionBuilder,
47
60
  deleteMany: () => deleteMany,
48
61
  deleteOne: () => deleteOne,
49
62
  extractComparableOptions: () => extractComparableOptions,
@@ -73,6 +86,655 @@ __export(index_exports, {
73
86
  });
74
87
  module.exports = __toCommonJS(index_exports);
75
88
 
89
+ // src/aggregate/expressions.ts
90
+ var $count = () => ({
91
+ __accum: true,
92
+ expr: { $sum: 1 }
93
+ });
94
+ var $sum = (field) => ({
95
+ __accum: true,
96
+ expr: { $sum: field }
97
+ });
98
+ var $avg = (field) => ({
99
+ __accum: true,
100
+ expr: { $avg: field }
101
+ });
102
+ var $min = (field) => ({
103
+ __accum: true,
104
+ expr: { $min: field }
105
+ });
106
+ var $max = (field) => ({
107
+ __accum: true,
108
+ expr: { $max: field }
109
+ });
110
+ var $first = (field) => ({
111
+ __accum: true,
112
+ expr: { $first: field }
113
+ });
114
+ var $last = (field) => ({
115
+ __accum: true,
116
+ expr: { $last: field }
117
+ });
118
+ var $push = (field) => ({
119
+ __accum: true,
120
+ expr: { $push: field }
121
+ });
122
+ var $addToSet = (field) => ({
123
+ __accum: true,
124
+ expr: { $addToSet: field }
125
+ });
126
+ function createAccumulatorBuilder() {
127
+ return {
128
+ count: () => ({ __accum: true, expr: { $sum: 1 } }),
129
+ sum: (field) => ({
130
+ __accum: true,
131
+ expr: { $sum: typeof field === "number" ? field : `$${field}` }
132
+ }),
133
+ avg: (field) => ({ __accum: true, expr: { $avg: `$${field}` } }),
134
+ min: (field) => ({ __accum: true, expr: { $min: `$${field}` } }),
135
+ max: (field) => ({ __accum: true, expr: { $max: `$${field}` } }),
136
+ first: (field) => ({ __accum: true, expr: { $first: `$${field}` } }),
137
+ last: (field) => ({ __accum: true, expr: { $last: `$${field}` } }),
138
+ push: (field) => ({ __accum: true, expr: { $push: `$${field}` } }),
139
+ addToSet: (field) => ({ __accum: true, expr: { $addToSet: `$${field}` } })
140
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime implementation uses string field names and returns plain objects — TypeScript cannot verify that the runtime Accumulator objects match the generic AccumulatorBuilder<T> return types. Safe because type resolution happens at compile time via AccumulatorBuilder<T>, and runtime values are identical to what the standalone $min/$max/etc. produce.
141
+ };
142
+ }
143
+ function createExpressionBuilder() {
144
+ const $2 = (field) => `$${field}`;
145
+ const val = (v) => typeof v === "number" ? v : `$${v}`;
146
+ const expr = (value) => ({ __expr: true, value });
147
+ return {
148
+ // Arithmetic
149
+ add: (field, value) => expr({ $add: [$2(field), val(value)] }),
150
+ subtract: (field, value) => expr({ $subtract: [$2(field), val(value)] }),
151
+ multiply: (field, value) => expr({ $multiply: [$2(field), val(value)] }),
152
+ divide: (field, value) => expr({ $divide: [$2(field), val(value)] }),
153
+ mod: (field, value) => expr({ $mod: [$2(field), val(value)] }),
154
+ abs: (field) => expr({ $abs: $2(field) }),
155
+ ceil: (field) => expr({ $ceil: $2(field) }),
156
+ floor: (field) => expr({ $floor: $2(field) }),
157
+ round: (field, place = 0) => expr({ $round: [$2(field), place] }),
158
+ // String
159
+ concat: (...parts) => {
160
+ const resolved = parts.map((p) => {
161
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p)) return $2(p);
162
+ return p;
163
+ });
164
+ return expr({ $concat: resolved });
165
+ },
166
+ toLower: (field) => expr({ $toLower: $2(field) }),
167
+ toUpper: (field) => expr({ $toUpper: $2(field) }),
168
+ trim: (field) => expr({ $trim: { input: $2(field) } }),
169
+ substr: (field, start, length) => expr({ $substrBytes: [$2(field), start, length] }),
170
+ // Comparison
171
+ eq: (field, value) => expr({ $eq: [$2(field), value] }),
172
+ gt: (field, value) => expr({ $gt: [$2(field), value] }),
173
+ gte: (field, value) => expr({ $gte: [$2(field), value] }),
174
+ lt: (field, value) => expr({ $lt: [$2(field), value] }),
175
+ lte: (field, value) => expr({ $lte: [$2(field), value] }),
176
+ ne: (field, value) => expr({ $ne: [$2(field), value] }),
177
+ // Date
178
+ year: (field) => expr({ $year: $2(field) }),
179
+ month: (field) => expr({ $month: $2(field) }),
180
+ dayOfMonth: (field) => expr({ $dayOfMonth: $2(field) }),
181
+ // Array
182
+ size: (field) => expr({ $size: $2(field) }),
183
+ // Conditional
184
+ cond: (condition, thenValue, elseValue) => expr({ $cond: [condition.value, thenValue, elseValue] }),
185
+ ifNull: (field, fallback) => expr({ $ifNull: [$2(field), fallback] })
186
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime implementation uses string field names — TypeScript cannot verify generic ExpressionBuilder<T> return types match. Safe because type resolution happens at compile time.
187
+ };
188
+ }
189
+
190
+ // src/schema/ref.ts
191
+ var import_zod = require("zod");
192
+ var refMetadata = /* @__PURE__ */ new WeakMap();
193
+ function getRefMetadata(schema) {
194
+ if (typeof schema !== "object" || schema === null) return void 0;
195
+ return refMetadata.get(schema);
196
+ }
197
+ var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
198
+ function installRefExtension() {
199
+ const proto = import_zod.z.ZodType.prototype;
200
+ if (REF_GUARD in proto) return;
201
+ Object.defineProperty(proto, "ref", {
202
+ value(collection2) {
203
+ refMetadata.set(this, { collection: collection2 });
204
+ return this;
205
+ },
206
+ enumerable: true,
207
+ configurable: true,
208
+ writable: true
209
+ });
210
+ Object.defineProperty(proto, REF_GUARD, {
211
+ value: true,
212
+ enumerable: false,
213
+ configurable: false,
214
+ writable: false
215
+ });
216
+ }
217
+
218
+ // src/aggregate/pipeline.ts
219
+ var AggregatePipeline = class _AggregatePipeline {
220
+ definition;
221
+ nativeCollection;
222
+ stages;
223
+ constructor(definition, nativeCollection, stages) {
224
+ this.definition = definition;
225
+ this.nativeCollection = nativeCollection;
226
+ this.stages = stages;
227
+ }
228
+ /**
229
+ * Append an arbitrary aggregation stage to the pipeline (escape hatch).
230
+ *
231
+ * Returns a new pipeline instance with the stage appended — the
232
+ * original pipeline is not modified.
233
+ *
234
+ * Optionally accepts a type parameter `TNew` to change the output
235
+ * type when the stage transforms the document shape.
236
+ *
237
+ * @typeParam TNew - The output type after this stage. Defaults to the current output type.
238
+ * @param stage - A raw MongoDB aggregation stage document (e.g. `{ $match: { ... } }`).
239
+ * @returns A new pipeline with the stage appended.
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * const admins = aggregate(users)
244
+ * .raw({ $match: { role: 'admin' } })
245
+ * .toArray()
246
+ * ```
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * // Change output type with a $project stage
251
+ * const names = aggregate(users)
252
+ * .raw<{ name: string }>({ $project: { name: 1, _id: 0 } })
253
+ * .toArray()
254
+ * ```
255
+ */
256
+ raw(stage) {
257
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
258
+ ...this.stages,
259
+ stage
260
+ ]);
261
+ }
262
+ /**
263
+ * Execute the pipeline and return all results as an array.
264
+ *
265
+ * @returns A promise resolving to the array of output documents.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * const results = await aggregate(users)
270
+ * .raw({ $match: { age: { $gte: 18 } } })
271
+ * .toArray()
272
+ * ```
273
+ */
274
+ async toArray() {
275
+ const cursor = this.nativeCollection.aggregate(this.stages);
276
+ return await cursor.toArray();
277
+ }
278
+ /**
279
+ * Stream pipeline results one document at a time via `for await...of`.
280
+ *
281
+ * @returns An async generator yielding output documents.
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * for await (const user of aggregate(users).raw({ $match: { role: 'admin' } })) {
286
+ * console.log(user.name)
287
+ * }
288
+ * ```
289
+ */
290
+ async *[Symbol.asyncIterator]() {
291
+ const cursor = this.nativeCollection.aggregate(this.stages);
292
+ for await (const doc of cursor) {
293
+ yield doc;
294
+ }
295
+ }
296
+ /**
297
+ * Return the query execution plan without running the pipeline.
298
+ *
299
+ * Useful for debugging and understanding how MongoDB will process
300
+ * the pipeline stages.
301
+ *
302
+ * @returns A promise resolving to the explain output document.
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * const plan = await aggregate(users)
307
+ * .raw({ $match: { role: 'admin' } })
308
+ * .explain()
309
+ * console.log(plan)
310
+ * ```
311
+ */
312
+ async explain() {
313
+ const cursor = this.nativeCollection.aggregate(this.stages);
314
+ return await cursor.explain();
315
+ }
316
+ // ── Shape-preserving stages ──────────────────────────────────────
317
+ /**
318
+ * Filter documents using a type-safe match expression.
319
+ *
320
+ * Appends a `$match` stage to the pipeline. The filter is constrained
321
+ * to the current output type, so only valid fields and operators are accepted.
322
+ *
323
+ * Supports two forms of type narrowing:
324
+ *
325
+ * **Tier 1 — Explicit type parameter:**
326
+ * ```ts
327
+ * .match<{ role: 'engineer' | 'designer' }>({ role: { $in: ['engineer', 'designer'] } })
328
+ * // role narrows to 'engineer' | 'designer'
329
+ * ```
330
+ *
331
+ * **Tier 2 — Automatic inference from filter literals:**
332
+ * ```ts
333
+ * .match({ role: 'engineer' }) // role narrows to 'engineer'
334
+ * .match({ role: { $ne: 'intern' } }) // role narrows to Exclude<Role, 'intern'>
335
+ * .match({ role: { $in: ['engineer', 'designer'] as const } }) // needs as const
336
+ * ```
337
+ *
338
+ * When no type parameter is provided and the filter doesn't contain
339
+ * inferrable literals, the output type is unchanged (backward compatible).
340
+ *
341
+ * @typeParam TNarrow - Optional object mapping field names to narrowed types. Must be a subtype of the corresponding fields in TOutput.
342
+ * @typeParam F - Inferred from the filter argument. Do not provide explicitly.
343
+ * @param filter - A type-safe filter for the current output type.
344
+ * @returns A new pipeline with the `$match` stage appended and output type narrowed.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * // Explicit narrowing
349
+ * const filtered = await users.aggregate()
350
+ * .match<{ role: 'engineer' }>({ role: 'engineer' })
351
+ * .toArray()
352
+ * // filtered[0].role → 'engineer'
353
+ *
354
+ * // Automatic narrowing with $in (requires as const)
355
+ * const subset = await users.aggregate()
356
+ * .match({ role: { $in: ['engineer', 'designer'] as const } })
357
+ * .toArray()
358
+ * // subset[0].role → 'engineer' | 'designer'
359
+ * ```
360
+ */
361
+ match(filter) {
362
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
363
+ ...this.stages,
364
+ { $match: filter }
365
+ ]);
366
+ return pipeline;
367
+ }
368
+ /**
369
+ * Sort documents by one or more fields.
370
+ *
371
+ * Appends a `$sort` stage. Keys are constrained to `keyof TOutput & string`
372
+ * and values must be `1` (ascending) or `-1` (descending).
373
+ *
374
+ * @param spec - A sort specification mapping field names to sort direction.
375
+ * @returns A new pipeline with the `$sort` stage appended.
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * const sorted = await aggregate(users)
380
+ * .sort({ age: -1, name: 1 })
381
+ * .toArray()
382
+ * ```
383
+ */
384
+ sort(spec) {
385
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
386
+ ...this.stages,
387
+ { $sort: spec }
388
+ ]);
389
+ }
390
+ /**
391
+ * Skip a number of documents in the pipeline.
392
+ *
393
+ * Appends a `$skip` stage. Commonly used with {@link limit} for pagination.
394
+ *
395
+ * @param n - The number of documents to skip.
396
+ * @returns A new pipeline with the `$skip` stage appended.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * // Page 2 (10 items per page)
401
+ * const page2 = await aggregate(users)
402
+ * .sort({ name: 1 })
403
+ * .skip(10)
404
+ * .limit(10)
405
+ * .toArray()
406
+ * ```
407
+ */
408
+ skip(n) {
409
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
410
+ ...this.stages,
411
+ { $skip: n }
412
+ ]);
413
+ }
414
+ /**
415
+ * Limit the number of documents passing through the pipeline.
416
+ *
417
+ * Appends a `$limit` stage. Commonly used with {@link skip} for pagination,
418
+ * or after {@link sort} to get top/bottom N results.
419
+ *
420
+ * @param n - The maximum number of documents to pass through.
421
+ * @returns A new pipeline with the `$limit` stage appended.
422
+ *
423
+ * @example
424
+ * ```ts
425
+ * const top5 = await aggregate(users)
426
+ * .sort({ score: -1 })
427
+ * .limit(5)
428
+ * .toArray()
429
+ * ```
430
+ */
431
+ limit(n) {
432
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
433
+ ...this.stages,
434
+ { $limit: n }
435
+ ]);
436
+ }
437
+ // ── Shape-transforming projection stages ─────────────────────────
438
+ /**
439
+ * Include only specified fields in the output.
440
+ *
441
+ * Appends a `$project` stage with inclusion (`1`) for each key.
442
+ * The `_id` field is always included. The output type narrows to
443
+ * `Pick<TOutput, K | '_id'>`.
444
+ *
445
+ * @param spec - An object mapping field names to `1` for inclusion.
446
+ * @returns A new pipeline with the `$project` stage appended.
447
+ *
448
+ * @example
449
+ * ```ts
450
+ * const namesOnly = await aggregate(users)
451
+ * .project({ name: 1 })
452
+ * .toArray()
453
+ * // [{ _id: ..., name: 'Ada' }, ...]
454
+ * ```
455
+ */
456
+ project(spec) {
457
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
458
+ ...this.stages,
459
+ { $project: spec }
460
+ ]);
461
+ return pipeline;
462
+ }
463
+ /**
464
+ * Variadic shorthand for {@link project} — pick fields to include.
465
+ *
466
+ * Generates a `$project` stage that includes only the listed fields
467
+ * (plus `_id`). Equivalent to `.project({ field1: 1, field2: 1 })`.
468
+ *
469
+ * @param fields - Field names to include in the output.
470
+ * @returns A new pipeline with the `$project` stage appended.
471
+ *
472
+ * @example
473
+ * ```ts
474
+ * const namesAndRoles = await aggregate(users)
475
+ * .pick('name', 'role')
476
+ * .toArray()
477
+ * ```
478
+ */
479
+ pick(...fields) {
480
+ const spec = Object.fromEntries(fields.map((f) => [f, 1]));
481
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
482
+ ...this.stages,
483
+ { $project: spec }
484
+ ]);
485
+ return pipeline;
486
+ }
487
+ /**
488
+ * Exclude specified fields from the output.
489
+ *
490
+ * Appends a `$project` stage with exclusion (`0`) for each key.
491
+ * All other fields pass through. The output type becomes `Omit<TOutput, K>`.
492
+ *
493
+ * @param fields - Field names to exclude from the output.
494
+ * @returns A new pipeline with the `$project` stage appended.
495
+ *
496
+ * @example
497
+ * ```ts
498
+ * const noAge = await aggregate(users)
499
+ * .omit('age')
500
+ * .toArray()
501
+ * ```
502
+ */
503
+ omit(...fields) {
504
+ const spec = Object.fromEntries(fields.map((f) => [f, 0]));
505
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
506
+ ...this.stages,
507
+ { $project: spec }
508
+ ]);
509
+ return pipeline;
510
+ }
511
+ groupBy(field, accumulators) {
512
+ const resolved = typeof accumulators === "function" ? accumulators(createAccumulatorBuilder()) : accumulators;
513
+ const _id = Array.isArray(field) ? Object.fromEntries(field.map((f) => [f, `$${f}`])) : `$${field}`;
514
+ const accumExprs = Object.fromEntries(
515
+ Object.entries(resolved).map(([key, acc]) => [key, acc.expr])
516
+ );
517
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
518
+ ...this.stages,
519
+ { $group: { _id, ...accumExprs } }
520
+ ]);
521
+ return pipeline;
522
+ }
523
+ // Implementation
524
+ addFields(fields) {
525
+ const resolved = typeof fields === "function" ? fields(createExpressionBuilder()) : fields;
526
+ const stage = Object.fromEntries(
527
+ Object.entries(resolved).map(([k, v]) => [
528
+ k,
529
+ v && typeof v === "object" && "__expr" in v ? v.value : v
530
+ ])
531
+ );
532
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
533
+ ...this.stages,
534
+ { $addFields: stage }
535
+ ]);
536
+ return pipeline;
537
+ }
538
+ // ── unwind stage ─────────────────────────────────────────────────
539
+ /**
540
+ * Deconstruct an array field, outputting one document per array element.
541
+ *
542
+ * Appends an `$unwind` stage. The unwound field's type changes from
543
+ * `T[]` to `T` in the output type. Documents with empty or missing
544
+ * arrays are dropped unless `preserveEmpty` is `true`.
545
+ *
546
+ * @param field - The name of the array field to unwind.
547
+ * @param options - Optional settings for the unwind stage.
548
+ * @param options.preserveEmpty - If `true`, documents with null, missing, or empty arrays are preserved.
549
+ * @returns A new pipeline with the `$unwind` stage appended.
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * const flat = await aggregate(orders)
554
+ * .unwind('items')
555
+ * .toArray()
556
+ * // Each result has a single `items` value instead of an array
557
+ * ```
558
+ */
559
+ unwind(field, options) {
560
+ const stage = options?.preserveEmpty ? { $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true } } : { $unwind: `$${field}` };
561
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
562
+ ...this.stages,
563
+ stage
564
+ ]);
565
+ return pipeline;
566
+ }
567
+ lookup(fieldOrFrom, options) {
568
+ const stages = [...this.stages];
569
+ if (typeof fieldOrFrom === "object") {
570
+ const foreignName = fieldOrFrom.name;
571
+ const foreignField = options?.on;
572
+ if (!foreignField) {
573
+ throw new Error(
574
+ `[zodmon] lookup: reverse lookup on '${foreignName}' requires an 'on' option specifying which field on the foreign collection references this collection.`
575
+ );
576
+ }
577
+ const asField = options?.as ?? foreignName;
578
+ stages.push({
579
+ $lookup: {
580
+ from: foreignName,
581
+ localField: "_id",
582
+ foreignField,
583
+ as: asField
584
+ }
585
+ });
586
+ if (options?.unwind) {
587
+ stages.push({ $unwind: { path: `$${asField}`, preserveNullAndEmptyArrays: true } });
588
+ }
589
+ } else {
590
+ const shape = this.definition.shape;
591
+ const fieldSchema = shape[fieldOrFrom];
592
+ const ref = getRefMetadata(fieldSchema);
593
+ if (!ref) {
594
+ throw new Error(
595
+ `[zodmon] lookup: field '${fieldOrFrom}' has no .ref() metadata. Use .lookup(CollectionDef, { on: foreignKey }) for reverse lookups, or add .ref(TargetCollection) to the field schema.`
596
+ );
597
+ }
598
+ const targetName = ref.collection.name;
599
+ const asField = options?.as ?? targetName;
600
+ stages.push({
601
+ $lookup: {
602
+ from: targetName,
603
+ localField: fieldOrFrom,
604
+ foreignField: "_id",
605
+ as: asField
606
+ }
607
+ });
608
+ if (options?.unwind) {
609
+ stages.push({ $unwind: { path: `$${asField}`, preserveNullAndEmptyArrays: true } });
610
+ }
611
+ }
612
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, stages);
613
+ return pipeline;
614
+ }
615
+ // ── Convenience shortcuts ────────────────────────────────────────
616
+ /**
617
+ * Count documents per group, sorted by count descending.
618
+ *
619
+ * Shorthand for `.groupBy(field, { count: $count() }).sort({ count: -1 })`.
620
+ *
621
+ * @param field - The field to group and count by.
622
+ * @returns A new pipeline producing `{ _id: TOutput[K], count: number }` results.
623
+ *
624
+ * @example
625
+ * ```ts
626
+ * const roleCounts = await aggregate(users)
627
+ * .countBy('role')
628
+ * .toArray()
629
+ * // [{ _id: 'user', count: 3 }, { _id: 'admin', count: 2 }]
630
+ * ```
631
+ */
632
+ countBy(field) {
633
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
634
+ ...this.stages,
635
+ { $group: { _id: `$${field}`, count: { $sum: 1 } } },
636
+ { $sort: { count: -1 } }
637
+ ]);
638
+ return pipeline;
639
+ }
640
+ /**
641
+ * Sum a numeric field per group, sorted by total descending.
642
+ *
643
+ * Shorthand for `.groupBy(field, { total: $sum('$sumField') }).sort({ total: -1 })`.
644
+ *
645
+ * @param field - The field to group by.
646
+ * @param sumField - The numeric field to sum.
647
+ * @returns A new pipeline producing `{ _id: TOutput[K], total: number }` results.
648
+ *
649
+ * @example
650
+ * ```ts
651
+ * const revenueByCategory = await aggregate(orders)
652
+ * .sumBy('category', 'amount')
653
+ * .toArray()
654
+ * // [{ _id: 'electronics', total: 5000 }, ...]
655
+ * ```
656
+ */
657
+ sumBy(field, sumField) {
658
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
659
+ ...this.stages,
660
+ { $group: { _id: `$${field}`, total: { $sum: `$${sumField}` } } },
661
+ { $sort: { total: -1 } }
662
+ ]);
663
+ return pipeline;
664
+ }
665
+ /**
666
+ * Sort by a single field with a friendly direction name.
667
+ *
668
+ * Shorthand for `.sort({ [field]: direction === 'desc' ? -1 : 1 })`.
669
+ *
670
+ * @param field - The field to sort by.
671
+ * @param direction - Sort direction: `'asc'` (default) or `'desc'`.
672
+ * @returns A new pipeline with the `$sort` stage appended.
673
+ *
674
+ * @example
675
+ * ```ts
676
+ * const youngest = await aggregate(users)
677
+ * .sortBy('age')
678
+ * .toArray()
679
+ * ```
680
+ */
681
+ sortBy(field, direction = "asc") {
682
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
683
+ ...this.stages,
684
+ { $sort: { [field]: direction === "desc" ? -1 : 1 } }
685
+ ]);
686
+ }
687
+ /**
688
+ * Return the top N documents sorted by a field descending.
689
+ *
690
+ * Shorthand for `.sort({ [by]: -1 }).limit(n)`.
691
+ *
692
+ * @param n - The number of documents to return.
693
+ * @param options - An object with a `by` field specifying the sort key.
694
+ * @returns A new pipeline with `$sort` and `$limit` stages appended.
695
+ *
696
+ * @example
697
+ * ```ts
698
+ * const top3 = await aggregate(users)
699
+ * .top(3, { by: 'score' })
700
+ * .toArray()
701
+ * ```
702
+ */
703
+ top(n, options) {
704
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
705
+ ...this.stages,
706
+ { $sort: { [options.by]: -1 } },
707
+ { $limit: n }
708
+ ]);
709
+ }
710
+ /**
711
+ * Return the bottom N documents sorted by a field ascending.
712
+ *
713
+ * Shorthand for `.sort({ [by]: 1 }).limit(n)`.
714
+ *
715
+ * @param n - The number of documents to return.
716
+ * @param options - An object with a `by` field specifying the sort key.
717
+ * @returns A new pipeline with `$sort` and `$limit` stages appended.
718
+ *
719
+ * @example
720
+ * ```ts
721
+ * const bottom3 = await aggregate(users)
722
+ * .bottom(3, { by: 'score' })
723
+ * .toArray()
724
+ * ```
725
+ */
726
+ bottom(n, options) {
727
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
728
+ ...this.stages,
729
+ { $sort: { [options.by]: 1 } },
730
+ { $limit: n }
731
+ ]);
732
+ }
733
+ };
734
+ function aggregate(handle) {
735
+ return new AggregatePipeline(handle.definition, handle.native, []);
736
+ }
737
+
76
738
  // src/client/client.ts
77
739
  var import_mongodb2 = require("mongodb");
78
740
 
@@ -239,7 +901,7 @@ async function syncIndexes(handle, options) {
239
901
  }
240
902
 
241
903
  // src/crud/delete.ts
242
- var import_zod = require("zod");
904
+ var import_zod2 = require("zod");
243
905
 
244
906
  // src/errors/validation.ts
245
907
  var ZodmonValidationError = class extends Error {
@@ -280,7 +942,7 @@ async function findOneAndDelete(handle, filter, options) {
280
942
  try {
281
943
  return handle.definition.schema.parse(result);
282
944
  } catch (err) {
283
- if (err instanceof import_zod.z.ZodError) {
945
+ if (err instanceof import_zod2.z.ZodError) {
284
946
  throw new ZodmonValidationError(handle.definition.name, err);
285
947
  }
286
948
  throw err;
@@ -288,7 +950,7 @@ async function findOneAndDelete(handle, filter, options) {
288
950
  }
289
951
 
290
952
  // src/crud/find.ts
291
- var import_zod3 = require("zod");
953
+ var import_zod4 = require("zod");
292
954
 
293
955
  // src/errors/not-found.ts
294
956
  var ZodmonNotFoundError = class extends Error {
@@ -325,7 +987,7 @@ function checkUnindexedFields(definition, filter) {
325
987
  }
326
988
 
327
989
  // src/query/cursor.ts
328
- var import_zod2 = require("zod");
990
+ var import_zod3 = require("zod");
329
991
 
330
992
  // src/crud/paginate.ts
331
993
  var import_mongodb = require("mongodb");
@@ -585,7 +1247,7 @@ var TypedFindCursor = class {
585
1247
  try {
586
1248
  return this.schema.parse(raw2);
587
1249
  } catch (err) {
588
- if (err instanceof import_zod2.z.ZodError) {
1250
+ if (err instanceof import_zod3.z.ZodError) {
589
1251
  throw new ZodmonValidationError(this.collectionName, err);
590
1252
  }
591
1253
  throw err;
@@ -606,7 +1268,7 @@ async function findOne(handle, filter, options) {
606
1268
  try {
607
1269
  return handle.definition.schema.parse(raw2);
608
1270
  } catch (err) {
609
- if (err instanceof import_zod3.z.ZodError) {
1271
+ if (err instanceof import_zod4.z.ZodError) {
610
1272
  throw new ZodmonValidationError(handle.definition.name, err);
611
1273
  }
612
1274
  throw err;
@@ -628,13 +1290,13 @@ function find(handle, filter, options) {
628
1290
  }
629
1291
 
630
1292
  // src/crud/insert.ts
631
- var import_zod4 = require("zod");
1293
+ var import_zod5 = require("zod");
632
1294
  async function insertOne(handle, doc) {
633
1295
  let parsed;
634
1296
  try {
635
1297
  parsed = handle.definition.schema.parse(doc);
636
1298
  } catch (err) {
637
- if (err instanceof import_zod4.z.ZodError) {
1299
+ if (err instanceof import_zod5.z.ZodError) {
638
1300
  throw new ZodmonValidationError(handle.definition.name, err);
639
1301
  }
640
1302
  throw err;
@@ -649,7 +1311,7 @@ async function insertMany(handle, docs) {
649
1311
  try {
650
1312
  parsed.push(handle.definition.schema.parse(doc));
651
1313
  } catch (err) {
652
- if (err instanceof import_zod4.z.ZodError) {
1314
+ if (err instanceof import_zod5.z.ZodError) {
653
1315
  throw new ZodmonValidationError(handle.definition.name, err);
654
1316
  }
655
1317
  throw err;
@@ -660,7 +1322,7 @@ async function insertMany(handle, docs) {
660
1322
  }
661
1323
 
662
1324
  // src/crud/update.ts
663
- var import_zod5 = require("zod");
1325
+ var import_zod6 = require("zod");
664
1326
  async function updateOne(handle, filter, update, options) {
665
1327
  return await handle.native.updateOne(filter, update, options);
666
1328
  }
@@ -691,7 +1353,7 @@ async function findOneAndUpdate(handle, filter, update, options) {
691
1353
  try {
692
1354
  return handle.definition.schema.parse(result);
693
1355
  } catch (err) {
694
- if (err instanceof import_zod5.z.ZodError) {
1356
+ if (err instanceof import_zod6.z.ZodError) {
695
1357
  throw new ZodmonValidationError(handle.definition.name, err);
696
1358
  }
697
1359
  throw err;
@@ -982,6 +1644,27 @@ var CollectionHandle = class {
982
1644
  async syncIndexes(options) {
983
1645
  return await syncIndexes(this, options);
984
1646
  }
1647
+ /**
1648
+ * Start a type-safe aggregation pipeline on this collection.
1649
+ *
1650
+ * Returns a fluent pipeline builder that tracks the output document
1651
+ * shape through each stage. The pipeline is lazy — no query executes
1652
+ * until a terminal method (`toArray`, `for await`, `explain`) is called.
1653
+ *
1654
+ * @returns A new pipeline builder starting with this collection's document type.
1655
+ *
1656
+ * @example
1657
+ * ```ts
1658
+ * const users = db.use(Users)
1659
+ * const result = await users.aggregate()
1660
+ * .match({ role: 'admin' })
1661
+ * .groupBy('role', { count: $count() })
1662
+ * .toArray()
1663
+ * ```
1664
+ */
1665
+ aggregate() {
1666
+ return aggregate(this);
1667
+ }
985
1668
  };
986
1669
 
987
1670
  // src/client/client.ts
@@ -1007,9 +1690,7 @@ var Database = class {
1007
1690
  */
1008
1691
  use(def) {
1009
1692
  this._collections.set(def.name, def);
1010
- const native = this._db.collection(
1011
- def.name
1012
- );
1693
+ const native = this._db.collection(def.name);
1013
1694
  return new CollectionHandle(
1014
1695
  def,
1015
1696
  native
@@ -1087,36 +1768,6 @@ var import_zod9 = require("zod");
1087
1768
 
1088
1769
  // src/schema/extensions.ts
1089
1770
  var import_zod7 = require("zod");
1090
-
1091
- // src/schema/ref.ts
1092
- var import_zod6 = require("zod");
1093
- var refMetadata = /* @__PURE__ */ new WeakMap();
1094
- function getRefMetadata(schema) {
1095
- if (typeof schema !== "object" || schema === null) return void 0;
1096
- return refMetadata.get(schema);
1097
- }
1098
- var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
1099
- function installRefExtension() {
1100
- const proto = import_zod6.z.ZodType.prototype;
1101
- if (REF_GUARD in proto) return;
1102
- Object.defineProperty(proto, "ref", {
1103
- value(collection2) {
1104
- refMetadata.set(this, { collection: collection2 });
1105
- return this;
1106
- },
1107
- enumerable: true,
1108
- configurable: true,
1109
- writable: true
1110
- });
1111
- Object.defineProperty(proto, REF_GUARD, {
1112
- value: true,
1113
- enumerable: false,
1114
- configurable: false,
1115
- writable: false
1116
- });
1117
- }
1118
-
1119
- // src/schema/extensions.ts
1120
1771
  var indexMetadata = /* @__PURE__ */ new WeakMap();
1121
1772
  function getIndexMetadata(schema) {
1122
1773
  if (typeof schema !== "object" || schema === null) return void 0;
@@ -1367,29 +2018,42 @@ var $ = {
1367
2018
  // Annotate the CommonJS export names for ESM import in node:
1368
2019
  0 && (module.exports = {
1369
2020
  $,
2021
+ $addToSet,
1370
2022
  $and,
2023
+ $avg,
2024
+ $count,
1371
2025
  $eq,
1372
2026
  $exists,
2027
+ $first,
1373
2028
  $gt,
1374
2029
  $gte,
1375
2030
  $in,
2031
+ $last,
1376
2032
  $lt,
1377
2033
  $lte,
2034
+ $max,
2035
+ $min,
1378
2036
  $ne,
1379
2037
  $nin,
1380
2038
  $nor,
1381
2039
  $not,
1382
2040
  $or,
2041
+ $push,
1383
2042
  $regex,
2043
+ $sum,
2044
+ AggregatePipeline,
1384
2045
  CollectionHandle,
1385
2046
  Database,
1386
2047
  IndexBuilder,
1387
2048
  TypedFindCursor,
1388
2049
  ZodmonNotFoundError,
1389
2050
  ZodmonValidationError,
2051
+ aggregate,
1390
2052
  checkUnindexedFields,
1391
2053
  collection,
2054
+ createAccumulatorBuilder,
1392
2055
  createClient,
2056
+ createExpressionBuilder,
1393
2057
  deleteMany,
1394
2058
  deleteOne,
1395
2059
  extractComparableOptions,