@zodmon/core 0.7.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,30 +21,45 @@ 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,
55
+ checkUnindexedFields: () => checkUnindexedFields,
44
56
  collection: () => collection,
57
+ createAccumulatorBuilder: () => createAccumulatorBuilder,
45
58
  createClient: () => createClient,
59
+ createExpressionBuilder: () => createExpressionBuilder,
46
60
  deleteMany: () => deleteMany,
47
61
  deleteOne: () => deleteOne,
62
+ extractComparableOptions: () => extractComparableOptions,
48
63
  extractDbName: () => extractDbName,
49
64
  extractFieldIndexes: () => extractFieldIndexes,
50
65
  find: () => find,
@@ -52,6 +67,7 @@ __export(index_exports, {
52
67
  findOneAndDelete: () => findOneAndDelete,
53
68
  findOneAndUpdate: () => findOneAndUpdate,
54
69
  findOneOrThrow: () => findOneOrThrow,
70
+ generateIndexName: () => generateIndexName,
55
71
  getIndexMetadata: () => getIndexMetadata,
56
72
  getRefMetadata: () => getRefMetadata,
57
73
  index: () => index,
@@ -61,16 +77,831 @@ __export(index_exports, {
61
77
  objectId: () => objectId,
62
78
  oid: () => oid,
63
79
  raw: () => raw,
80
+ serializeIndexKey: () => serializeIndexKey,
81
+ syncIndexes: () => syncIndexes,
82
+ toCompoundIndexSpec: () => toCompoundIndexSpec,
83
+ toFieldIndexSpec: () => toFieldIndexSpec,
64
84
  updateMany: () => updateMany,
65
85
  updateOne: () => updateOne
66
86
  });
67
87
  module.exports = __toCommonJS(index_exports);
68
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
+
69
738
  // src/client/client.ts
70
739
  var import_mongodb2 = require("mongodb");
71
740
 
741
+ // src/indexes/spec.ts
742
+ function toFieldIndexSpec(def) {
743
+ const direction = def.text ? "text" : def.descending ? -1 : 1;
744
+ const key = { [def.field]: direction };
745
+ const options = {};
746
+ if (def.unique) options["unique"] = true;
747
+ if (def.sparse) options["sparse"] = true;
748
+ if (def.expireAfter !== void 0) options["expireAfterSeconds"] = def.expireAfter;
749
+ if (def.partial) options["partialFilterExpression"] = def.partial;
750
+ return { key, options };
751
+ }
752
+ function toCompoundIndexSpec(def) {
753
+ const key = { ...def.fields };
754
+ const options = {};
755
+ if (def.options?.unique) options["unique"] = true;
756
+ if (def.options?.sparse) options["sparse"] = true;
757
+ if (def.options?.name) options["name"] = def.options.name;
758
+ if (def.options?.partial) options["partialFilterExpression"] = def.options.partial;
759
+ return { key, options };
760
+ }
761
+ function serializeIndexKey(key) {
762
+ return Object.entries(key).map(([field, dir]) => `${field}:${dir}`).join(",");
763
+ }
764
+
765
+ // src/indexes/sync.ts
766
+ var COMPARABLE_OPTION_KEYS = [
767
+ "unique",
768
+ "sparse",
769
+ "expireAfterSeconds",
770
+ "partialFilterExpression"
771
+ ];
772
+ function extractComparableOptions(info) {
773
+ const result = {};
774
+ for (const key of COMPARABLE_OPTION_KEYS) {
775
+ if (info[key] !== void 0) {
776
+ result[key] = info[key];
777
+ }
778
+ }
779
+ return result;
780
+ }
781
+ function generateIndexName(key) {
782
+ return Object.entries(key).map(([field, dir]) => `${field}_${dir}`).join("_");
783
+ }
784
+ function sortKeys(obj) {
785
+ const sorted = {};
786
+ for (const key of Object.keys(obj).sort()) {
787
+ sorted[key] = obj[key];
788
+ }
789
+ return sorted;
790
+ }
791
+ function resolveSpecName(spec) {
792
+ const specName = spec.options["name"];
793
+ return typeof specName === "string" ? specName : generateIndexName(spec.key);
794
+ }
795
+ function resolveExistingName(info, key) {
796
+ const infoName = info["name"];
797
+ if (typeof infoName === "string") return infoName;
798
+ return generateIndexName(key);
799
+ }
800
+ function optionsMatch(a, b) {
801
+ return JSON.stringify(sortKeys(stripName(a))) === JSON.stringify(sortKeys(stripName(b)));
802
+ }
803
+ function stripName(obj) {
804
+ const { name: _, ...rest } = obj;
805
+ return rest;
806
+ }
807
+ async function processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc) {
808
+ const serialized = serializeIndexKey(spec.key);
809
+ const existing = existingByKey.get(serialized);
810
+ if (!existing) {
811
+ if (!dryRun) await native.createIndex(spec.key, spec.options);
812
+ acc.created.push(resolveSpecName(spec));
813
+ return;
814
+ }
815
+ acc.matchedKeys.add(serialized);
816
+ const existingName = resolveExistingName(existing, spec.key);
817
+ const existingOpts = extractComparableOptions(existing);
818
+ if (optionsMatch(existingOpts, spec.options)) {
819
+ acc.skipped.push(existingName);
820
+ return;
821
+ }
822
+ if (dropOrphaned) {
823
+ if (!dryRun) {
824
+ await native.dropIndex(existingName);
825
+ await native.createIndex(spec.key, spec.options);
826
+ }
827
+ acc.dropped.push(existingName);
828
+ acc.created.push(resolveSpecName(spec));
829
+ return;
830
+ }
831
+ acc.stale.push({
832
+ name: existingName,
833
+ key: spec.key,
834
+ existing: existingOpts,
835
+ desired: spec.options
836
+ });
837
+ }
838
+ async function processOrphanedIndexes(existingIndexes, desiredKeys, matchedKeys, native, dryRun, dropOrphaned, dropped) {
839
+ for (const idx of existingIndexes) {
840
+ const rawName = idx["name"];
841
+ const name = typeof rawName === "string" ? rawName : "";
842
+ if (name === "_id_") continue;
843
+ const serialized = serializeIndexKey(idx["key"]);
844
+ if (matchedKeys.has(serialized) || desiredKeys.has(serialized)) continue;
845
+ if (dropOrphaned) {
846
+ if (!dryRun) await native.dropIndex(name);
847
+ dropped.push(name);
848
+ }
849
+ }
850
+ }
851
+ async function listIndexesSafe(native) {
852
+ try {
853
+ return await native.listIndexes().toArray();
854
+ } catch (err) {
855
+ if (err instanceof Error && err.message.includes("ns does not exist")) {
856
+ return [];
857
+ }
858
+ throw err;
859
+ }
860
+ }
861
+ async function syncIndexes(handle, options) {
862
+ const { dryRun = false, dropOrphaned = false } = options ?? {};
863
+ const native = handle.native;
864
+ const def = handle.definition;
865
+ const desiredSpecs = [
866
+ ...def.fieldIndexes.map(toFieldIndexSpec),
867
+ ...def.compoundIndexes.map(toCompoundIndexSpec)
868
+ ];
869
+ const existingIndexes = await listIndexesSafe(native);
870
+ const existingByKey = /* @__PURE__ */ new Map();
871
+ for (const idx of existingIndexes) {
872
+ const serialized = serializeIndexKey(idx["key"]);
873
+ existingByKey.set(serialized, idx);
874
+ }
875
+ const acc = {
876
+ created: [],
877
+ dropped: [],
878
+ skipped: [],
879
+ stale: [],
880
+ matchedKeys: /* @__PURE__ */ new Set()
881
+ };
882
+ for (const spec of desiredSpecs) {
883
+ await processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc);
884
+ }
885
+ const desiredKeys = new Set(desiredSpecs.map((s) => serializeIndexKey(s.key)));
886
+ await processOrphanedIndexes(
887
+ existingIndexes,
888
+ desiredKeys,
889
+ acc.matchedKeys,
890
+ native,
891
+ dryRun,
892
+ dropOrphaned,
893
+ acc.dropped
894
+ );
895
+ return {
896
+ created: acc.created,
897
+ dropped: acc.dropped,
898
+ skipped: acc.skipped,
899
+ stale: acc.stale
900
+ };
901
+ }
902
+
72
903
  // src/crud/delete.ts
73
- var import_zod = require("zod");
904
+ var import_zod2 = require("zod");
74
905
 
75
906
  // src/errors/validation.ts
76
907
  var ZodmonValidationError = class extends Error {
@@ -111,7 +942,7 @@ async function findOneAndDelete(handle, filter, options) {
111
942
  try {
112
943
  return handle.definition.schema.parse(result);
113
944
  } catch (err) {
114
- if (err instanceof import_zod.z.ZodError) {
945
+ if (err instanceof import_zod2.z.ZodError) {
115
946
  throw new ZodmonValidationError(handle.definition.name, err);
116
947
  }
117
948
  throw err;
@@ -119,7 +950,7 @@ async function findOneAndDelete(handle, filter, options) {
119
950
  }
120
951
 
121
952
  // src/crud/find.ts
122
- var import_zod3 = require("zod");
953
+ var import_zod4 = require("zod");
123
954
 
124
955
  // src/errors/not-found.ts
125
956
  var ZodmonNotFoundError = class extends Error {
@@ -132,8 +963,31 @@ var ZodmonNotFoundError = class extends Error {
132
963
  }
133
964
  };
134
965
 
966
+ // src/indexes/warn.ts
967
+ var SKIP_OPERATORS = /* @__PURE__ */ new Set(["$or", "$and", "$nor", "$text", "$where", "$expr", "$comment"]);
968
+ function checkUnindexedFields(definition, filter) {
969
+ if (definition.options.warnUnindexedQueries !== true) return;
970
+ const covered = /* @__PURE__ */ new Set();
971
+ for (const fi of definition.fieldIndexes) {
972
+ covered.add(fi.field);
973
+ }
974
+ for (const ci of definition.compoundIndexes) {
975
+ const firstField = Object.keys(ci.fields)[0];
976
+ if (firstField !== void 0) {
977
+ covered.add(firstField);
978
+ }
979
+ }
980
+ for (const key of Object.keys(filter)) {
981
+ if (key === "_id") continue;
982
+ if (SKIP_OPERATORS.has(key)) continue;
983
+ if (!covered.has(key)) {
984
+ console.warn(`[zodmon] warn: query on '${definition.name}' uses unindexed field '${key}'`);
985
+ }
986
+ }
987
+ }
988
+
135
989
  // src/query/cursor.ts
136
- var import_zod2 = require("zod");
990
+ var import_zod3 = require("zod");
137
991
 
138
992
  // src/crud/paginate.ts
139
993
  var import_mongodb = require("mongodb");
@@ -149,8 +1003,8 @@ function deserializeValue(value) {
149
1003
  }
150
1004
  return value;
151
1005
  }
152
- function encodeCursor(doc, sortKeys, direction) {
153
- const values = sortKeys.map(([field]) => serializeValue(doc[field]));
1006
+ function encodeCursor(doc, sortKeys2, direction) {
1007
+ const values = sortKeys2.map(([field]) => serializeValue(doc[field]));
154
1008
  return btoa(JSON.stringify([direction, ...values]));
155
1009
  }
156
1010
  function decodeCursor(cursor) {
@@ -169,14 +1023,14 @@ function decodeCursor(cursor) {
169
1023
  }
170
1024
  return { direction, values: rawValues.map(deserializeValue) };
171
1025
  }
172
- function buildCursorFilter(sortKeys, values, isBackward) {
1026
+ function buildCursorFilter(sortKeys2, values, isBackward) {
173
1027
  const clauses = [];
174
- for (let i = 0; i < sortKeys.length; i++) {
1028
+ for (let i = 0; i < sortKeys2.length; i++) {
175
1029
  const clause = {};
176
1030
  for (let j = 0; j < i; j++) {
177
- clause[sortKeys[j][0]] = values[j];
1031
+ clause[sortKeys2[j][0]] = values[j];
178
1032
  }
179
- const [field, direction] = sortKeys[i];
1033
+ const [field, direction] = sortKeys2[i];
180
1034
  const isAsc = direction === 1;
181
1035
  const op = isAsc !== isBackward ? "$gt" : "$lt";
182
1036
  if (values[i] === null) {
@@ -272,14 +1126,37 @@ var TypedFindCursor = class {
272
1126
  this.cursor.limit(n);
273
1127
  return this;
274
1128
  }
1129
+ /**
1130
+ * Force the query optimizer to use the specified index.
1131
+ *
1132
+ * Only accepts index names that were declared via `.name()` in the
1133
+ * collection definition. If no named indexes exist, any string is accepted.
1134
+ *
1135
+ * @param indexName - The name of a declared compound index.
1136
+ * @returns `this` for chaining.
1137
+ *
1138
+ * @example
1139
+ * ```ts
1140
+ * const Users = collection('users', { email: z.string(), role: z.string() }, {
1141
+ * indexes: [index({ email: 1, role: -1 }).name('email_role_idx')],
1142
+ * })
1143
+ * const admins = await users.find({ role: 'admin' })
1144
+ * .hint('email_role_idx')
1145
+ * .toArray()
1146
+ * ```
1147
+ */
1148
+ hint(indexName) {
1149
+ this.cursor.hint(indexName);
1150
+ return this;
1151
+ }
275
1152
  async paginate(opts) {
276
1153
  const sortRecord = this.sortSpec ? this.sortSpec : null;
277
- const sortKeys = resolveSortKeys(sortRecord);
278
- const sort = Object.fromEntries(sortKeys);
1154
+ const sortKeys2 = resolveSortKeys(sortRecord);
1155
+ const sort = Object.fromEntries(sortKeys2);
279
1156
  if ("page" in opts) {
280
- return await this.offsetPaginate(sortKeys, sort, opts);
1157
+ return await this.offsetPaginate(sortKeys2, sort, opts);
281
1158
  }
282
- return await this.cursorPaginate(sortKeys, sort, opts);
1159
+ return await this.cursorPaginate(sortKeys2, sort, opts);
283
1160
  }
284
1161
  /** @internal Offset pagination implementation. */
285
1162
  async offsetPaginate(_sortKeys, sort, opts) {
@@ -300,16 +1177,16 @@ var TypedFindCursor = class {
300
1177
  };
301
1178
  }
302
1179
  /** @internal Cursor pagination implementation. */
303
- async cursorPaginate(sortKeys, sort, opts) {
1180
+ async cursorPaginate(sortKeys2, sort, opts) {
304
1181
  let isBackward = false;
305
1182
  let combinedFilter = this.filter;
306
1183
  if (opts.cursor) {
307
1184
  const decoded = decodeCursor(opts.cursor);
308
1185
  isBackward = decoded.direction === "b";
309
- const cursorFilter = buildCursorFilter(sortKeys, decoded.values, isBackward);
1186
+ const cursorFilter = buildCursorFilter(sortKeys2, decoded.values, isBackward);
310
1187
  combinedFilter = this.filter && Object.keys(this.filter).length > 0 ? { $and: [this.filter, cursorFilter] } : cursorFilter;
311
1188
  }
312
- const effectiveSort = isBackward ? Object.fromEntries(sortKeys.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
1189
+ const effectiveSort = isBackward ? Object.fromEntries(sortKeys2.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
313
1190
  const raw2 = await this.nativeCollection.find(combinedFilter).sort(effectiveSort).limit(opts.limit + 1).toArray();
314
1191
  const hasMore = raw2.length > opts.limit;
315
1192
  if (hasMore) raw2.pop();
@@ -319,8 +1196,8 @@ var TypedFindCursor = class {
319
1196
  docs,
320
1197
  hasNext: isBackward ? true : hasMore,
321
1198
  hasPrev: isBackward ? hasMore : opts.cursor != null,
322
- startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys, "b") : null,
323
- endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys, "f") : null
1199
+ startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys2, "b") : null,
1200
+ endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys2, "f") : null
324
1201
  };
325
1202
  }
326
1203
  /**
@@ -370,7 +1247,7 @@ var TypedFindCursor = class {
370
1247
  try {
371
1248
  return this.schema.parse(raw2);
372
1249
  } catch (err) {
373
- if (err instanceof import_zod2.z.ZodError) {
1250
+ if (err instanceof import_zod3.z.ZodError) {
374
1251
  throw new ZodmonValidationError(this.collectionName, err);
375
1252
  }
376
1253
  throw err;
@@ -380,6 +1257,7 @@ var TypedFindCursor = class {
380
1257
 
381
1258
  // src/crud/find.ts
382
1259
  async function findOne(handle, filter, options) {
1260
+ checkUnindexedFields(handle.definition, filter);
383
1261
  const findOptions = options?.project ? { projection: options.project } : void 0;
384
1262
  const raw2 = await handle.native.findOne(filter, findOptions);
385
1263
  if (!raw2) return null;
@@ -390,7 +1268,7 @@ async function findOne(handle, filter, options) {
390
1268
  try {
391
1269
  return handle.definition.schema.parse(raw2);
392
1270
  } catch (err) {
393
- if (err instanceof import_zod3.z.ZodError) {
1271
+ if (err instanceof import_zod4.z.ZodError) {
394
1272
  throw new ZodmonValidationError(handle.definition.name, err);
395
1273
  }
396
1274
  throw err;
@@ -404,6 +1282,7 @@ async function findOneOrThrow(handle, filter, options) {
404
1282
  return doc;
405
1283
  }
406
1284
  function find(handle, filter, options) {
1285
+ checkUnindexedFields(handle.definition, filter);
407
1286
  const raw2 = handle.native.find(filter);
408
1287
  const cursor = raw2;
409
1288
  const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
@@ -411,13 +1290,13 @@ function find(handle, filter, options) {
411
1290
  }
412
1291
 
413
1292
  // src/crud/insert.ts
414
- var import_zod4 = require("zod");
1293
+ var import_zod5 = require("zod");
415
1294
  async function insertOne(handle, doc) {
416
1295
  let parsed;
417
1296
  try {
418
1297
  parsed = handle.definition.schema.parse(doc);
419
1298
  } catch (err) {
420
- if (err instanceof import_zod4.z.ZodError) {
1299
+ if (err instanceof import_zod5.z.ZodError) {
421
1300
  throw new ZodmonValidationError(handle.definition.name, err);
422
1301
  }
423
1302
  throw err;
@@ -432,7 +1311,7 @@ async function insertMany(handle, docs) {
432
1311
  try {
433
1312
  parsed.push(handle.definition.schema.parse(doc));
434
1313
  } catch (err) {
435
- if (err instanceof import_zod4.z.ZodError) {
1314
+ if (err instanceof import_zod5.z.ZodError) {
436
1315
  throw new ZodmonValidationError(handle.definition.name, err);
437
1316
  }
438
1317
  throw err;
@@ -443,7 +1322,7 @@ async function insertMany(handle, docs) {
443
1322
  }
444
1323
 
445
1324
  // src/crud/update.ts
446
- var import_zod5 = require("zod");
1325
+ var import_zod6 = require("zod");
447
1326
  async function updateOne(handle, filter, update, options) {
448
1327
  return await handle.native.updateOne(filter, update, options);
449
1328
  }
@@ -474,7 +1353,7 @@ async function findOneAndUpdate(handle, filter, update, options) {
474
1353
  try {
475
1354
  return handle.definition.schema.parse(result);
476
1355
  } catch (err) {
477
- if (err instanceof import_zod5.z.ZodError) {
1356
+ if (err instanceof import_zod6.z.ZodError) {
478
1357
  throw new ZodmonValidationError(handle.definition.name, err);
479
1358
  }
480
1359
  throw err;
@@ -735,6 +1614,57 @@ var CollectionHandle = class {
735
1614
  async findOneAndDelete(filter, options) {
736
1615
  return await findOneAndDelete(this, filter, options);
737
1616
  }
1617
+ /**
1618
+ * Synchronize the indexes declared in this collection's schema with MongoDB.
1619
+ *
1620
+ * Compares the desired indexes (from field-level `.index()` / `.unique()` /
1621
+ * `.text()` / `.expireAfter()` and compound `indexes` in collection options)
1622
+ * with the indexes that currently exist in MongoDB, then creates, drops, or
1623
+ * reports differences depending on the options.
1624
+ *
1625
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
1626
+ * @returns A summary of created, dropped, skipped, and stale indexes.
1627
+ *
1628
+ * @example
1629
+ * ```ts
1630
+ * const users = db.use(Users)
1631
+ * const result = await users.syncIndexes()
1632
+ * console.log('Created:', result.created)
1633
+ * console.log('Stale:', result.stale.map(s => s.name))
1634
+ * ```
1635
+ *
1636
+ * @example
1637
+ * ```ts
1638
+ * // Dry run to preview changes without modifying the database
1639
+ * const diff = await users.syncIndexes({ dryRun: true })
1640
+ * console.log('Would create:', diff.created)
1641
+ * console.log('Would drop:', diff.dropped)
1642
+ * ```
1643
+ */
1644
+ async syncIndexes(options) {
1645
+ return await syncIndexes(this, options);
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
+ }
738
1668
  };
739
1669
 
740
1670
  // src/client/client.ts
@@ -767,12 +1697,33 @@ var Database = class {
767
1697
  );
768
1698
  }
769
1699
  /**
770
- * Synchronize indexes defined in registered collections with MongoDB.
1700
+ * Synchronize indexes for all registered collections with MongoDB.
1701
+ *
1702
+ * Iterates every collection registered via {@link use} and calls
1703
+ * {@link syncIndexes} on each one. Returns a record keyed by collection
1704
+ * name with the sync result for each.
1705
+ *
1706
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
1707
+ * @returns A record mapping collection names to their sync results.
771
1708
  *
772
- * Stub — full implementation in TASK-92.
1709
+ * @example
1710
+ * ```ts
1711
+ * const db = createClient('mongodb://localhost:27017', 'myapp')
1712
+ * db.use(Users)
1713
+ * db.use(Posts)
1714
+ * const results = await db.syncIndexes()
1715
+ * console.log(results['users'].created) // ['email_1']
1716
+ * console.log(results['posts'].created) // ['title_1']
1717
+ * ```
773
1718
  */
774
- syncIndexes() {
775
- return Promise.resolve();
1719
+ async syncIndexes(options) {
1720
+ const results = {};
1721
+ for (const [name, def] of this._collections) {
1722
+ const native = this._db.collection(name);
1723
+ const handle = new CollectionHandle(def, native);
1724
+ results[name] = await syncIndexes(handle, options);
1725
+ }
1726
+ return results;
776
1727
  }
777
1728
  /**
778
1729
  * Execute a function within a MongoDB transaction with auto-commit/rollback.
@@ -817,36 +1768,6 @@ var import_zod9 = require("zod");
817
1768
 
818
1769
  // src/schema/extensions.ts
819
1770
  var import_zod7 = require("zod");
820
-
821
- // src/schema/ref.ts
822
- var import_zod6 = require("zod");
823
- var refMetadata = /* @__PURE__ */ new WeakMap();
824
- function getRefMetadata(schema) {
825
- if (typeof schema !== "object" || schema === null) return void 0;
826
- return refMetadata.get(schema);
827
- }
828
- var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
829
- function installRefExtension() {
830
- const proto = import_zod6.z.ZodType.prototype;
831
- if (REF_GUARD in proto) return;
832
- Object.defineProperty(proto, "ref", {
833
- value(collection2) {
834
- refMetadata.set(this, { collection: collection2 });
835
- return this;
836
- },
837
- enumerable: true,
838
- configurable: true,
839
- writable: true
840
- });
841
- Object.defineProperty(proto, REF_GUARD, {
842
- value: true,
843
- enumerable: false,
844
- configurable: false,
845
- writable: false
846
- });
847
- }
848
-
849
- // src/schema/extensions.ts
850
1771
  var indexMetadata = /* @__PURE__ */ new WeakMap();
851
1772
  function getIndexMetadata(schema) {
852
1773
  if (typeof schema !== "object" || schema === null) return void 0;
@@ -987,6 +1908,8 @@ function collection(name, shape, options) {
987
1908
  schema,
988
1909
  shape,
989
1910
  fieldIndexes,
1911
+ // Safe cast: compoundIndexes is TIndexes at runtime (or an empty array when
1912
+ // no options provided). The spread into [...TIndexes] preserves the tuple type.
990
1913
  compoundIndexes: compoundIndexes ?? [],
991
1914
  options: {
992
1915
  validation: validation ?? "strict",
@@ -1011,12 +1934,29 @@ var IndexBuilder = class _IndexBuilder {
1011
1934
  options
1012
1935
  });
1013
1936
  }
1937
+ // Safe cast: _clone returns IndexBuilder<TKeys> but `this` may carry an
1938
+ // intersection from .name(). The cast is safe because _clone preserves all fields.
1014
1939
  unique() {
1015
1940
  return this._clone({ ...this.options, unique: true });
1016
1941
  }
1942
+ // Safe cast: same reasoning as unique().
1017
1943
  sparse() {
1018
1944
  return this._clone({ ...this.options, sparse: true });
1019
1945
  }
1946
+ /**
1947
+ * Set a custom name for this index, preserving the literal type.
1948
+ *
1949
+ * The returned builder carries the literal name type via an intersection,
1950
+ * enabling type-safe `.hint()` on cursors that only accepts declared names.
1951
+ *
1952
+ * @param name - The index name.
1953
+ * @returns A new IndexBuilder with the name recorded at the type level.
1954
+ *
1955
+ * @example
1956
+ * ```ts
1957
+ * index({ email: 1, role: -1 }).name('email_role_idx')
1958
+ * ```
1959
+ */
1020
1960
  name(name) {
1021
1961
  return this._clone({ ...this.options, name });
1022
1962
  }
@@ -1078,30 +2018,45 @@ var $ = {
1078
2018
  // Annotate the CommonJS export names for ESM import in node:
1079
2019
  0 && (module.exports = {
1080
2020
  $,
2021
+ $addToSet,
1081
2022
  $and,
2023
+ $avg,
2024
+ $count,
1082
2025
  $eq,
1083
2026
  $exists,
2027
+ $first,
1084
2028
  $gt,
1085
2029
  $gte,
1086
2030
  $in,
2031
+ $last,
1087
2032
  $lt,
1088
2033
  $lte,
2034
+ $max,
2035
+ $min,
1089
2036
  $ne,
1090
2037
  $nin,
1091
2038
  $nor,
1092
2039
  $not,
1093
2040
  $or,
2041
+ $push,
1094
2042
  $regex,
2043
+ $sum,
2044
+ AggregatePipeline,
1095
2045
  CollectionHandle,
1096
2046
  Database,
1097
2047
  IndexBuilder,
1098
2048
  TypedFindCursor,
1099
2049
  ZodmonNotFoundError,
1100
2050
  ZodmonValidationError,
2051
+ aggregate,
2052
+ checkUnindexedFields,
1101
2053
  collection,
2054
+ createAccumulatorBuilder,
1102
2055
  createClient,
2056
+ createExpressionBuilder,
1103
2057
  deleteMany,
1104
2058
  deleteOne,
2059
+ extractComparableOptions,
1105
2060
  extractDbName,
1106
2061
  extractFieldIndexes,
1107
2062
  find,
@@ -1109,6 +2064,7 @@ var $ = {
1109
2064
  findOneAndDelete,
1110
2065
  findOneAndUpdate,
1111
2066
  findOneOrThrow,
2067
+ generateIndexName,
1112
2068
  getIndexMetadata,
1113
2069
  getRefMetadata,
1114
2070
  index,
@@ -1118,6 +2074,10 @@ var $ = {
1118
2074
  objectId,
1119
2075
  oid,
1120
2076
  raw,
2077
+ serializeIndexKey,
2078
+ syncIndexes,
2079
+ toCompoundIndexSpec,
2080
+ toFieldIndexSpec,
1121
2081
  updateMany,
1122
2082
  updateOne
1123
2083
  });