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