@classytic/mongokit 3.2.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +470 -193
  2. package/dist/actions/index.d.mts +9 -0
  3. package/dist/actions/index.mjs +15 -0
  4. package/dist/aggregate-BAi4Do-X.mjs +767 -0
  5. package/dist/aggregate-CCHI7F51.d.mts +269 -0
  6. package/dist/ai/index.d.mts +125 -0
  7. package/dist/ai/index.mjs +203 -0
  8. package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
  9. package/dist/chunk-DQk6qfdC.mjs +18 -0
  10. package/dist/create-BuO6xt0v.mjs +55 -0
  11. package/dist/custom-id.plugin-B_zIs6gE.mjs +1818 -0
  12. package/dist/custom-id.plugin-BzZI4gnE.d.mts +893 -0
  13. package/dist/index.d.mts +1012 -0
  14. package/dist/index.mjs +1906 -0
  15. package/dist/limits-DsNeCx4D.mjs +299 -0
  16. package/dist/logger-D8ily-PP.mjs +51 -0
  17. package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
  18. package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
  19. package/dist/pagination/PaginationEngine.d.mts +93 -0
  20. package/dist/pagination/PaginationEngine.mjs +196 -0
  21. package/dist/plugins/index.d.mts +3 -0
  22. package/dist/plugins/index.mjs +3 -0
  23. package/dist/types-D-gploPr.d.mts +1241 -0
  24. package/dist/utils/{index.d.ts → index.d.mts} +14 -21
  25. package/dist/utils/index.mjs +5 -0
  26. package/package.json +21 -21
  27. package/dist/actions/index.d.ts +0 -3
  28. package/dist/actions/index.js +0 -5
  29. package/dist/ai/index.d.ts +0 -175
  30. package/dist/ai/index.js +0 -206
  31. package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
  32. package/dist/chunks/chunk-44KXLGPO.js +0 -388
  33. package/dist/chunks/chunk-DEVXDBRL.js +0 -1226
  34. package/dist/chunks/chunk-I7CWNAJB.js +0 -46
  35. package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
  36. package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
  37. package/dist/chunks/chunk-URLJFIR7.js +0 -22
  38. package/dist/chunks/chunk-VWKIKZYF.js +0 -737
  39. package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
  40. package/dist/index-BDn5fSTE.d.ts +0 -516
  41. package/dist/index.d.ts +0 -1422
  42. package/dist/index.js +0 -1893
  43. package/dist/pagination/PaginationEngine.d.ts +0 -117
  44. package/dist/pagination/PaginationEngine.js +0 -3
  45. package/dist/plugins/index.d.ts +0 -922
  46. package/dist/plugins/index.js +0 -6
  47. package/dist/types-Jni1KgkP.d.ts +0 -780
  48. package/dist/utils/index.js +0 -5
package/dist/index.mjs ADDED
@@ -0,0 +1,1906 @@
1
+ import { i as createError, r as warn, t as configureLogger } from "./logger-D8ily-PP.mjs";
2
+ import { n as createMany, t as create } from "./create-BuO6xt0v.mjs";
3
+ import { a as deleteById, d as getById, f as getByQuery, i as LookupBuilder, l as count, p as getOrCreate, r as distinct, s as update, t as aggregate, u as exists } from "./aggregate-BAi4Do-X.mjs";
4
+ import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
5
+ import { c as filterResponseData, l as getFieldsForUser, s as createFieldPreset, u as getMongooseProjection } from "./cache-keys-C8Z9B5sw.mjs";
6
+ import { C as auditLogPlugin, S as softDeletePlugin, T as fieldFilterPlugin, _ as immutableField, a as sequentialId, b as validationChainPlugin, c as multiTenantPlugin, d as subdocumentPlugin, f as aggregateHelpersPlugin, g as blockIf, h as autoInject, i as prefixedId, l as cascadePlugin, m as mongoOperationsPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as batchOperationsPlugin, r as getNextSequence, s as observabilityPlugin, t as customIdPlugin, u as cachePlugin, v as requireField, w as timestampPlugin, x as methodRegistryPlugin, y as uniqueField } from "./custom-id.plugin-B_zIs6gE.mjs";
7
+ import { a as isFieldUpdateAllowed, i as getSystemManagedFields, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-COdDEkIJ.mjs";
8
+ import { t as actions_exports } from "./actions/index.mjs";
9
+ import mongoose from "mongoose";
10
+
11
+ //#region src/query/AggregationBuilder.ts
12
+ /**
13
+ * Normalize SortSpec to MongoDB's strict format (1 | -1)
14
+ * Converts 'asc' -> 1, 'desc' -> -1
15
+ */
16
+ function normalizeSortSpec(sortSpec) {
17
+ const normalized = {};
18
+ for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
19
+ else if (order === "desc") normalized[field] = -1;
20
+ else normalized[field] = order;
21
+ return normalized;
22
+ }
23
+ /**
24
+ * Fluent builder for MongoDB aggregation pipelines
25
+ * Optimized for complex queries at scale
26
+ */
27
+ var AggregationBuilder = class AggregationBuilder {
28
+ pipeline = [];
29
+ _diskUse = false;
30
+ /**
31
+ * Get the current pipeline
32
+ */
33
+ get() {
34
+ return [...this.pipeline];
35
+ }
36
+ /**
37
+ * Build and return the final pipeline
38
+ */
39
+ build() {
40
+ return this.get();
41
+ }
42
+ /**
43
+ * Build pipeline with execution options (allowDiskUse, etc.)
44
+ */
45
+ plan() {
46
+ return {
47
+ pipeline: this.get(),
48
+ allowDiskUse: this._diskUse
49
+ };
50
+ }
51
+ /**
52
+ * Build and execute the pipeline against a model
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * const results = await new AggregationBuilder()
57
+ * .match({ status: 'active' })
58
+ * .allowDiskUse()
59
+ * .exec(MyModel);
60
+ * ```
61
+ */
62
+ async exec(model, session) {
63
+ const agg = model.aggregate(this.build());
64
+ if (this._diskUse) agg.allowDiskUse(true);
65
+ if (session) agg.session(session);
66
+ return agg.exec();
67
+ }
68
+ /**
69
+ * Reset the pipeline
70
+ */
71
+ reset() {
72
+ this.pipeline = [];
73
+ this._diskUse = false;
74
+ return this;
75
+ }
76
+ /**
77
+ * Add a raw pipeline stage
78
+ */
79
+ addStage(stage) {
80
+ this.pipeline.push(stage);
81
+ return this;
82
+ }
83
+ /**
84
+ * Add multiple raw pipeline stages
85
+ */
86
+ addStages(stages) {
87
+ this.pipeline.push(...stages);
88
+ return this;
89
+ }
90
+ /**
91
+ * $match - Filter documents
92
+ * IMPORTANT: Place $match as early as possible for performance
93
+ */
94
+ match(query) {
95
+ this.pipeline.push({ $match: query });
96
+ return this;
97
+ }
98
+ /**
99
+ * $project - Include/exclude fields or compute new fields
100
+ */
101
+ project(projection) {
102
+ this.pipeline.push({ $project: projection });
103
+ return this;
104
+ }
105
+ /**
106
+ * $group - Group documents and compute aggregations
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * .group({
111
+ * _id: '$department',
112
+ * count: { $sum: 1 },
113
+ * avgSalary: { $avg: '$salary' }
114
+ * })
115
+ * ```
116
+ */
117
+ group(groupSpec) {
118
+ this.pipeline.push({ $group: groupSpec });
119
+ return this;
120
+ }
121
+ /**
122
+ * $sort - Sort documents
123
+ */
124
+ sort(sortSpec) {
125
+ if (typeof sortSpec === "string") {
126
+ const order = sortSpec.startsWith("-") ? -1 : 1;
127
+ const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
128
+ this.pipeline.push({ $sort: { [field]: order } });
129
+ } else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
130
+ return this;
131
+ }
132
+ /**
133
+ * $limit - Limit number of documents
134
+ */
135
+ limit(count) {
136
+ this.pipeline.push({ $limit: count });
137
+ return this;
138
+ }
139
+ /**
140
+ * $skip - Skip documents
141
+ */
142
+ skip(count) {
143
+ this.pipeline.push({ $skip: count });
144
+ return this;
145
+ }
146
+ /**
147
+ * $unwind - Deconstruct array field
148
+ */
149
+ unwind(path, preserveNullAndEmptyArrays = false) {
150
+ this.pipeline.push({ $unwind: {
151
+ path: path.startsWith("$") ? path : `$${path}`,
152
+ preserveNullAndEmptyArrays
153
+ } });
154
+ return this;
155
+ }
156
+ /**
157
+ * $addFields - Add new fields or replace existing fields
158
+ */
159
+ addFields(fields) {
160
+ this.pipeline.push({ $addFields: fields });
161
+ return this;
162
+ }
163
+ /**
164
+ * $set - Alias for $addFields
165
+ */
166
+ set(fields) {
167
+ return this.addFields(fields);
168
+ }
169
+ /**
170
+ * $unset - Remove fields
171
+ */
172
+ unset(fields) {
173
+ this.pipeline.push({ $unset: fields });
174
+ return this;
175
+ }
176
+ /**
177
+ * $replaceRoot - Replace the root document
178
+ */
179
+ replaceRoot(newRoot) {
180
+ this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
181
+ return this;
182
+ }
183
+ /**
184
+ * $lookup - Join with another collection (simple form)
185
+ *
186
+ * @param from - Collection to join with
187
+ * @param localField - Field from source collection
188
+ * @param foreignField - Field from target collection
189
+ * @param as - Output field name
190
+ * @param single - Unwrap array to single object
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * // Join employees with departments by slug
195
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
196
+ * ```
197
+ */
198
+ lookup(from, localField, foreignField, as, single) {
199
+ const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
200
+ this.pipeline.push(...stages);
201
+ return this;
202
+ }
203
+ /**
204
+ * $lookup - Join with another collection (advanced form with pipeline)
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * .lookupWithPipeline({
209
+ * from: 'products',
210
+ * localField: 'productIds',
211
+ * foreignField: 'sku',
212
+ * as: 'products',
213
+ * pipeline: [
214
+ * { $match: { status: 'active' } },
215
+ * { $project: { name: 1, price: 1 } }
216
+ * ]
217
+ * })
218
+ * ```
219
+ */
220
+ lookupWithPipeline(options) {
221
+ const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
222
+ if (options.as) builder.as(options.as);
223
+ if (options.single) builder.single(options.single);
224
+ if (options.pipeline) builder.pipeline(options.pipeline);
225
+ if (options.let) builder.let(options.let);
226
+ this.pipeline.push(...builder.build());
227
+ return this;
228
+ }
229
+ /**
230
+ * Multiple lookups at once
231
+ *
232
+ * @example
233
+ * ```typescript
234
+ * .multiLookup([
235
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
236
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
237
+ * ])
238
+ * ```
239
+ */
240
+ multiLookup(lookups) {
241
+ const stages = LookupBuilder.multiple(lookups);
242
+ this.pipeline.push(...stages);
243
+ return this;
244
+ }
245
+ /**
246
+ * $facet - Process multiple aggregation pipelines in a single stage
247
+ * Useful for computing multiple aggregations in parallel
248
+ *
249
+ * @example
250
+ * ```typescript
251
+ * .facet({
252
+ * totalCount: [{ $count: 'count' }],
253
+ * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
254
+ * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
255
+ * })
256
+ * ```
257
+ */
258
+ facet(facets) {
259
+ this.pipeline.push({ $facet: facets });
260
+ return this;
261
+ }
262
+ /**
263
+ * $bucket - Categorize documents into buckets
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * .bucket({
268
+ * groupBy: '$price',
269
+ * boundaries: [0, 50, 100, 200],
270
+ * default: 'Other',
271
+ * output: {
272
+ * count: { $sum: 1 },
273
+ * products: { $push: '$name' }
274
+ * }
275
+ * })
276
+ * ```
277
+ */
278
+ bucket(options) {
279
+ this.pipeline.push({ $bucket: options });
280
+ return this;
281
+ }
282
+ /**
283
+ * $bucketAuto - Automatically determine bucket boundaries
284
+ */
285
+ bucketAuto(options) {
286
+ this.pipeline.push({ $bucketAuto: options });
287
+ return this;
288
+ }
289
+ /**
290
+ * $setWindowFields - Perform window functions (MongoDB 5.0+)
291
+ * Useful for rankings, running totals, moving averages
292
+ *
293
+ * @example
294
+ * ```typescript
295
+ * .setWindowFields({
296
+ * partitionBy: '$department',
297
+ * sortBy: { salary: -1 },
298
+ * output: {
299
+ * rank: { $rank: {} },
300
+ * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
301
+ * }
302
+ * })
303
+ * ```
304
+ */
305
+ setWindowFields(options) {
306
+ const normalizedOptions = {
307
+ ...options,
308
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
309
+ };
310
+ this.pipeline.push({ $setWindowFields: normalizedOptions });
311
+ return this;
312
+ }
313
+ /**
314
+ * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
315
+ *
316
+ * @example
317
+ * ```typescript
318
+ * .unionWith({
319
+ * coll: 'archivedOrders',
320
+ * pipeline: [{ $match: { year: 2024 } }]
321
+ * })
322
+ * ```
323
+ */
324
+ unionWith(options) {
325
+ this.pipeline.push({ $unionWith: options });
326
+ return this;
327
+ }
328
+ /**
329
+ * $densify - Fill gaps in data (MongoDB 5.1+)
330
+ * Useful for time series data with missing points
331
+ */
332
+ densify(options) {
333
+ this.pipeline.push({ $densify: options });
334
+ return this;
335
+ }
336
+ /**
337
+ * $fill - Fill null or missing field values (MongoDB 5.3+)
338
+ */
339
+ fill(options) {
340
+ const normalizedOptions = {
341
+ ...options,
342
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
343
+ };
344
+ this.pipeline.push({ $fill: normalizedOptions });
345
+ return this;
346
+ }
347
+ /**
348
+ * Enable allowDiskUse for large aggregations that exceed 100MB memory limit
349
+ *
350
+ * @example
351
+ * ```typescript
352
+ * const results = await new AggregationBuilder()
353
+ * .match({ status: 'active' })
354
+ * .group({ _id: '$category', total: { $sum: '$amount' } })
355
+ * .allowDiskUse()
356
+ * .exec(Model);
357
+ * ```
358
+ */
359
+ allowDiskUse(enable = true) {
360
+ this._diskUse = enable;
361
+ return this;
362
+ }
363
+ /**
364
+ * Paginate - Add skip and limit for offset-based pagination
365
+ */
366
+ paginate(page, limit) {
367
+ const skip = (page - 1) * limit;
368
+ return this.skip(skip).limit(limit);
369
+ }
370
+ /**
371
+ * Count total documents (useful with $facet for pagination metadata)
372
+ */
373
+ count(outputField = "count") {
374
+ this.pipeline.push({ $count: outputField });
375
+ return this;
376
+ }
377
+ /**
378
+ * Sample - Randomly select N documents
379
+ */
380
+ sample(size) {
381
+ this.pipeline.push({ $sample: { size } });
382
+ return this;
383
+ }
384
+ /**
385
+ * Out - Write results to a collection
386
+ */
387
+ out(collection) {
388
+ this.pipeline.push({ $out: collection });
389
+ return this;
390
+ }
391
+ /**
392
+ * Merge - Merge results into a collection
393
+ */
394
+ merge(options) {
395
+ this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
396
+ return this;
397
+ }
398
+ /**
399
+ * GeoNear - Perform geospatial queries
400
+ */
401
+ geoNear(options) {
402
+ this.pipeline.push({ $geoNear: options });
403
+ return this;
404
+ }
405
+ /**
406
+ * GraphLookup - Perform recursive search (graph traversal)
407
+ */
408
+ graphLookup(options) {
409
+ this.pipeline.push({ $graphLookup: options });
410
+ return this;
411
+ }
412
+ /**
413
+ * $search - Atlas Search full-text search (Atlas only)
414
+ *
415
+ * @example
416
+ * ```typescript
417
+ * .search({
418
+ * index: 'default',
419
+ * text: {
420
+ * query: 'laptop computer',
421
+ * path: ['title', 'description'],
422
+ * fuzzy: { maxEdits: 2 }
423
+ * }
424
+ * })
425
+ * ```
426
+ */
427
+ search(options) {
428
+ this.pipeline.push({ $search: options });
429
+ return this;
430
+ }
431
+ /**
432
+ * $searchMeta - Get Atlas Search metadata (Atlas only)
433
+ */
434
+ searchMeta(options) {
435
+ this.pipeline.push({ $searchMeta: options });
436
+ return this;
437
+ }
438
+ /**
439
+ * $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
440
+ *
441
+ * Requires an Atlas Vector Search index on the target field.
442
+ * Must be the first stage in the pipeline.
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * const results = await new AggregationBuilder()
447
+ * .vectorSearch({
448
+ * index: 'vector_index',
449
+ * path: 'embedding',
450
+ * queryVector: await getEmbedding('running shoes'),
451
+ * limit: 10,
452
+ * numCandidates: 100,
453
+ * filter: { category: 'footwear' }
454
+ * })
455
+ * .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
456
+ * .exec(ProductModel);
457
+ * ```
458
+ */
459
+ vectorSearch(options) {
460
+ if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
461
+ const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
462
+ const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
463
+ this.pipeline.push({ $vectorSearch: {
464
+ index: options.index,
465
+ path: options.path,
466
+ queryVector: options.queryVector,
467
+ numCandidates,
468
+ limit: options.limit,
469
+ ...options.filter && { filter: options.filter },
470
+ ...options.exact && { exact: options.exact }
471
+ } });
472
+ return this;
473
+ }
474
+ /**
475
+ * Add vectorSearchScore as a field after $vectorSearch
476
+ * Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
477
+ */
478
+ withVectorScore(fieldName = "score") {
479
+ return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
480
+ }
481
+ /**
482
+ * Create a builder from an existing pipeline
483
+ */
484
+ static from(pipeline) {
485
+ const builder = new AggregationBuilder();
486
+ builder.pipeline = [...pipeline];
487
+ return builder;
488
+ }
489
+ /**
490
+ * Create a builder with initial match stage
491
+ */
492
+ static startWith(query) {
493
+ return new AggregationBuilder().match(query);
494
+ }
495
+ };
496
+
497
+ //#endregion
498
+ //#region src/Repository.ts
499
+ /**
500
+ * Repository Pattern - Data Access Layer
501
+ *
502
+ * Event-driven, plugin-based abstraction for MongoDB operations
503
+ * Inspired by Meta & Stripe's repository patterns
504
+ *
505
+ * @example
506
+ * ```typescript
507
+ * const userRepo = new Repository(UserModel, [
508
+ * timestampPlugin(),
509
+ * softDeletePlugin(),
510
+ * ]);
511
+ *
512
+ * // Create
513
+ * const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
514
+ *
515
+ * // Read with pagination
516
+ * const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
517
+ *
518
+ * // Update
519
+ * const updated = await userRepo.update(user._id, { name: 'John Doe' });
520
+ *
521
+ * // Delete
522
+ * await userRepo.delete(user._id);
523
+ * ```
524
+ */
525
+ /**
526
+ * Production-grade repository for MongoDB
527
+ * Event-driven, plugin-based, with smart pagination
528
+ */
529
+ var Repository = class {
530
+ Model;
531
+ model;
532
+ _hooks;
533
+ _pagination;
534
+ _hookMode;
535
+ _hasTextIndex = null;
536
+ constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
537
+ this.Model = Model;
538
+ this.model = Model.modelName;
539
+ this._hooks = /* @__PURE__ */ new Map();
540
+ this._pagination = new PaginationEngine(Model, paginationConfig);
541
+ this._hookMode = options.hooks ?? "async";
542
+ plugins.forEach((plugin) => this.use(plugin));
543
+ }
544
+ /**
545
+ * Register a plugin
546
+ */
547
+ use(plugin) {
548
+ if (typeof plugin === "function") plugin(this);
549
+ else if (plugin && typeof plugin.apply === "function") plugin.apply(this);
550
+ return this;
551
+ }
552
+ /**
553
+ * Register event listener
554
+ */
555
+ on(event, listener) {
556
+ if (!this._hooks.has(event)) this._hooks.set(event, []);
557
+ this._hooks.get(event).push(listener);
558
+ return this;
559
+ }
560
+ /**
561
+ * Remove a specific event listener
562
+ */
563
+ off(event, listener) {
564
+ const listeners = this._hooks.get(event);
565
+ if (listeners) {
566
+ const idx = listeners.indexOf(listener);
567
+ if (idx !== -1) listeners.splice(idx, 1);
568
+ }
569
+ return this;
570
+ }
571
+ /**
572
+ * Remove all listeners for an event, or all listeners entirely
573
+ */
574
+ removeAllListeners(event) {
575
+ if (event) this._hooks.delete(event);
576
+ else this._hooks.clear();
577
+ return this;
578
+ }
579
+ /**
580
+ * Emit event (sync - for backwards compatibility)
581
+ */
582
+ emit(event, data) {
583
+ const listeners = this._hooks.get(event) || [];
584
+ for (const listener of listeners) try {
585
+ const result = listener(data);
586
+ if (result && typeof result.then === "function") result.catch((error) => {
587
+ if (event === "error:hook") return;
588
+ const err = error instanceof Error ? error : new Error(String(error));
589
+ this.emit("error:hook", {
590
+ event,
591
+ error: err
592
+ });
593
+ });
594
+ } catch (error) {
595
+ if (event === "error:hook") continue;
596
+ const err = error instanceof Error ? error : new Error(String(error));
597
+ this.emit("error:hook", {
598
+ event,
599
+ error: err
600
+ });
601
+ }
602
+ }
603
+ /**
604
+ * Emit event and await all async handlers
605
+ */
606
+ async emitAsync(event, data) {
607
+ const listeners = this._hooks.get(event) || [];
608
+ for (const listener of listeners) await listener(data);
609
+ }
610
+ async _emitHook(event, data) {
611
+ if (this._hookMode === "async") {
612
+ await this.emitAsync(event, data);
613
+ return;
614
+ }
615
+ this.emit(event, data);
616
+ }
617
+ async _emitErrorHook(event, data) {
618
+ try {
619
+ await this._emitHook(event, data);
620
+ } catch (hookError) {
621
+ warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
622
+ }
623
+ }
624
+ /**
625
+ * Create single document
626
+ */
627
+ async create(data, options = {}) {
628
+ const context = await this._buildContext("create", {
629
+ data,
630
+ ...options
631
+ });
632
+ try {
633
+ const result = await create(this.Model, context.data || data, options);
634
+ await this._emitHook("after:create", {
635
+ context,
636
+ result
637
+ });
638
+ return result;
639
+ } catch (error) {
640
+ await this._emitErrorHook("error:create", {
641
+ context,
642
+ error
643
+ });
644
+ throw this._handleError(error);
645
+ }
646
+ }
647
+ /**
648
+ * Create multiple documents
649
+ */
650
+ async createMany(dataArray, options = {}) {
651
+ const context = await this._buildContext("createMany", {
652
+ dataArray,
653
+ ...options
654
+ });
655
+ try {
656
+ const result = await createMany(this.Model, context.dataArray || dataArray, options);
657
+ await this._emitHook("after:createMany", {
658
+ context,
659
+ result
660
+ });
661
+ return result;
662
+ } catch (error) {
663
+ await this._emitErrorHook("error:createMany", {
664
+ context,
665
+ error
666
+ });
667
+ throw this._handleError(error);
668
+ }
669
+ }
670
+ /**
671
+ * Get document by ID
672
+ */
673
+ async getById(id, options = {}) {
674
+ const populateSpec = options.populateOptions || options.populate;
675
+ const context = await this._buildContext("getById", {
676
+ id,
677
+ ...options,
678
+ populate: populateSpec
679
+ });
680
+ if (context._cacheHit) return context._cachedResult;
681
+ try {
682
+ const result = await getById(this.Model, id, context);
683
+ await this._emitHook("after:getById", {
684
+ context,
685
+ result
686
+ });
687
+ return result;
688
+ } catch (error) {
689
+ await this._emitErrorHook("error:getById", {
690
+ context,
691
+ error
692
+ });
693
+ throw this._handleError(error);
694
+ }
695
+ }
696
+ /**
697
+ * Get single document by query
698
+ */
699
+ async getByQuery(query, options = {}) {
700
+ const populateSpec = options.populateOptions || options.populate;
701
+ const context = await this._buildContext("getByQuery", {
702
+ query,
703
+ ...options,
704
+ populate: populateSpec
705
+ });
706
+ if (context._cacheHit) return context._cachedResult;
707
+ const finalQuery = context.query || query;
708
+ try {
709
+ const result = await getByQuery(this.Model, finalQuery, context);
710
+ await this._emitHook("after:getByQuery", {
711
+ context,
712
+ result
713
+ });
714
+ return result;
715
+ } catch (error) {
716
+ await this._emitErrorHook("error:getByQuery", {
717
+ context,
718
+ error
719
+ });
720
+ throw this._handleError(error);
721
+ }
722
+ }
723
+ /**
724
+ * Unified pagination - auto-detects offset vs keyset based on params
725
+ *
726
+ * Auto-detection logic:
727
+ * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
728
+ * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
729
+ * - Else → defaults to offset pagination with page=1
730
+ *
731
+ * @example
732
+ * // Offset pagination (page-based)
733
+ * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
734
+ * await repo.getAll({ pagination: { page: 2, limit: 20 } });
735
+ *
736
+ * // Keyset pagination (cursor-based)
737
+ * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
738
+ * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
739
+ *
740
+ * // Simple query (defaults to page 1)
741
+ * await repo.getAll({ filters: { status: 'active' } });
742
+ *
743
+ * // Skip cache for fresh data
744
+ * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
745
+ */
746
+ async getAll(params = {}, options = {}) {
747
+ const context = await this._buildContext("getAll", {
748
+ ...params,
749
+ ...options
750
+ });
751
+ if (context._cacheHit) return context._cachedResult;
752
+ const filters = context.filters ?? params.filters ?? {};
753
+ const search = context.search ?? params.search;
754
+ const sort = context.sort ?? params.sort ?? "-createdAt";
755
+ const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
756
+ const page = context.page ?? params.pagination?.page ?? params.page;
757
+ const after = context.after ?? params.cursor ?? params.after;
758
+ const mode = context.mode ?? params.mode;
759
+ let useKeyset = false;
760
+ if (mode) useKeyset = mode === "keyset";
761
+ else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
762
+ let query = { ...filters };
763
+ if (search) {
764
+ if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
765
+ if (this._hasTextIndex) query.$text = { $search: search };
766
+ else throw createError(400, `No text index found for ${this.model}. Cannot perform text search.`);
767
+ }
768
+ const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
769
+ const paginationOptions = {
770
+ filters: query,
771
+ sort: this._parseSort(sort),
772
+ limit,
773
+ populate: this._parsePopulate(populateSpec),
774
+ select: context.select || options.select,
775
+ lean: context.lean ?? options.lean ?? true,
776
+ session: options.session,
777
+ hint: context.hint ?? params.hint,
778
+ maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
779
+ readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
780
+ };
781
+ try {
782
+ let result;
783
+ if (useKeyset) result = await this._pagination.stream({
784
+ ...paginationOptions,
785
+ sort: paginationOptions.sort,
786
+ after
787
+ });
788
+ else result = await this._pagination.paginate({
789
+ ...paginationOptions,
790
+ page: page || 1,
791
+ countStrategy: context.countStrategy ?? params.countStrategy
792
+ });
793
+ await this._emitHook("after:getAll", {
794
+ context,
795
+ result
796
+ });
797
+ return result;
798
+ } catch (error) {
799
+ await this._emitErrorHook("error:getAll", {
800
+ context,
801
+ error
802
+ });
803
+ throw this._handleError(error);
804
+ }
805
+ }
806
+ /**
807
+ * Get or create document
808
+ */
809
+ async getOrCreate(query, createData, options = {}) {
810
+ return getOrCreate(this.Model, query, createData, options);
811
+ }
812
+ /**
813
+ * Count documents
814
+ */
815
+ async count(query = {}, options = {}) {
816
+ return count(this.Model, query, options);
817
+ }
818
+ /**
819
+ * Check if document exists
820
+ */
821
+ async exists(query, options = {}) {
822
+ return exists(this.Model, query, options);
823
+ }
824
+ /**
825
+ * Update document by ID
826
+ */
827
+ async update(id, data, options = {}) {
828
+ const context = await this._buildContext("update", {
829
+ id,
830
+ data,
831
+ ...options
832
+ });
833
+ try {
834
+ const result = await update(this.Model, id, context.data || data, context);
835
+ await this._emitHook("after:update", {
836
+ context,
837
+ result
838
+ });
839
+ return result;
840
+ } catch (error) {
841
+ await this._emitErrorHook("error:update", {
842
+ context,
843
+ error
844
+ });
845
+ throw this._handleError(error);
846
+ }
847
+ }
848
+ /**
849
+ * Delete document by ID
850
+ */
851
+ async delete(id, options = {}) {
852
+ const context = await this._buildContext("delete", {
853
+ id,
854
+ ...options
855
+ });
856
+ try {
857
+ if (context.softDeleted) {
858
+ const result = {
859
+ success: true,
860
+ message: "Soft deleted successfully"
861
+ };
862
+ await this._emitHook("after:delete", {
863
+ context,
864
+ result
865
+ });
866
+ return result;
867
+ }
868
+ const result = await deleteById(this.Model, id, {
869
+ session: options.session,
870
+ query: context.query
871
+ });
872
+ await this._emitHook("after:delete", {
873
+ context,
874
+ result
875
+ });
876
+ return result;
877
+ } catch (error) {
878
+ await this._emitErrorHook("error:delete", {
879
+ context,
880
+ error
881
+ });
882
+ throw this._handleError(error);
883
+ }
884
+ }
885
+ /**
886
+ * Execute aggregation pipeline
887
+ */
888
+ async aggregate(pipeline, options = {}) {
889
+ return aggregate(this.Model, pipeline, options);
890
+ }
891
+ /**
892
+ * Aggregate pipeline with pagination
893
+ * Best for: Complex queries, grouping, joins
894
+ */
895
+ async aggregatePaginate(options = {}) {
896
+ const context = await this._buildContext("aggregatePaginate", options);
897
+ return this._pagination.aggregatePaginate(context);
898
+ }
899
+ /**
900
+ * Get distinct values
901
+ */
902
+ async distinct(field, query = {}, options = {}) {
903
+ return distinct(this.Model, field, query, options);
904
+ }
905
+ /**
906
+ * Query with custom field lookups ($lookup)
907
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
908
+ *
909
+ * @example
910
+ * ```typescript
911
+ * // Join employees with departments using slug instead of ObjectId
912
+ * const employees = await employeeRepo.lookupPopulate({
913
+ * filters: { status: 'active' },
914
+ * lookups: [
915
+ * {
916
+ * from: 'departments',
917
+ * localField: 'departmentSlug',
918
+ * foreignField: 'slug',
919
+ * as: 'department',
920
+ * single: true
921
+ * }
922
+ * ],
923
+ * sort: '-createdAt',
924
+ * page: 1,
925
+ * limit: 50
926
+ * });
927
+ * ```
928
+ */
929
+ async lookupPopulate(options) {
930
+ const context = await this._buildContext("lookupPopulate", options);
931
+ try {
932
+ const builder = new AggregationBuilder();
933
+ const filters = context.filters ?? options.filters;
934
+ if (filters && Object.keys(filters).length > 0) builder.match(filters);
935
+ builder.multiLookup(options.lookups);
936
+ if (options.sort) builder.sort(this._parseSort(options.sort));
937
+ const page = options.page || 1;
938
+ const limit = options.limit || this._pagination.config.defaultLimit || 20;
939
+ const skip = (page - 1) * limit;
940
+ const SAFE_LIMIT = 1e3;
941
+ const SAFE_MAX_OFFSET = 1e4;
942
+ if (limit > SAFE_LIMIT) warn(`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`);
943
+ if (skip > SAFE_MAX_OFFSET) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`);
944
+ const dataStages = [{ $skip: skip }, { $limit: limit }];
945
+ if (options.select) {
946
+ let projection;
947
+ if (typeof options.select === "string") {
948
+ projection = {};
949
+ const fields = options.select.split(",").map((f) => f.trim());
950
+ for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
951
+ else projection[field] = 1;
952
+ } else if (Array.isArray(options.select)) {
953
+ projection = {};
954
+ for (const field of options.select) if (field.startsWith("-")) projection[field.substring(1)] = 0;
955
+ else projection[field] = 1;
956
+ } else projection = options.select;
957
+ dataStages.push({ $project: projection });
958
+ }
959
+ builder.facet({
960
+ metadata: [{ $count: "total" }],
961
+ data: dataStages
962
+ });
963
+ const pipeline = builder.build();
964
+ const result = (await this.Model.aggregate(pipeline).session(options.session || null))[0] || {
965
+ metadata: [],
966
+ data: []
967
+ };
968
+ const total = result.metadata[0]?.total || 0;
969
+ const data = result.data || [];
970
+ await this._emitHook("after:lookupPopulate", {
971
+ context,
972
+ result: data
973
+ });
974
+ return {
975
+ data,
976
+ total,
977
+ page,
978
+ limit
979
+ };
980
+ } catch (error) {
981
+ await this._emitErrorHook("error:lookupPopulate", {
982
+ context,
983
+ error
984
+ });
985
+ throw this._handleError(error);
986
+ }
987
+ }
988
+ /**
989
+ * Create an aggregation builder for this model
990
+ * Useful for building complex custom aggregations
991
+ *
992
+ * @example
993
+ * ```typescript
994
+ * const pipeline = repo.buildAggregation()
995
+ * .match({ status: 'active' })
996
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
997
+ * .group({ _id: '$department', count: { $sum: 1 } })
998
+ * .sort({ count: -1 })
999
+ * .build();
1000
+ *
1001
+ * const results = await repo.Model.aggregate(pipeline);
1002
+ * ```
1003
+ */
1004
+ buildAggregation() {
1005
+ return new AggregationBuilder();
1006
+ }
1007
+ /**
1008
+ * Create a lookup builder
1009
+ * Useful for building $lookup stages independently
1010
+ *
1011
+ * @example
1012
+ * ```typescript
1013
+ * const lookupStages = repo.buildLookup('departments')
1014
+ * .localField('deptSlug')
1015
+ * .foreignField('slug')
1016
+ * .as('department')
1017
+ * .single()
1018
+ * .build();
1019
+ *
1020
+ * const pipeline = [
1021
+ * { $match: { status: 'active' } },
1022
+ * ...lookupStages
1023
+ * ];
1024
+ * ```
1025
+ */
1026
+ buildLookup(from) {
1027
+ return new LookupBuilder(from);
1028
+ }
1029
+ /**
1030
+ * Execute callback within a transaction with automatic retry on transient failures.
1031
+ *
1032
+ * Uses the MongoDB driver's `session.withTransaction()` which automatically retries
1033
+ * on `TransientTransactionError` and `UnknownTransactionCommitResult`.
1034
+ *
1035
+ * The callback always receives a `ClientSession`. When `allowFallback` is true
1036
+ * and the MongoDB deployment doesn't support transactions (e.g., standalone),
1037
+ * the callback runs without a transaction on the same session.
1038
+ *
1039
+ * @param callback - Receives a `ClientSession` to pass to repository operations
1040
+ * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
1041
+ * @param options.onFallback - Called when falling back to non-transactional execution
1042
+ * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
1043
+ *
1044
+ * @example
1045
+ * ```typescript
1046
+ * const result = await repo.withTransaction(async (session) => {
1047
+ * const order = await repo.create({ total: 100 }, { session });
1048
+ * await paymentRepo.create({ orderId: order._id }, { session });
1049
+ * return order;
1050
+ * });
1051
+ *
1052
+ * // With fallback for standalone/dev environments
1053
+ * await repo.withTransaction(callback, {
1054
+ * allowFallback: true,
1055
+ * onFallback: (err) => logger.warn('Running without transaction', err),
1056
+ * });
1057
+ * ```
1058
+ */
1059
+ async withTransaction(callback, options = {}) {
1060
+ const session = await mongoose.startSession();
1061
+ try {
1062
+ return await session.withTransaction(() => callback(session), options.transactionOptions);
1063
+ } catch (error) {
1064
+ const err = error;
1065
+ if (options.allowFallback && this._isTransactionUnsupported(err)) {
1066
+ options.onFallback?.(err);
1067
+ return await callback(session);
1068
+ }
1069
+ throw err;
1070
+ } finally {
1071
+ await session.endSession();
1072
+ }
1073
+ }
1074
+ _isTransactionUnsupported(error) {
1075
+ const message = (error.message || "").toLowerCase();
1076
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
1077
+ }
1078
+ /**
1079
+ * Execute custom query with event emission
1080
+ */
1081
+ async _executeQuery(buildQuery) {
1082
+ const operation = buildQuery.name || "custom";
1083
+ const context = await this._buildContext(operation, {});
1084
+ try {
1085
+ const result = await buildQuery(this.Model);
1086
+ await this._emitHook(`after:${operation}`, {
1087
+ context,
1088
+ result
1089
+ });
1090
+ return result;
1091
+ } catch (error) {
1092
+ await this._emitErrorHook(`error:${operation}`, {
1093
+ context,
1094
+ error
1095
+ });
1096
+ throw this._handleError(error);
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Build operation context and run before hooks
1101
+ */
1102
+ async _buildContext(operation, options) {
1103
+ const context = {
1104
+ operation,
1105
+ model: this.model,
1106
+ ...options
1107
+ };
1108
+ const event = `before:${operation}`;
1109
+ const hooks = this._hooks.get(event) || [];
1110
+ for (const hook of hooks) await hook(context);
1111
+ return context;
1112
+ }
1113
+ /**
1114
+ * Parse sort string or object
1115
+ */
1116
+ _parseSort(sort) {
1117
+ if (!sort) return { createdAt: -1 };
1118
+ if (typeof sort === "object") {
1119
+ if (Object.keys(sort).length === 0) return { createdAt: -1 };
1120
+ return sort;
1121
+ }
1122
+ const sortObj = {};
1123
+ const fields = sort.split(",").map((s) => s.trim());
1124
+ for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
1125
+ else sortObj[field] = 1;
1126
+ return sortObj;
1127
+ }
1128
+ /**
1129
+ * Parse populate specification
1130
+ */
1131
+ _parsePopulate(populate) {
1132
+ if (!populate) return [];
1133
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
1134
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
1135
+ return [populate];
1136
+ }
1137
+ /**
1138
+ * Handle errors with proper HTTP status codes
1139
+ */
1140
+ _handleError(error) {
1141
+ if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
1142
+ if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
1143
+ if (error.status && error.message) return error;
1144
+ return createError(500, error.message || "Internal Server Error");
1145
+ }
1146
+ };
1147
+
1148
+ //#endregion
1149
+ //#region src/query/QueryParser.ts
1150
+ /**
1151
+ * Modern Query Parser - URL to MongoDB Query Transpiler
1152
+ *
1153
+ * Next-generation query parser that converts URL parameters to MongoDB aggregation pipelines.
1154
+ * Smarter than Prisma/tRPC for MongoDB with support for:
1155
+ * - Custom field lookups ($lookup)
1156
+ * - Complex filtering with operators
1157
+ * - Full-text search
1158
+ * - Aggregations via URL
1159
+ * - Security hardening
1160
+ *
1161
+ * @example
1162
+ * ```typescript
1163
+ * // Simple usage
1164
+ * const parser = new QueryParser();
1165
+ * const query = parser.parse(req.query);
1166
+ *
1167
+ * // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
1168
+ * // Result: Complete MongoDB query with $lookup, filters, sort, pagination
1169
+ * ```
1170
+ *
1171
+ * ## SECURITY CONSIDERATIONS FOR PRODUCTION
1172
+ *
1173
+ * ### Aggregation Security (enableAggregations option)
1174
+ *
1175
+ * **IMPORTANT:** The `enableAggregations` option exposes powerful MongoDB aggregation
1176
+ * pipeline capabilities via URL parameters. While this feature includes sanitization
1177
+ * (blocks $where, $function, $accumulator), it should be used with caution:
1178
+ *
1179
+ * **Recommended security practices:**
1180
+ * 1. **Disable by default for public endpoints:**
1181
+ * ```typescript
1182
+ * const parser = new QueryParser({
1183
+ * enableAggregations: false // Default: disabled
1184
+ * });
1185
+ * ```
1186
+ *
1187
+ * 2. **Use per-route allowlists for trusted clients:**
1188
+ * ```typescript
1189
+ * // Admin/internal routes only
1190
+ * if (req.user?.role === 'admin') {
1191
+ * const allowedStages = ['$match', '$project', '$sort', '$limit'];
1192
+ * // Validate aggregate parameter against allowlist
1193
+ * }
1194
+ * ```
1195
+ *
1196
+ * 3. **Validate stage structure:** Even with sanitization, complex pipelines can
1197
+ * cause performance issues. Consider limiting:
1198
+ * - Number of pipeline stages (e.g., max 5)
1199
+ * - Specific allowed operators per stage
1200
+ * - Allowed fields in $project/$match
1201
+ *
1202
+ * 4. **Monitor resource usage:** Aggregation pipelines can be expensive.
1203
+ * Use MongoDB profiling to track slow operations.
1204
+ *
1205
+ * ### Lookup Security
1206
+ *
1207
+ * Lookup pipelines are sanitized by default:
1208
+ * - Dangerous stages blocked ($out, $merge, $unionWith, $collStats, $currentOp, $listSessions)
1209
+ * - Dangerous operators blocked inside $match/$addFields/$set ($where, $function, $accumulator, $expr)
1210
+ * - Optional collection whitelist via `allowedLookupCollections`
1211
+ * For maximum security, use per-collection field allowlists in your controller layer.
1212
+ *
1213
+ * ### Filter Security
1214
+ *
1215
+ * All filters are sanitized:
1216
+ * - Dangerous operators blocked ($where, $function, $accumulator, $expr)
1217
+ * - Regex patterns validated (ReDoS protection)
1218
+ * - Max filter depth enforced (prevents filter bombs)
1219
+ * - Max limit enforced (prevents resource exhaustion)
1220
+ *
1221
+ * @see {@link https://github.com/classytic/mongokit/blob/main/docs/SECURITY.md}
1222
+ */
1223
+ /**
1224
+ * Modern Query Parser
1225
+ * Converts URL parameters to MongoDB queries with $lookup support
1226
+ */
1227
+ var QueryParser = class {
1228
+ options;
1229
+ operators = {
1230
+ eq: "$eq",
1231
+ ne: "$ne",
1232
+ gt: "$gt",
1233
+ gte: "$gte",
1234
+ lt: "$lt",
1235
+ lte: "$lte",
1236
+ in: "$in",
1237
+ nin: "$nin",
1238
+ like: "$regex",
1239
+ contains: "$regex",
1240
+ regex: "$regex",
1241
+ exists: "$exists",
1242
+ size: "$size",
1243
+ type: "$type"
1244
+ };
1245
+ dangerousOperators;
1246
+ /**
1247
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
1248
+ * Detects:
1249
+ * - Quantifiers: {n,m}
1250
+ * - Possessive quantifiers: *+, ++, ?+
1251
+ * - Nested quantifiers: (a+)+, (a*)*
1252
+ * - Backreferences: \1, \2, etc.
1253
+ * - Complex character classes: [...]...[...]
1254
+ */
1255
+ dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
1256
+ constructor(options = {}) {
1257
+ this.options = {
1258
+ maxRegexLength: options.maxRegexLength ?? 500,
1259
+ maxSearchLength: options.maxSearchLength ?? 200,
1260
+ maxFilterDepth: options.maxFilterDepth ?? 10,
1261
+ maxLimit: options.maxLimit ?? 1e3,
1262
+ additionalDangerousOperators: options.additionalDangerousOperators ?? [],
1263
+ enableLookups: options.enableLookups ?? true,
1264
+ enableAggregations: options.enableAggregations ?? false,
1265
+ searchMode: options.searchMode ?? "text",
1266
+ searchFields: options.searchFields,
1267
+ allowedLookupCollections: options.allowedLookupCollections,
1268
+ allowedFilterFields: options.allowedFilterFields,
1269
+ allowedSortFields: options.allowedSortFields
1270
+ };
1271
+ if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1272
+ warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
1273
+ this.options.searchMode = "text";
1274
+ }
1275
+ this.dangerousOperators = [
1276
+ "$where",
1277
+ "$function",
1278
+ "$accumulator",
1279
+ "$expr",
1280
+ ...this.options.additionalDangerousOperators
1281
+ ];
1282
+ }
1283
+ /**
1284
+ * Parse URL query parameters into MongoDB query format
1285
+ *
1286
+ * @example
1287
+ * ```typescript
1288
+ * // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
1289
+ * const query = parser.parse(req.query);
1290
+ * // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
1291
+ * ```
1292
+ */
1293
+ parse(query) {
1294
+ const { page, limit = 20, sort = "-createdAt", populate, search, after, cursor, select, lookup, aggregate, ...filters } = query || {};
1295
+ let parsedLimit = parseInt(String(limit), 10);
1296
+ if (isNaN(parsedLimit) || parsedLimit < 1) parsedLimit = 20;
1297
+ if (parsedLimit > this.options.maxLimit) {
1298
+ warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1299
+ parsedLimit = this.options.maxLimit;
1300
+ }
1301
+ const sanitizedSearch = this._sanitizeSearch(search);
1302
+ const { simplePopulate, populateOptions } = this._parsePopulate(populate);
1303
+ const parsed = {
1304
+ filters: this._parseFilters(filters),
1305
+ limit: parsedLimit,
1306
+ sort: this._parseSort(sort),
1307
+ populate: simplePopulate,
1308
+ populateOptions,
1309
+ search: sanitizedSearch
1310
+ };
1311
+ if (sanitizedSearch && this.options.searchMode === "regex" && this.options.searchFields) {
1312
+ const regexSearchFilters = this._buildRegexSearch(sanitizedSearch);
1313
+ if (regexSearchFilters) {
1314
+ if (parsed.filters.$or) {
1315
+ parsed.filters = {
1316
+ ...parsed.filters,
1317
+ $and: [{ $or: parsed.filters.$or }, { $or: regexSearchFilters }]
1318
+ };
1319
+ delete parsed.filters.$or;
1320
+ } else parsed.filters.$or = regexSearchFilters;
1321
+ parsed.search = void 0;
1322
+ }
1323
+ }
1324
+ if (select) parsed.select = this._parseSelect(select);
1325
+ if (this.options.enableLookups && lookup) parsed.lookups = this._parseLookups(lookup);
1326
+ if (this.options.enableAggregations && aggregate) parsed.aggregation = this._parseAggregation(aggregate);
1327
+ if (after || cursor) parsed.after = String(after || cursor);
1328
+ if (page !== void 0) parsed.page = parseInt(String(page), 10);
1329
+ const orGroup = this._parseOr(query);
1330
+ if (orGroup) if (parsed.filters.$or) {
1331
+ const existingOr = parsed.filters.$or;
1332
+ delete parsed.filters.$or;
1333
+ parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
1334
+ } else parsed.filters.$or = orGroup;
1335
+ parsed.filters = this._enhanceWithBetween(parsed.filters);
1336
+ return parsed;
1337
+ }
1338
+ /**
1339
+ * Parse lookup configurations from URL parameters
1340
+ *
1341
+ * Supported formats:
1342
+ * 1. Simple: ?lookup[department]=slug
1343
+ * → Join with 'departments' collection on slug field
1344
+ *
1345
+ * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
1346
+ * → Full control over join configuration
1347
+ *
1348
+ * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
1349
+ * → Multiple lookups
1350
+ *
1351
+ * @example
1352
+ * ```typescript
1353
+ * // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
1354
+ * const lookups = parser._parseLookups({
1355
+ * department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
1356
+ * });
1357
+ * // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
1358
+ * ```
1359
+ */
1360
+ _parseLookups(lookup) {
1361
+ if (!lookup || typeof lookup !== "object") return [];
1362
+ const lookups = [];
1363
+ const lookupObj = lookup;
1364
+ for (const [collectionName, config] of Object.entries(lookupObj)) try {
1365
+ const lookupConfig = this._parseSingleLookup(collectionName, config);
1366
+ if (lookupConfig) lookups.push(lookupConfig);
1367
+ } catch (error) {
1368
+ warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
1369
+ }
1370
+ return lookups;
1371
+ }
1372
+ /**
1373
+ * Parse a single lookup configuration
1374
+ */
1375
+ _parseSingleLookup(collectionName, config) {
1376
+ if (!config) return null;
1377
+ if (typeof config === "string") {
1378
+ const from = this._pluralize(collectionName);
1379
+ if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
1380
+ warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
1381
+ return null;
1382
+ }
1383
+ return {
1384
+ from,
1385
+ localField: `${collectionName}${this._capitalize(config)}`,
1386
+ foreignField: config,
1387
+ as: collectionName,
1388
+ single: true
1389
+ };
1390
+ }
1391
+ if (typeof config === "object" && config !== null) {
1392
+ const opts = config;
1393
+ const from = opts.from || this._pluralize(collectionName);
1394
+ const localField = opts.localField;
1395
+ const foreignField = opts.foreignField;
1396
+ if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
1397
+ warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
1398
+ return null;
1399
+ }
1400
+ if (!localField || !foreignField) {
1401
+ warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1402
+ return null;
1403
+ }
1404
+ return {
1405
+ from,
1406
+ localField,
1407
+ foreignField,
1408
+ as: opts.as || collectionName,
1409
+ single: opts.single === true || opts.single === "true",
1410
+ ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
1411
+ };
1412
+ }
1413
+ return null;
1414
+ }
1415
+ /**
1416
+ * Parse aggregation pipeline from URL (advanced feature)
1417
+ *
1418
+ * @example
1419
+ * ```typescript
1420
+ * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
1421
+ * const pipeline = parser._parseAggregation({
1422
+ * group: { _id: '$status', count: '$sum:1' }
1423
+ * });
1424
+ * ```
1425
+ */
1426
+ _parseAggregation(aggregate) {
1427
+ if (!aggregate || typeof aggregate !== "object") return void 0;
1428
+ const pipeline = [];
1429
+ const aggObj = aggregate;
1430
+ for (const [stage, config] of Object.entries(aggObj)) try {
1431
+ if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
1432
+ else if (stage === "match" && typeof config === "object") {
1433
+ const sanitizedMatch = this._sanitizeMatchConfig(config);
1434
+ if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
1435
+ } else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
1436
+ else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
1437
+ } catch (error) {
1438
+ warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1439
+ }
1440
+ return pipeline.length > 0 ? pipeline : void 0;
1441
+ }
1442
+ /**
1443
+ * Parse select/project fields
1444
+ *
1445
+ * @example
1446
+ * ```typescript
1447
+ * // URL: ?select=name,email,-password
1448
+ * // Returns: { name: 1, email: 1, password: 0 }
1449
+ * ```
1450
+ */
1451
+ _parseSelect(select) {
1452
+ if (!select) return void 0;
1453
+ if (typeof select === "string") {
1454
+ const projection = {};
1455
+ const fields = select.split(",").map((f) => f.trim());
1456
+ for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1457
+ else projection[field] = 1;
1458
+ return projection;
1459
+ }
1460
+ if (typeof select === "object" && select !== null) return select;
1461
+ }
1462
+ /**
1463
+ * Parse populate parameter - handles both simple string and advanced object format
1464
+ *
1465
+ * @example
1466
+ * ```typescript
1467
+ * // Simple: ?populate=author,category
1468
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1469
+ *
1470
+ * // Advanced: ?populate[author][select]=name,email
1471
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1472
+ * ```
1473
+ */
1474
+ _parsePopulate(populate) {
1475
+ if (!populate) return {};
1476
+ if (typeof populate === "string") return { simplePopulate: populate };
1477
+ if (typeof populate === "object" && populate !== null) {
1478
+ const populateObj = populate;
1479
+ if (Object.keys(populateObj).length === 0) return {};
1480
+ const populateOptions = [];
1481
+ for (const [path, config] of Object.entries(populateObj)) {
1482
+ if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
1483
+ warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1484
+ continue;
1485
+ }
1486
+ const option = this._parseSinglePopulate(path, config);
1487
+ if (option) populateOptions.push(option);
1488
+ }
1489
+ return populateOptions.length > 0 ? { populateOptions } : {};
1490
+ }
1491
+ return {};
1492
+ }
1493
+ /**
1494
+ * Parse a single populate configuration
1495
+ */
1496
+ _parseSinglePopulate(path, config, depth = 0) {
1497
+ if (depth > 5) {
1498
+ warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1499
+ return { path };
1500
+ }
1501
+ if (typeof config === "string") {
1502
+ if (config === "true" || config === "1") return { path };
1503
+ return {
1504
+ path,
1505
+ select: config.split(",").join(" ")
1506
+ };
1507
+ }
1508
+ if (typeof config === "object" && config !== null) {
1509
+ const opts = config;
1510
+ const option = { path };
1511
+ if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
1512
+ if (opts.match && typeof opts.match === "object") option.match = this._convertPopulateMatch(opts.match);
1513
+ if (opts.limit !== void 0) {
1514
+ const limit = parseInt(String(opts.limit), 10);
1515
+ if (!isNaN(limit) && limit > 0) {
1516
+ option.options = option.options || {};
1517
+ option.options.limit = limit;
1518
+ }
1519
+ }
1520
+ if (opts.sort && typeof opts.sort === "string") {
1521
+ const sortSpec = this._parseSort(opts.sort);
1522
+ if (sortSpec) {
1523
+ option.options = option.options || {};
1524
+ option.options.sort = sortSpec;
1525
+ }
1526
+ }
1527
+ if (opts.skip !== void 0) {
1528
+ const skip = parseInt(String(opts.skip), 10);
1529
+ if (!isNaN(skip) && skip >= 0) {
1530
+ option.options = option.options || {};
1531
+ option.options.skip = skip;
1532
+ }
1533
+ }
1534
+ if (opts.populate && typeof opts.populate === "object") {
1535
+ const nestedPopulate = opts.populate;
1536
+ const nestedEntries = Object.entries(nestedPopulate);
1537
+ if (nestedEntries.length > 0) {
1538
+ const [nestedPath, nestedConfig] = nestedEntries[0];
1539
+ const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
1540
+ if (nestedOption) option.populate = nestedOption;
1541
+ }
1542
+ }
1543
+ return option;
1544
+ }
1545
+ return null;
1546
+ }
1547
+ /**
1548
+ * Convert populate match values (handles boolean strings, etc.)
1549
+ */
1550
+ _convertPopulateMatch(match) {
1551
+ const converted = {};
1552
+ for (const [key, value] of Object.entries(match)) converted[key] = this._convertValue(value);
1553
+ return converted;
1554
+ }
1555
+ /**
1556
+ * Parse filter parameters
1557
+ */
1558
+ _parseFilters(filters, depth = 0) {
1559
+ if (depth > this.options.maxFilterDepth) {
1560
+ warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1561
+ return {};
1562
+ }
1563
+ const parsedFilters = {};
1564
+ const regexFields = {};
1565
+ for (const [key, value] of Object.entries(filters)) {
1566
+ if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
1567
+ warn(`[mongokit] Blocked dangerous operator: ${key}`);
1568
+ continue;
1569
+ }
1570
+ if ([
1571
+ "page",
1572
+ "limit",
1573
+ "sort",
1574
+ "populate",
1575
+ "search",
1576
+ "select",
1577
+ "lean",
1578
+ "includeDeleted",
1579
+ "lookup",
1580
+ "aggregate",
1581
+ "or",
1582
+ "OR",
1583
+ "$or"
1584
+ ].includes(key)) continue;
1585
+ const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
1586
+ const baseField = operatorMatch ? operatorMatch[1] : key;
1587
+ if (this.options.allowedFilterFields && !this.options.allowedFilterFields.includes(baseField)) {
1588
+ warn(`[mongokit] Blocked filter field not in allowlist: ${baseField}`);
1589
+ continue;
1590
+ }
1591
+ if (operatorMatch) {
1592
+ const [, , operator] = operatorMatch;
1593
+ if (this.dangerousOperators.includes("$" + operator)) {
1594
+ warn(`[mongokit] Blocked dangerous operator: ${operator}`);
1595
+ continue;
1596
+ }
1597
+ this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
1598
+ continue;
1599
+ }
1600
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
1601
+ else parsedFilters[key] = this._convertValue(value);
1602
+ }
1603
+ return parsedFilters;
1604
+ }
1605
+ /**
1606
+ * Handle operator syntax: field[operator]=value
1607
+ */
1608
+ _handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
1609
+ const [, field, operator] = operatorMatch;
1610
+ if (value === "" || value === null || value === void 0) return;
1611
+ if (operator.toLowerCase() === "options" && regexFields[field]) {
1612
+ const fieldValue = filters[field];
1613
+ if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
1614
+ else warn(`[mongokit] Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`);
1615
+ return;
1616
+ }
1617
+ if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
1618
+ const safeRegex = this._createSafeRegex(value);
1619
+ if (safeRegex) {
1620
+ filters[field] = { $regex: safeRegex };
1621
+ regexFields[field] = true;
1622
+ }
1623
+ return;
1624
+ }
1625
+ const mongoOperator = this._toMongoOperator(operator);
1626
+ if (this.dangerousOperators.includes(mongoOperator)) {
1627
+ warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
1628
+ return;
1629
+ }
1630
+ if (mongoOperator === "$eq") filters[field] = value;
1631
+ else if (mongoOperator === "$regex") {
1632
+ const safeRegex = this._createSafeRegex(value);
1633
+ if (safeRegex) {
1634
+ filters[field] = { $regex: safeRegex };
1635
+ regexFields[field] = true;
1636
+ }
1637
+ } else {
1638
+ let processedValue;
1639
+ const op = operator.toLowerCase();
1640
+ if ([
1641
+ "gt",
1642
+ "gte",
1643
+ "lt",
1644
+ "lte",
1645
+ "size"
1646
+ ].includes(op)) {
1647
+ processedValue = parseFloat(String(value));
1648
+ if (isNaN(processedValue)) return;
1649
+ } else if (op === "in" || op === "nin") processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
1650
+ else processedValue = this._convertValue(value);
1651
+ if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
1652
+ filters[field][mongoOperator] = processedValue;
1653
+ }
1654
+ }
1655
+ /**
1656
+ * Handle bracket syntax with object value
1657
+ */
1658
+ _handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
1659
+ if (depth > this.options.maxFilterDepth) {
1660
+ warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1661
+ return;
1662
+ }
1663
+ if (!parsedFilters[field]) parsedFilters[field] = {};
1664
+ for (const [operator, value] of Object.entries(operators)) {
1665
+ if (value === "" || value === null || value === void 0) continue;
1666
+ if (operator === "between") {
1667
+ parsedFilters[field].between = value;
1668
+ continue;
1669
+ }
1670
+ if (this.operators[operator]) {
1671
+ const mongoOperator = this.operators[operator];
1672
+ let processedValue;
1673
+ if ([
1674
+ "gt",
1675
+ "gte",
1676
+ "lt",
1677
+ "lte",
1678
+ "size"
1679
+ ].includes(operator)) {
1680
+ processedValue = parseFloat(String(value));
1681
+ if (isNaN(processedValue)) continue;
1682
+ } else if (operator === "in" || operator === "nin") processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
1683
+ else if (operator === "like" || operator === "contains" || operator === "regex") {
1684
+ const safeRegex = this._createSafeRegex(value);
1685
+ if (!safeRegex) continue;
1686
+ processedValue = safeRegex;
1687
+ } else processedValue = this._convertValue(value);
1688
+ parsedFilters[field][mongoOperator] = processedValue;
1689
+ }
1690
+ }
1691
+ if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
1692
+ }
1693
+ _parseSort(sort) {
1694
+ if (!sort) return void 0;
1695
+ if (typeof sort === "object") {
1696
+ const sortObj = {};
1697
+ for (const [key, value] of Object.entries(sort)) {
1698
+ if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(key)) {
1699
+ warn(`[mongokit] Blocked sort field not in allowlist: ${key}`);
1700
+ continue;
1701
+ }
1702
+ const strVal = String(value).toLowerCase();
1703
+ sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
1704
+ }
1705
+ return Object.keys(sortObj).length > 0 ? sortObj : void 0;
1706
+ }
1707
+ const sortObj = {};
1708
+ const fields = sort.split(",").map((s) => s.trim());
1709
+ for (const field of fields) {
1710
+ if (!field) continue;
1711
+ const cleanField = field.startsWith("-") ? field.substring(1) : field;
1712
+ if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(cleanField)) {
1713
+ warn(`[mongokit] Blocked sort field not in allowlist: ${cleanField}`);
1714
+ continue;
1715
+ }
1716
+ if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
1717
+ else sortObj[field] = 1;
1718
+ }
1719
+ return Object.keys(sortObj).length > 0 ? sortObj : void 0;
1720
+ }
1721
+ _toMongoOperator(operator) {
1722
+ const op = operator.toLowerCase();
1723
+ return op.startsWith("$") ? op : "$" + op;
1724
+ }
1725
+ _createSafeRegex(pattern, flags = "i") {
1726
+ if (pattern === null || pattern === void 0) return null;
1727
+ const patternStr = String(pattern);
1728
+ if (patternStr.length > this.options.maxRegexLength) {
1729
+ warn(`[mongokit] Regex pattern too long, truncating`);
1730
+ return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
1731
+ }
1732
+ if (this.dangerousRegexPatterns.test(patternStr)) {
1733
+ warn("[mongokit] Potentially dangerous regex pattern, escaping");
1734
+ return new RegExp(this._escapeRegex(patternStr), flags);
1735
+ }
1736
+ try {
1737
+ return new RegExp(patternStr, flags);
1738
+ } catch {
1739
+ return new RegExp(this._escapeRegex(patternStr), flags);
1740
+ }
1741
+ }
1742
+ _escapeRegex(str) {
1743
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1744
+ }
1745
+ /**
1746
+ * Sanitize $match configuration to prevent dangerous operators
1747
+ * Recursively filters out operators like $where, $function, $accumulator
1748
+ */
1749
+ _sanitizeMatchConfig(config) {
1750
+ const sanitized = {};
1751
+ for (const [key, value] of Object.entries(config)) {
1752
+ if (this.dangerousOperators.includes(key)) {
1753
+ warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1754
+ continue;
1755
+ }
1756
+ if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeMatchConfig(value);
1757
+ else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
1758
+ if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeMatchConfig(item);
1759
+ return item;
1760
+ });
1761
+ else sanitized[key] = value;
1762
+ }
1763
+ return sanitized;
1764
+ }
1765
+ /**
1766
+ * Sanitize pipeline stages for use in $lookup.
1767
+ * Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
1768
+ * operator expressions within $match, $addFields, and $set stages.
1769
+ */
1770
+ _sanitizePipeline(stages) {
1771
+ const blockedStages = [
1772
+ "$out",
1773
+ "$merge",
1774
+ "$unionWith",
1775
+ "$collStats",
1776
+ "$currentOp",
1777
+ "$listSessions"
1778
+ ];
1779
+ const sanitized = [];
1780
+ for (const stage of stages) {
1781
+ if (!stage || typeof stage !== "object") continue;
1782
+ const entries = Object.entries(stage);
1783
+ if (entries.length !== 1) continue;
1784
+ const [op, config] = entries[0];
1785
+ if (blockedStages.includes(op)) {
1786
+ warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
1787
+ continue;
1788
+ }
1789
+ if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: this._sanitizeMatchConfig(config) });
1790
+ else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: this._sanitizeExpressions(config) });
1791
+ else sanitized.push(stage);
1792
+ }
1793
+ return sanitized;
1794
+ }
1795
+ /**
1796
+ * Recursively sanitize expression objects, blocking dangerous operators
1797
+ * like $where, $function, $accumulator inside $addFields/$set stages.
1798
+ */
1799
+ _sanitizeExpressions(config) {
1800
+ const sanitized = {};
1801
+ for (const [key, value] of Object.entries(config)) {
1802
+ if (this.dangerousOperators.includes(key)) {
1803
+ warn(`[mongokit] Blocked dangerous operator in pipeline expression: ${key}`);
1804
+ continue;
1805
+ }
1806
+ if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeExpressions(value);
1807
+ else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
1808
+ if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeExpressions(item);
1809
+ return item;
1810
+ });
1811
+ else sanitized[key] = value;
1812
+ }
1813
+ return sanitized;
1814
+ }
1815
+ _sanitizeSearch(search) {
1816
+ if (search === null || search === void 0 || search === "") return void 0;
1817
+ let searchStr = String(search).trim();
1818
+ if (!searchStr) return void 0;
1819
+ if (searchStr.length > this.options.maxSearchLength) {
1820
+ warn(`[mongokit] Search query too long, truncating`);
1821
+ searchStr = searchStr.substring(0, this.options.maxSearchLength);
1822
+ }
1823
+ return searchStr;
1824
+ }
1825
+ /**
1826
+ * Build regex-based multi-field search filters
1827
+ * Creates an $or query with case-insensitive regex across all searchFields
1828
+ *
1829
+ * @example
1830
+ * // searchFields: ['name', 'description', 'sku']
1831
+ * // search: 'azure'
1832
+ * // Returns: [
1833
+ * // { name: { $regex: /azure/i } },
1834
+ * // { description: { $regex: /azure/i } },
1835
+ * // { sku: { $regex: /azure/i } }
1836
+ * // ]
1837
+ */
1838
+ _buildRegexSearch(searchTerm) {
1839
+ if (!this.options.searchFields || this.options.searchFields.length === 0) return null;
1840
+ const safeRegex = this._createSafeRegex(searchTerm, "i");
1841
+ if (!safeRegex) return null;
1842
+ const orConditions = [];
1843
+ for (const field of this.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
1844
+ return orConditions.length > 0 ? orConditions : null;
1845
+ }
1846
+ _convertValue(value) {
1847
+ if (value === null || value === void 0) return value;
1848
+ if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
1849
+ if (typeof value === "object") return value;
1850
+ const stringValue = String(value);
1851
+ if (stringValue === "true") return true;
1852
+ if (stringValue === "false") return false;
1853
+ if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) return stringValue;
1854
+ return stringValue;
1855
+ }
1856
+ _parseOr(query) {
1857
+ const orArray = [];
1858
+ const raw = query?.or || query?.OR || query?.$or;
1859
+ if (!raw) return void 0;
1860
+ const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
1861
+ for (const item of items) if (typeof item === "object" && item) orArray.push(this._parseFilters(item, 1));
1862
+ return orArray.length ? orArray : void 0;
1863
+ }
1864
+ _enhanceWithBetween(filters) {
1865
+ const output = { ...filters };
1866
+ for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
1867
+ const between = value.between;
1868
+ const [from, to] = String(between).split(",").map((s) => s.trim());
1869
+ const fromDate = from ? new Date(from) : void 0;
1870
+ const toDate = to ? new Date(to) : void 0;
1871
+ const range = {};
1872
+ if (fromDate && !isNaN(fromDate.getTime())) range.$gte = fromDate;
1873
+ if (toDate && !isNaN(toDate.getTime())) range.$lte = toDate;
1874
+ output[key] = range;
1875
+ }
1876
+ return output;
1877
+ }
1878
+ _pluralize(str) {
1879
+ if (str.endsWith("y")) return str.slice(0, -1) + "ies";
1880
+ if (str.endsWith("s")) return str;
1881
+ return str + "s";
1882
+ }
1883
+ _capitalize(str) {
1884
+ return str.charAt(0).toUpperCase() + str.slice(1);
1885
+ }
1886
+ };
1887
+
1888
+ //#endregion
1889
+ //#region src/index.ts
1890
+ /**
1891
+ * Factory function to create a repository instance
1892
+ *
1893
+ * @param Model - Mongoose model
1894
+ * @param plugins - Array of plugins to apply
1895
+ * @returns Repository instance
1896
+ *
1897
+ * @example
1898
+ * const userRepo = createRepository(UserModel, [timestampPlugin()]);
1899
+ */
1900
+ function createRepository(Model, plugins = [], paginationConfig = {}, options = {}) {
1901
+ return new Repository(Model, plugins, paginationConfig, options);
1902
+ }
1903
+ var src_default = Repository;
1904
+
1905
+ //#endregion
1906
+ export { AggregationBuilder, LookupBuilder, PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };