@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.js CHANGED
@@ -1,3 +1,652 @@
1
+ // src/aggregate/expressions.ts
2
+ var $count = () => ({
3
+ __accum: true,
4
+ expr: { $sum: 1 }
5
+ });
6
+ var $sum = (field) => ({
7
+ __accum: true,
8
+ expr: { $sum: field }
9
+ });
10
+ var $avg = (field) => ({
11
+ __accum: true,
12
+ expr: { $avg: field }
13
+ });
14
+ var $min = (field) => ({
15
+ __accum: true,
16
+ expr: { $min: field }
17
+ });
18
+ var $max = (field) => ({
19
+ __accum: true,
20
+ expr: { $max: field }
21
+ });
22
+ var $first = (field) => ({
23
+ __accum: true,
24
+ expr: { $first: field }
25
+ });
26
+ var $last = (field) => ({
27
+ __accum: true,
28
+ expr: { $last: field }
29
+ });
30
+ var $push = (field) => ({
31
+ __accum: true,
32
+ expr: { $push: field }
33
+ });
34
+ var $addToSet = (field) => ({
35
+ __accum: true,
36
+ expr: { $addToSet: field }
37
+ });
38
+ function createAccumulatorBuilder() {
39
+ return {
40
+ count: () => ({ __accum: true, expr: { $sum: 1 } }),
41
+ sum: (field) => ({
42
+ __accum: true,
43
+ expr: { $sum: typeof field === "number" ? field : `$${field}` }
44
+ }),
45
+ avg: (field) => ({ __accum: true, expr: { $avg: `$${field}` } }),
46
+ min: (field) => ({ __accum: true, expr: { $min: `$${field}` } }),
47
+ max: (field) => ({ __accum: true, expr: { $max: `$${field}` } }),
48
+ first: (field) => ({ __accum: true, expr: { $first: `$${field}` } }),
49
+ last: (field) => ({ __accum: true, expr: { $last: `$${field}` } }),
50
+ push: (field) => ({ __accum: true, expr: { $push: `$${field}` } }),
51
+ addToSet: (field) => ({ __accum: true, expr: { $addToSet: `$${field}` } })
52
+ // 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.
53
+ };
54
+ }
55
+ function createExpressionBuilder() {
56
+ const $2 = (field) => `$${field}`;
57
+ const val = (v) => typeof v === "number" ? v : `$${v}`;
58
+ const expr = (value) => ({ __expr: true, value });
59
+ return {
60
+ // Arithmetic
61
+ add: (field, value) => expr({ $add: [$2(field), val(value)] }),
62
+ subtract: (field, value) => expr({ $subtract: [$2(field), val(value)] }),
63
+ multiply: (field, value) => expr({ $multiply: [$2(field), val(value)] }),
64
+ divide: (field, value) => expr({ $divide: [$2(field), val(value)] }),
65
+ mod: (field, value) => expr({ $mod: [$2(field), val(value)] }),
66
+ abs: (field) => expr({ $abs: $2(field) }),
67
+ ceil: (field) => expr({ $ceil: $2(field) }),
68
+ floor: (field) => expr({ $floor: $2(field) }),
69
+ round: (field, place = 0) => expr({ $round: [$2(field), place] }),
70
+ // String
71
+ concat: (...parts) => {
72
+ const resolved = parts.map((p) => {
73
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p)) return $2(p);
74
+ return p;
75
+ });
76
+ return expr({ $concat: resolved });
77
+ },
78
+ toLower: (field) => expr({ $toLower: $2(field) }),
79
+ toUpper: (field) => expr({ $toUpper: $2(field) }),
80
+ trim: (field) => expr({ $trim: { input: $2(field) } }),
81
+ substr: (field, start, length) => expr({ $substrBytes: [$2(field), start, length] }),
82
+ // Comparison
83
+ eq: (field, value) => expr({ $eq: [$2(field), value] }),
84
+ gt: (field, value) => expr({ $gt: [$2(field), value] }),
85
+ gte: (field, value) => expr({ $gte: [$2(field), value] }),
86
+ lt: (field, value) => expr({ $lt: [$2(field), value] }),
87
+ lte: (field, value) => expr({ $lte: [$2(field), value] }),
88
+ ne: (field, value) => expr({ $ne: [$2(field), value] }),
89
+ // Date
90
+ year: (field) => expr({ $year: $2(field) }),
91
+ month: (field) => expr({ $month: $2(field) }),
92
+ dayOfMonth: (field) => expr({ $dayOfMonth: $2(field) }),
93
+ // Array
94
+ size: (field) => expr({ $size: $2(field) }),
95
+ // Conditional
96
+ cond: (condition, thenValue, elseValue) => expr({ $cond: [condition.value, thenValue, elseValue] }),
97
+ ifNull: (field, fallback) => expr({ $ifNull: [$2(field), fallback] })
98
+ // 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.
99
+ };
100
+ }
101
+
102
+ // src/schema/ref.ts
103
+ import { z } from "zod";
104
+ var refMetadata = /* @__PURE__ */ new WeakMap();
105
+ function getRefMetadata(schema) {
106
+ if (typeof schema !== "object" || schema === null) return void 0;
107
+ return refMetadata.get(schema);
108
+ }
109
+ var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
110
+ function installRefExtension() {
111
+ const proto = z.ZodType.prototype;
112
+ if (REF_GUARD in proto) return;
113
+ Object.defineProperty(proto, "ref", {
114
+ value(collection2) {
115
+ refMetadata.set(this, { collection: collection2 });
116
+ return this;
117
+ },
118
+ enumerable: true,
119
+ configurable: true,
120
+ writable: true
121
+ });
122
+ Object.defineProperty(proto, REF_GUARD, {
123
+ value: true,
124
+ enumerable: false,
125
+ configurable: false,
126
+ writable: false
127
+ });
128
+ }
129
+
130
+ // src/aggregate/pipeline.ts
131
+ var AggregatePipeline = class _AggregatePipeline {
132
+ definition;
133
+ nativeCollection;
134
+ stages;
135
+ constructor(definition, nativeCollection, stages) {
136
+ this.definition = definition;
137
+ this.nativeCollection = nativeCollection;
138
+ this.stages = stages;
139
+ }
140
+ /**
141
+ * Append an arbitrary aggregation stage to the pipeline (escape hatch).
142
+ *
143
+ * Returns a new pipeline instance with the stage appended — the
144
+ * original pipeline is not modified.
145
+ *
146
+ * Optionally accepts a type parameter `TNew` to change the output
147
+ * type when the stage transforms the document shape.
148
+ *
149
+ * @typeParam TNew - The output type after this stage. Defaults to the current output type.
150
+ * @param stage - A raw MongoDB aggregation stage document (e.g. `{ $match: { ... } }`).
151
+ * @returns A new pipeline with the stage appended.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * const admins = aggregate(users)
156
+ * .raw({ $match: { role: 'admin' } })
157
+ * .toArray()
158
+ * ```
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * // Change output type with a $project stage
163
+ * const names = aggregate(users)
164
+ * .raw<{ name: string }>({ $project: { name: 1, _id: 0 } })
165
+ * .toArray()
166
+ * ```
167
+ */
168
+ raw(stage) {
169
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
170
+ ...this.stages,
171
+ stage
172
+ ]);
173
+ }
174
+ /**
175
+ * Execute the pipeline and return all results as an array.
176
+ *
177
+ * @returns A promise resolving to the array of output documents.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * const results = await aggregate(users)
182
+ * .raw({ $match: { age: { $gte: 18 } } })
183
+ * .toArray()
184
+ * ```
185
+ */
186
+ async toArray() {
187
+ const cursor = this.nativeCollection.aggregate(this.stages);
188
+ return await cursor.toArray();
189
+ }
190
+ /**
191
+ * Stream pipeline results one document at a time via `for await...of`.
192
+ *
193
+ * @returns An async generator yielding output documents.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * for await (const user of aggregate(users).raw({ $match: { role: 'admin' } })) {
198
+ * console.log(user.name)
199
+ * }
200
+ * ```
201
+ */
202
+ async *[Symbol.asyncIterator]() {
203
+ const cursor = this.nativeCollection.aggregate(this.stages);
204
+ for await (const doc of cursor) {
205
+ yield doc;
206
+ }
207
+ }
208
+ /**
209
+ * Return the query execution plan without running the pipeline.
210
+ *
211
+ * Useful for debugging and understanding how MongoDB will process
212
+ * the pipeline stages.
213
+ *
214
+ * @returns A promise resolving to the explain output document.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const plan = await aggregate(users)
219
+ * .raw({ $match: { role: 'admin' } })
220
+ * .explain()
221
+ * console.log(plan)
222
+ * ```
223
+ */
224
+ async explain() {
225
+ const cursor = this.nativeCollection.aggregate(this.stages);
226
+ return await cursor.explain();
227
+ }
228
+ // ── Shape-preserving stages ──────────────────────────────────────
229
+ /**
230
+ * Filter documents using a type-safe match expression.
231
+ *
232
+ * Appends a `$match` stage to the pipeline. The filter is constrained
233
+ * to the current output type, so only valid fields and operators are accepted.
234
+ *
235
+ * Supports two forms of type narrowing:
236
+ *
237
+ * **Tier 1 — Explicit type parameter:**
238
+ * ```ts
239
+ * .match<{ role: 'engineer' | 'designer' }>({ role: { $in: ['engineer', 'designer'] } })
240
+ * // role narrows to 'engineer' | 'designer'
241
+ * ```
242
+ *
243
+ * **Tier 2 — Automatic inference from filter literals:**
244
+ * ```ts
245
+ * .match({ role: 'engineer' }) // role narrows to 'engineer'
246
+ * .match({ role: { $ne: 'intern' } }) // role narrows to Exclude<Role, 'intern'>
247
+ * .match({ role: { $in: ['engineer', 'designer'] as const } }) // needs as const
248
+ * ```
249
+ *
250
+ * When no type parameter is provided and the filter doesn't contain
251
+ * inferrable literals, the output type is unchanged (backward compatible).
252
+ *
253
+ * @typeParam TNarrow - Optional object mapping field names to narrowed types. Must be a subtype of the corresponding fields in TOutput.
254
+ * @typeParam F - Inferred from the filter argument. Do not provide explicitly.
255
+ * @param filter - A type-safe filter for the current output type.
256
+ * @returns A new pipeline with the `$match` stage appended and output type narrowed.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * // Explicit narrowing
261
+ * const filtered = await users.aggregate()
262
+ * .match<{ role: 'engineer' }>({ role: 'engineer' })
263
+ * .toArray()
264
+ * // filtered[0].role → 'engineer'
265
+ *
266
+ * // Automatic narrowing with $in (requires as const)
267
+ * const subset = await users.aggregate()
268
+ * .match({ role: { $in: ['engineer', 'designer'] as const } })
269
+ * .toArray()
270
+ * // subset[0].role → 'engineer' | 'designer'
271
+ * ```
272
+ */
273
+ match(filter) {
274
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
275
+ ...this.stages,
276
+ { $match: filter }
277
+ ]);
278
+ return pipeline;
279
+ }
280
+ /**
281
+ * Sort documents by one or more fields.
282
+ *
283
+ * Appends a `$sort` stage. Keys are constrained to `keyof TOutput & string`
284
+ * and values must be `1` (ascending) or `-1` (descending).
285
+ *
286
+ * @param spec - A sort specification mapping field names to sort direction.
287
+ * @returns A new pipeline with the `$sort` stage appended.
288
+ *
289
+ * @example
290
+ * ```ts
291
+ * const sorted = await aggregate(users)
292
+ * .sort({ age: -1, name: 1 })
293
+ * .toArray()
294
+ * ```
295
+ */
296
+ sort(spec) {
297
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
298
+ ...this.stages,
299
+ { $sort: spec }
300
+ ]);
301
+ }
302
+ /**
303
+ * Skip a number of documents in the pipeline.
304
+ *
305
+ * Appends a `$skip` stage. Commonly used with {@link limit} for pagination.
306
+ *
307
+ * @param n - The number of documents to skip.
308
+ * @returns A new pipeline with the `$skip` stage appended.
309
+ *
310
+ * @example
311
+ * ```ts
312
+ * // Page 2 (10 items per page)
313
+ * const page2 = await aggregate(users)
314
+ * .sort({ name: 1 })
315
+ * .skip(10)
316
+ * .limit(10)
317
+ * .toArray()
318
+ * ```
319
+ */
320
+ skip(n) {
321
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
322
+ ...this.stages,
323
+ { $skip: n }
324
+ ]);
325
+ }
326
+ /**
327
+ * Limit the number of documents passing through the pipeline.
328
+ *
329
+ * Appends a `$limit` stage. Commonly used with {@link skip} for pagination,
330
+ * or after {@link sort} to get top/bottom N results.
331
+ *
332
+ * @param n - The maximum number of documents to pass through.
333
+ * @returns A new pipeline with the `$limit` stage appended.
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * const top5 = await aggregate(users)
338
+ * .sort({ score: -1 })
339
+ * .limit(5)
340
+ * .toArray()
341
+ * ```
342
+ */
343
+ limit(n) {
344
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
345
+ ...this.stages,
346
+ { $limit: n }
347
+ ]);
348
+ }
349
+ // ── Shape-transforming projection stages ─────────────────────────
350
+ /**
351
+ * Include only specified fields in the output.
352
+ *
353
+ * Appends a `$project` stage with inclusion (`1`) for each key.
354
+ * The `_id` field is always included. The output type narrows to
355
+ * `Pick<TOutput, K | '_id'>`.
356
+ *
357
+ * @param spec - An object mapping field names to `1` for inclusion.
358
+ * @returns A new pipeline with the `$project` stage appended.
359
+ *
360
+ * @example
361
+ * ```ts
362
+ * const namesOnly = await aggregate(users)
363
+ * .project({ name: 1 })
364
+ * .toArray()
365
+ * // [{ _id: ..., name: 'Ada' }, ...]
366
+ * ```
367
+ */
368
+ project(spec) {
369
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
370
+ ...this.stages,
371
+ { $project: spec }
372
+ ]);
373
+ return pipeline;
374
+ }
375
+ /**
376
+ * Variadic shorthand for {@link project} — pick fields to include.
377
+ *
378
+ * Generates a `$project` stage that includes only the listed fields
379
+ * (plus `_id`). Equivalent to `.project({ field1: 1, field2: 1 })`.
380
+ *
381
+ * @param fields - Field names to include in the output.
382
+ * @returns A new pipeline with the `$project` stage appended.
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * const namesAndRoles = await aggregate(users)
387
+ * .pick('name', 'role')
388
+ * .toArray()
389
+ * ```
390
+ */
391
+ pick(...fields) {
392
+ const spec = Object.fromEntries(fields.map((f) => [f, 1]));
393
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
394
+ ...this.stages,
395
+ { $project: spec }
396
+ ]);
397
+ return pipeline;
398
+ }
399
+ /**
400
+ * Exclude specified fields from the output.
401
+ *
402
+ * Appends a `$project` stage with exclusion (`0`) for each key.
403
+ * All other fields pass through. The output type becomes `Omit<TOutput, K>`.
404
+ *
405
+ * @param fields - Field names to exclude from the output.
406
+ * @returns A new pipeline with the `$project` stage appended.
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * const noAge = await aggregate(users)
411
+ * .omit('age')
412
+ * .toArray()
413
+ * ```
414
+ */
415
+ omit(...fields) {
416
+ const spec = Object.fromEntries(fields.map((f) => [f, 0]));
417
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
418
+ ...this.stages,
419
+ { $project: spec }
420
+ ]);
421
+ return pipeline;
422
+ }
423
+ groupBy(field, accumulators) {
424
+ const resolved = typeof accumulators === "function" ? accumulators(createAccumulatorBuilder()) : accumulators;
425
+ const _id = Array.isArray(field) ? Object.fromEntries(field.map((f) => [f, `$${f}`])) : `$${field}`;
426
+ const accumExprs = Object.fromEntries(
427
+ Object.entries(resolved).map(([key, acc]) => [key, acc.expr])
428
+ );
429
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
430
+ ...this.stages,
431
+ { $group: { _id, ...accumExprs } }
432
+ ]);
433
+ return pipeline;
434
+ }
435
+ // Implementation
436
+ addFields(fields) {
437
+ const resolved = typeof fields === "function" ? fields(createExpressionBuilder()) : fields;
438
+ const stage = Object.fromEntries(
439
+ Object.entries(resolved).map(([k, v]) => [
440
+ k,
441
+ v && typeof v === "object" && "__expr" in v ? v.value : v
442
+ ])
443
+ );
444
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
445
+ ...this.stages,
446
+ { $addFields: stage }
447
+ ]);
448
+ return pipeline;
449
+ }
450
+ // ── unwind stage ─────────────────────────────────────────────────
451
+ /**
452
+ * Deconstruct an array field, outputting one document per array element.
453
+ *
454
+ * Appends an `$unwind` stage. The unwound field's type changes from
455
+ * `T[]` to `T` in the output type. Documents with empty or missing
456
+ * arrays are dropped unless `preserveEmpty` is `true`.
457
+ *
458
+ * @param field - The name of the array field to unwind.
459
+ * @param options - Optional settings for the unwind stage.
460
+ * @param options.preserveEmpty - If `true`, documents with null, missing, or empty arrays are preserved.
461
+ * @returns A new pipeline with the `$unwind` stage appended.
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * const flat = await aggregate(orders)
466
+ * .unwind('items')
467
+ * .toArray()
468
+ * // Each result has a single `items` value instead of an array
469
+ * ```
470
+ */
471
+ unwind(field, options) {
472
+ const stage = options?.preserveEmpty ? { $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true } } : { $unwind: `$${field}` };
473
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
474
+ ...this.stages,
475
+ stage
476
+ ]);
477
+ return pipeline;
478
+ }
479
+ lookup(fieldOrFrom, options) {
480
+ const stages = [...this.stages];
481
+ if (typeof fieldOrFrom === "object") {
482
+ const foreignName = fieldOrFrom.name;
483
+ const foreignField = options?.on;
484
+ if (!foreignField) {
485
+ throw new Error(
486
+ `[zodmon] lookup: reverse lookup on '${foreignName}' requires an 'on' option specifying which field on the foreign collection references this collection.`
487
+ );
488
+ }
489
+ const asField = options?.as ?? foreignName;
490
+ stages.push({
491
+ $lookup: {
492
+ from: foreignName,
493
+ localField: "_id",
494
+ foreignField,
495
+ as: asField
496
+ }
497
+ });
498
+ if (options?.unwind) {
499
+ stages.push({ $unwind: { path: `$${asField}`, preserveNullAndEmptyArrays: true } });
500
+ }
501
+ } else {
502
+ const shape = this.definition.shape;
503
+ const fieldSchema = shape[fieldOrFrom];
504
+ const ref = getRefMetadata(fieldSchema);
505
+ if (!ref) {
506
+ throw new Error(
507
+ `[zodmon] lookup: field '${fieldOrFrom}' has no .ref() metadata. Use .lookup(CollectionDef, { on: foreignKey }) for reverse lookups, or add .ref(TargetCollection) to the field schema.`
508
+ );
509
+ }
510
+ const targetName = ref.collection.name;
511
+ const asField = options?.as ?? targetName;
512
+ stages.push({
513
+ $lookup: {
514
+ from: targetName,
515
+ localField: fieldOrFrom,
516
+ foreignField: "_id",
517
+ as: asField
518
+ }
519
+ });
520
+ if (options?.unwind) {
521
+ stages.push({ $unwind: { path: `$${asField}`, preserveNullAndEmptyArrays: true } });
522
+ }
523
+ }
524
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, stages);
525
+ return pipeline;
526
+ }
527
+ // ── Convenience shortcuts ────────────────────────────────────────
528
+ /**
529
+ * Count documents per group, sorted by count descending.
530
+ *
531
+ * Shorthand for `.groupBy(field, { count: $count() }).sort({ count: -1 })`.
532
+ *
533
+ * @param field - The field to group and count by.
534
+ * @returns A new pipeline producing `{ _id: TOutput[K], count: number }` results.
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * const roleCounts = await aggregate(users)
539
+ * .countBy('role')
540
+ * .toArray()
541
+ * // [{ _id: 'user', count: 3 }, { _id: 'admin', count: 2 }]
542
+ * ```
543
+ */
544
+ countBy(field) {
545
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
546
+ ...this.stages,
547
+ { $group: { _id: `$${field}`, count: { $sum: 1 } } },
548
+ { $sort: { count: -1 } }
549
+ ]);
550
+ return pipeline;
551
+ }
552
+ /**
553
+ * Sum a numeric field per group, sorted by total descending.
554
+ *
555
+ * Shorthand for `.groupBy(field, { total: $sum('$sumField') }).sort({ total: -1 })`.
556
+ *
557
+ * @param field - The field to group by.
558
+ * @param sumField - The numeric field to sum.
559
+ * @returns A new pipeline producing `{ _id: TOutput[K], total: number }` results.
560
+ *
561
+ * @example
562
+ * ```ts
563
+ * const revenueByCategory = await aggregate(orders)
564
+ * .sumBy('category', 'amount')
565
+ * .toArray()
566
+ * // [{ _id: 'electronics', total: 5000 }, ...]
567
+ * ```
568
+ */
569
+ sumBy(field, sumField) {
570
+ const pipeline = new _AggregatePipeline(this.definition, this.nativeCollection, [
571
+ ...this.stages,
572
+ { $group: { _id: `$${field}`, total: { $sum: `$${sumField}` } } },
573
+ { $sort: { total: -1 } }
574
+ ]);
575
+ return pipeline;
576
+ }
577
+ /**
578
+ * Sort by a single field with a friendly direction name.
579
+ *
580
+ * Shorthand for `.sort({ [field]: direction === 'desc' ? -1 : 1 })`.
581
+ *
582
+ * @param field - The field to sort by.
583
+ * @param direction - Sort direction: `'asc'` (default) or `'desc'`.
584
+ * @returns A new pipeline with the `$sort` stage appended.
585
+ *
586
+ * @example
587
+ * ```ts
588
+ * const youngest = await aggregate(users)
589
+ * .sortBy('age')
590
+ * .toArray()
591
+ * ```
592
+ */
593
+ sortBy(field, direction = "asc") {
594
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
595
+ ...this.stages,
596
+ { $sort: { [field]: direction === "desc" ? -1 : 1 } }
597
+ ]);
598
+ }
599
+ /**
600
+ * Return the top N documents sorted by a field descending.
601
+ *
602
+ * Shorthand for `.sort({ [by]: -1 }).limit(n)`.
603
+ *
604
+ * @param n - The number of documents to return.
605
+ * @param options - An object with a `by` field specifying the sort key.
606
+ * @returns A new pipeline with `$sort` and `$limit` stages appended.
607
+ *
608
+ * @example
609
+ * ```ts
610
+ * const top3 = await aggregate(users)
611
+ * .top(3, { by: 'score' })
612
+ * .toArray()
613
+ * ```
614
+ */
615
+ top(n, options) {
616
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
617
+ ...this.stages,
618
+ { $sort: { [options.by]: -1 } },
619
+ { $limit: n }
620
+ ]);
621
+ }
622
+ /**
623
+ * Return the bottom N documents sorted by a field ascending.
624
+ *
625
+ * Shorthand for `.sort({ [by]: 1 }).limit(n)`.
626
+ *
627
+ * @param n - The number of documents to return.
628
+ * @param options - An object with a `by` field specifying the sort key.
629
+ * @returns A new pipeline with `$sort` and `$limit` stages appended.
630
+ *
631
+ * @example
632
+ * ```ts
633
+ * const bottom3 = await aggregate(users)
634
+ * .bottom(3, { by: 'score' })
635
+ * .toArray()
636
+ * ```
637
+ */
638
+ bottom(n, options) {
639
+ return new _AggregatePipeline(this.definition, this.nativeCollection, [
640
+ ...this.stages,
641
+ { $sort: { [options.by]: 1 } },
642
+ { $limit: n }
643
+ ]);
644
+ }
645
+ };
646
+ function aggregate(handle) {
647
+ return new AggregatePipeline(handle.definition, handle.native, []);
648
+ }
649
+
1
650
  // src/client/client.ts
2
651
  import { MongoClient } from "mongodb";
3
652
 
@@ -164,7 +813,7 @@ async function syncIndexes(handle, options) {
164
813
  }
165
814
 
166
815
  // src/crud/delete.ts
167
- import { z } from "zod";
816
+ import { z as z2 } from "zod";
168
817
 
169
818
  // src/errors/validation.ts
170
819
  var ZodmonValidationError = class extends Error {
@@ -205,7 +854,7 @@ async function findOneAndDelete(handle, filter, options) {
205
854
  try {
206
855
  return handle.definition.schema.parse(result);
207
856
  } catch (err) {
208
- if (err instanceof z.ZodError) {
857
+ if (err instanceof z2.ZodError) {
209
858
  throw new ZodmonValidationError(handle.definition.name, err);
210
859
  }
211
860
  throw err;
@@ -213,7 +862,7 @@ async function findOneAndDelete(handle, filter, options) {
213
862
  }
214
863
 
215
864
  // src/crud/find.ts
216
- import { z as z3 } from "zod";
865
+ import { z as z4 } from "zod";
217
866
 
218
867
  // src/errors/not-found.ts
219
868
  var ZodmonNotFoundError = class extends Error {
@@ -250,7 +899,7 @@ function checkUnindexedFields(definition, filter) {
250
899
  }
251
900
 
252
901
  // src/query/cursor.ts
253
- import { z as z2 } from "zod";
902
+ import { z as z3 } from "zod";
254
903
 
255
904
  // src/crud/paginate.ts
256
905
  import { ObjectId } from "mongodb";
@@ -510,7 +1159,7 @@ var TypedFindCursor = class {
510
1159
  try {
511
1160
  return this.schema.parse(raw2);
512
1161
  } catch (err) {
513
- if (err instanceof z2.ZodError) {
1162
+ if (err instanceof z3.ZodError) {
514
1163
  throw new ZodmonValidationError(this.collectionName, err);
515
1164
  }
516
1165
  throw err;
@@ -531,7 +1180,7 @@ async function findOne(handle, filter, options) {
531
1180
  try {
532
1181
  return handle.definition.schema.parse(raw2);
533
1182
  } catch (err) {
534
- if (err instanceof z3.ZodError) {
1183
+ if (err instanceof z4.ZodError) {
535
1184
  throw new ZodmonValidationError(handle.definition.name, err);
536
1185
  }
537
1186
  throw err;
@@ -553,13 +1202,13 @@ function find(handle, filter, options) {
553
1202
  }
554
1203
 
555
1204
  // src/crud/insert.ts
556
- import { z as z4 } from "zod";
1205
+ import { z as z5 } from "zod";
557
1206
  async function insertOne(handle, doc) {
558
1207
  let parsed;
559
1208
  try {
560
1209
  parsed = handle.definition.schema.parse(doc);
561
1210
  } catch (err) {
562
- if (err instanceof z4.ZodError) {
1211
+ if (err instanceof z5.ZodError) {
563
1212
  throw new ZodmonValidationError(handle.definition.name, err);
564
1213
  }
565
1214
  throw err;
@@ -574,7 +1223,7 @@ async function insertMany(handle, docs) {
574
1223
  try {
575
1224
  parsed.push(handle.definition.schema.parse(doc));
576
1225
  } catch (err) {
577
- if (err instanceof z4.ZodError) {
1226
+ if (err instanceof z5.ZodError) {
578
1227
  throw new ZodmonValidationError(handle.definition.name, err);
579
1228
  }
580
1229
  throw err;
@@ -585,7 +1234,7 @@ async function insertMany(handle, docs) {
585
1234
  }
586
1235
 
587
1236
  // src/crud/update.ts
588
- import { z as z5 } from "zod";
1237
+ import { z as z6 } from "zod";
589
1238
  async function updateOne(handle, filter, update, options) {
590
1239
  return await handle.native.updateOne(filter, update, options);
591
1240
  }
@@ -616,7 +1265,7 @@ async function findOneAndUpdate(handle, filter, update, options) {
616
1265
  try {
617
1266
  return handle.definition.schema.parse(result);
618
1267
  } catch (err) {
619
- if (err instanceof z5.ZodError) {
1268
+ if (err instanceof z6.ZodError) {
620
1269
  throw new ZodmonValidationError(handle.definition.name, err);
621
1270
  }
622
1271
  throw err;
@@ -907,6 +1556,27 @@ var CollectionHandle = class {
907
1556
  async syncIndexes(options) {
908
1557
  return await syncIndexes(this, options);
909
1558
  }
1559
+ /**
1560
+ * Start a type-safe aggregation pipeline on this collection.
1561
+ *
1562
+ * Returns a fluent pipeline builder that tracks the output document
1563
+ * shape through each stage. The pipeline is lazy — no query executes
1564
+ * until a terminal method (`toArray`, `for await`, `explain`) is called.
1565
+ *
1566
+ * @returns A new pipeline builder starting with this collection's document type.
1567
+ *
1568
+ * @example
1569
+ * ```ts
1570
+ * const users = db.use(Users)
1571
+ * const result = await users.aggregate()
1572
+ * .match({ role: 'admin' })
1573
+ * .groupBy('role', { count: $count() })
1574
+ * .toArray()
1575
+ * ```
1576
+ */
1577
+ aggregate() {
1578
+ return aggregate(this);
1579
+ }
910
1580
  };
911
1581
 
912
1582
  // src/client/client.ts
@@ -932,9 +1602,7 @@ var Database = class {
932
1602
  */
933
1603
  use(def) {
934
1604
  this._collections.set(def.name, def);
935
- const native = this._db.collection(
936
- def.name
937
- );
1605
+ const native = this._db.collection(def.name);
938
1606
  return new CollectionHandle(
939
1607
  def,
940
1608
  native
@@ -1012,36 +1680,6 @@ import { z as z9 } from "zod";
1012
1680
 
1013
1681
  // src/schema/extensions.ts
1014
1682
  import { z as z7 } from "zod";
1015
-
1016
- // src/schema/ref.ts
1017
- import { z as z6 } from "zod";
1018
- var refMetadata = /* @__PURE__ */ new WeakMap();
1019
- function getRefMetadata(schema) {
1020
- if (typeof schema !== "object" || schema === null) return void 0;
1021
- return refMetadata.get(schema);
1022
- }
1023
- var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
1024
- function installRefExtension() {
1025
- const proto = z6.ZodType.prototype;
1026
- if (REF_GUARD in proto) return;
1027
- Object.defineProperty(proto, "ref", {
1028
- value(collection2) {
1029
- refMetadata.set(this, { collection: collection2 });
1030
- return this;
1031
- },
1032
- enumerable: true,
1033
- configurable: true,
1034
- writable: true
1035
- });
1036
- Object.defineProperty(proto, REF_GUARD, {
1037
- value: true,
1038
- enumerable: false,
1039
- configurable: false,
1040
- writable: false
1041
- });
1042
- }
1043
-
1044
- // src/schema/extensions.ts
1045
1683
  var indexMetadata = /* @__PURE__ */ new WeakMap();
1046
1684
  function getIndexMetadata(schema) {
1047
1685
  if (typeof schema !== "object" || schema === null) return void 0;
@@ -1291,29 +1929,42 @@ var $ = {
1291
1929
  };
1292
1930
  export {
1293
1931
  $,
1932
+ $addToSet,
1294
1933
  $and,
1934
+ $avg,
1935
+ $count,
1295
1936
  $eq,
1296
1937
  $exists,
1938
+ $first,
1297
1939
  $gt,
1298
1940
  $gte,
1299
1941
  $in,
1942
+ $last,
1300
1943
  $lt,
1301
1944
  $lte,
1945
+ $max,
1946
+ $min,
1302
1947
  $ne,
1303
1948
  $nin,
1304
1949
  $nor,
1305
1950
  $not,
1306
1951
  $or,
1952
+ $push,
1307
1953
  $regex,
1954
+ $sum,
1955
+ AggregatePipeline,
1308
1956
  CollectionHandle,
1309
1957
  Database,
1310
1958
  IndexBuilder,
1311
1959
  TypedFindCursor,
1312
1960
  ZodmonNotFoundError,
1313
1961
  ZodmonValidationError,
1962
+ aggregate,
1314
1963
  checkUnindexedFields,
1315
1964
  collection,
1965
+ createAccumulatorBuilder,
1316
1966
  createClient,
1967
+ createExpressionBuilder,
1317
1968
  deleteMany,
1318
1969
  deleteOne,
1319
1970
  extractComparableOptions,