@classytic/mongokit 3.1.6 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,224 +1,18 @@
1
- import { getById, getByQuery, getOrCreate, count, exists, update, deleteById, aggregate, distinct } from './chunks/chunk-SAKSLT47.js';
2
- export { actions_exports as actions } from './chunks/chunk-SAKSLT47.js';
3
- import { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
4
- export { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
5
- export { aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, fieldFilterPlugin, immutableField, methodRegistryPlugin, mongoOperationsPlugin, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin } from './chunks/chunk-CSLJ2PL2.js';
6
- import { create, createMany } from './chunks/chunk-CF6FLC2G.js';
7
- export { buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, createMemoryCache, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from './chunks/chunk-IT7DCOKR.js';
1
+ import { LookupBuilder, getById, getByQuery, getOrCreate, count, exists, update, deleteById, aggregate, distinct } from './chunks/chunk-5G42WJHC.js';
2
+ export { LookupBuilder, actions_exports as actions } from './chunks/chunk-5G42WJHC.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-B64F5ZWE.js';
6
+ import { create, createMany } from './chunks/chunk-GZBKEPVE.js';
7
+ export { buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, createMemoryCache, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from './chunks/chunk-UE2IEXZJ.js';
8
8
  export { createFieldPreset, filterResponseData, getFieldsForUser, getMongooseProjection } from './chunks/chunk-2ZN65ZOP.js';
9
- import { createError } from './chunks/chunk-VJXDGP3C.js';
10
- export { createError } from './chunks/chunk-VJXDGP3C.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';
11
14
  import mongoose from 'mongoose';
12
15
 
13
- // src/query/LookupBuilder.ts
14
- var LookupBuilder = class _LookupBuilder {
15
- options = {};
16
- constructor(from) {
17
- if (from) this.options.from = from;
18
- }
19
- /**
20
- * Set the collection to join with
21
- */
22
- from(collection) {
23
- this.options.from = collection;
24
- return this;
25
- }
26
- /**
27
- * Set the local field (source collection)
28
- * IMPORTANT: This field should be indexed for optimal performance
29
- */
30
- localField(field) {
31
- this.options.localField = field;
32
- return this;
33
- }
34
- /**
35
- * Set the foreign field (target collection)
36
- * IMPORTANT: This field should be indexed (preferably unique) for optimal performance
37
- */
38
- foreignField(field) {
39
- this.options.foreignField = field;
40
- return this;
41
- }
42
- /**
43
- * Set the output field name
44
- * Defaults to the collection name if not specified
45
- */
46
- as(fieldName) {
47
- this.options.as = fieldName;
48
- return this;
49
- }
50
- /**
51
- * Mark this lookup as returning a single document
52
- * Automatically unwraps the array result to a single object or null
53
- */
54
- single(isSingle = true) {
55
- this.options.single = isSingle;
56
- return this;
57
- }
58
- /**
59
- * Add a pipeline to filter/transform joined documents
60
- * Useful for filtering, sorting, or limiting joined results
61
- *
62
- * @example
63
- * ```typescript
64
- * lookup.pipeline([
65
- * { $match: { status: 'active' } },
66
- * { $sort: { priority: -1 } },
67
- * { $limit: 5 }
68
- * ]);
69
- * ```
70
- */
71
- pipeline(stages) {
72
- this.options.pipeline = stages;
73
- return this;
74
- }
75
- /**
76
- * Set let variables for use in pipeline
77
- * Allows referencing local document fields in the pipeline
78
- */
79
- let(variables) {
80
- this.options.let = variables;
81
- return this;
82
- }
83
- /**
84
- * Build the $lookup aggregation stage(s)
85
- * Returns an array of pipeline stages including $lookup and optional $unwind
86
- *
87
- * IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
88
- * 1. Simple form: { from, localField, foreignField, as }
89
- * 2. Pipeline form: { from, let, pipeline, as }
90
- *
91
- * When pipeline or let is specified, we use the pipeline form.
92
- * Otherwise, we use the simpler localField/foreignField form.
93
- */
94
- build() {
95
- const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
96
- if (!from) {
97
- throw new Error('LookupBuilder: "from" collection is required');
98
- }
99
- const outputField = as || from;
100
- const stages = [];
101
- const usePipelineForm = pipeline || letVars;
102
- let lookupStage;
103
- if (usePipelineForm) {
104
- if (!pipeline || pipeline.length === 0) {
105
- if (!localField || !foreignField) {
106
- throw new Error(
107
- "LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline"
108
- );
109
- }
110
- const autoPipeline = [
111
- {
112
- $match: {
113
- $expr: {
114
- $eq: [`$${foreignField}`, `$$${localField}`]
115
- }
116
- }
117
- }
118
- ];
119
- lookupStage = {
120
- $lookup: {
121
- from,
122
- let: { [localField]: `$${localField}`, ...letVars || {} },
123
- pipeline: autoPipeline,
124
- as: outputField
125
- }
126
- };
127
- } else {
128
- lookupStage = {
129
- $lookup: {
130
- from,
131
- ...letVars && { let: letVars },
132
- pipeline,
133
- as: outputField
134
- }
135
- };
136
- }
137
- } else {
138
- if (!localField || !foreignField) {
139
- throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
140
- }
141
- lookupStage = {
142
- $lookup: {
143
- from,
144
- localField,
145
- foreignField,
146
- as: outputField
147
- }
148
- };
149
- }
150
- stages.push(lookupStage);
151
- if (single) {
152
- stages.push({
153
- $unwind: {
154
- path: `$${outputField}`,
155
- preserveNullAndEmptyArrays: true
156
- // Keep documents even if no match found
157
- }
158
- });
159
- }
160
- return stages;
161
- }
162
- /**
163
- * Build and return only the $lookup stage (without $unwind)
164
- * Useful when you want to handle unwrapping yourself
165
- */
166
- buildLookupOnly() {
167
- const stages = this.build();
168
- return stages[0];
169
- }
170
- /**
171
- * Static helper: Create a simple lookup in one line
172
- */
173
- static simple(from, localField, foreignField, options = {}) {
174
- return new _LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
175
- }
176
- /**
177
- * Static helper: Create multiple lookups at once
178
- *
179
- * @example
180
- * ```typescript
181
- * const pipeline = LookupBuilder.multiple([
182
- * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
183
- * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
184
- * ]);
185
- * ```
186
- */
187
- static multiple(lookups) {
188
- return lookups.flatMap((lookup) => {
189
- const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
190
- if (lookup.as) builder.as(lookup.as);
191
- if (lookup.single) builder.single(lookup.single);
192
- if (lookup.pipeline) builder.pipeline(lookup.pipeline);
193
- if (lookup.let) builder.let(lookup.let);
194
- return builder.build();
195
- });
196
- }
197
- /**
198
- * Static helper: Create a nested lookup (lookup within lookup)
199
- * Useful for multi-level joins like Order -> Product -> Category
200
- *
201
- * @example
202
- * ```typescript
203
- * // Join orders with products, then products with categories
204
- * const pipeline = LookupBuilder.nested([
205
- * { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
206
- * { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
207
- * ]);
208
- * ```
209
- */
210
- static nested(lookups) {
211
- return lookups.flatMap((lookup, index) => {
212
- const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
213
- if (lookup.as) builder.as(lookup.as);
214
- if (lookup.single !== void 0) builder.single(lookup.single);
215
- if (lookup.pipeline) builder.pipeline(lookup.pipeline);
216
- if (lookup.let) builder.let(lookup.let);
217
- return builder.build();
218
- });
219
- }
220
- };
221
-
222
16
  // src/query/AggregationBuilder.ts
223
17
  function normalizeSortSpec(sortSpec) {
224
18
  const normalized = {};
@@ -235,6 +29,7 @@ function normalizeSortSpec(sortSpec) {
235
29
  }
236
30
  var AggregationBuilder = class _AggregationBuilder {
237
31
  pipeline = [];
32
+ _diskUse = false;
238
33
  /**
239
34
  * Get the current pipeline
240
35
  */
@@ -247,11 +42,35 @@ var AggregationBuilder = class _AggregationBuilder {
247
42
  build() {
248
43
  return this.get();
249
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
+ }
250
68
  /**
251
69
  * Reset the pipeline
252
70
  */
253
71
  reset() {
254
72
  this.pipeline = [];
73
+ this._diskUse = false;
255
74
  return this;
256
75
  }
257
76
  /**
@@ -543,6 +362,25 @@ var AggregationBuilder = class _AggregationBuilder {
543
362
  return this;
544
363
  }
545
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
+ // ============================================================
546
384
  // UTILITY METHODS
547
385
  // ============================================================
548
386
  /**
@@ -626,6 +464,56 @@ var AggregationBuilder = class _AggregationBuilder {
626
464
  return this;
627
465
  }
628
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
+ // ============================================================
629
517
  // HELPER FACTORY METHODS
630
518
  // ============================================================
631
519
  /**
@@ -680,6 +568,28 @@ var Repository = class {
680
568
  this._hooks.get(event).push(listener);
681
569
  return this;
682
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
+ }
683
593
  /**
684
594
  * Emit event (sync - for backwards compatibility)
685
595
  */
@@ -761,9 +671,14 @@ var Repository = class {
761
671
  if (context._cacheHit) {
762
672
  return context._cachedResult;
763
673
  }
764
- const result = await getById(this.Model, id, context);
765
- await this._emitHook("after:getById", { context, result });
766
- return result;
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
+ }
767
682
  }
768
683
  /**
769
684
  * Get single document by query
@@ -775,9 +690,14 @@ var Repository = class {
775
690
  return context._cachedResult;
776
691
  }
777
692
  const finalQuery = context.query || query;
778
- const result = await getByQuery(this.Model, finalQuery, context);
779
- await this._emitHook("after:getByQuery", { context, result });
780
- return result;
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
+ }
781
701
  }
782
702
  /**
783
703
  * Unified pagination - auto-detects offset vs keyset based on params
@@ -807,14 +727,13 @@ var Repository = class {
807
727
  if (context._cacheHit) {
808
728
  return context._cachedResult;
809
729
  }
810
- const hasPageParam = params.page !== void 0 || params.pagination;
811
- const hasCursorParam = "cursor" in params || "after" in params;
812
- const hasSortParam = params.sort !== void 0;
813
- const useKeyset = !hasPageParam && (hasCursorParam || hasSortParam);
814
- const filters = context.filters || params.filters || {};
815
- const search = params.search;
816
- const sort = params.sort || "-createdAt";
817
- const limit = params.limit || params.pagination?.limit || this._pagination.config.defaultLimit;
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));
818
737
  let query = { ...filters };
819
738
  if (search) query.$text = { $search: search };
820
739
  const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
@@ -827,23 +746,26 @@ var Repository = class {
827
746
  lean: context.lean ?? options.lean ?? true,
828
747
  session: options.session
829
748
  };
830
- let result;
831
- if (useKeyset) {
832
- result = await this._pagination.stream({
833
- ...paginationOptions,
834
- sort: paginationOptions.sort,
835
- // Required for keyset
836
- after: params.cursor || params.after
837
- });
838
- } else {
839
- const page = params.pagination?.page || params.page || 1;
840
- result = await this._pagination.paginate({
841
- ...paginationOptions,
842
- page
843
- });
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);
844
768
  }
845
- await this._emitHook("after:getAll", { context, result });
846
- return result;
847
769
  }
848
770
  /**
849
771
  * Get or create document
@@ -888,7 +810,7 @@ var Repository = class {
888
810
  await this._emitHook("after:delete", { context, result: result2 });
889
811
  return result2;
890
812
  }
891
- const result = await deleteById(this.Model, id, options);
813
+ const result = await deleteById(this.Model, id, { session: options.session, query: context.query });
892
814
  await this._emitHook("after:delete", { context, result });
893
815
  return result;
894
816
  } catch (error) {
@@ -944,8 +866,9 @@ var Repository = class {
944
866
  const context = await this._buildContext("lookupPopulate", options);
945
867
  try {
946
868
  const builder = new AggregationBuilder();
947
- if (options.filters && Object.keys(options.filters).length > 0) {
948
- builder.match(options.filters);
869
+ const filters = context.filters ?? options.filters;
870
+ if (filters && Object.keys(filters).length > 0) {
871
+ builder.match(filters);
949
872
  }
950
873
  builder.multiLookup(options.lookups);
951
874
  if (options.sort) {
@@ -957,12 +880,12 @@ var Repository = class {
957
880
  const SAFE_LIMIT = 1e3;
958
881
  const SAFE_MAX_OFFSET = 1e4;
959
882
  if (limit > SAFE_LIMIT) {
960
- console.warn(
883
+ warn(
961
884
  `[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`
962
885
  );
963
886
  }
964
887
  if (skip > SAFE_MAX_OFFSET) {
965
- console.warn(
888
+ warn(
966
889
  `[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`
967
890
  );
968
891
  }
@@ -1059,40 +982,52 @@ var Repository = class {
1059
982
  return new LookupBuilder(from);
1060
983
  }
1061
984
  /**
1062
- * Execute callback within a transaction
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
+ * ```
1063
1013
  */
1064
1014
  async withTransaction(callback, options = {}) {
1065
1015
  const session = await mongoose.startSession();
1066
- let started = false;
1067
1016
  try {
1068
- session.startTransaction();
1069
- started = true;
1070
- const result = await callback(session);
1071
- await session.commitTransaction();
1017
+ const result = await session.withTransaction(
1018
+ () => callback(session),
1019
+ options.transactionOptions
1020
+ );
1072
1021
  return result;
1073
1022
  } catch (error) {
1074
1023
  const err = error;
1075
1024
  if (options.allowFallback && this._isTransactionUnsupported(err)) {
1076
- if (typeof options.onFallback === "function") {
1077
- options.onFallback(err);
1078
- }
1079
- if (started) {
1080
- try {
1081
- await session.abortTransaction();
1082
- } catch {
1083
- }
1084
- }
1085
- return await callback(null);
1086
- }
1087
- if (started) {
1088
- try {
1089
- await session.abortTransaction();
1090
- } catch {
1091
- }
1025
+ options.onFallback?.(err);
1026
+ return await callback(session);
1092
1027
  }
1093
1028
  throw err;
1094
1029
  } finally {
1095
- session.endSession();
1030
+ await session.endSession();
1096
1031
  }
1097
1032
  }
1098
1033
  _isTransactionUnsupported(error) {
@@ -1199,10 +1134,11 @@ var QueryParser = class {
1199
1134
  enableLookups: options.enableLookups ?? true,
1200
1135
  enableAggregations: options.enableAggregations ?? false,
1201
1136
  searchMode: options.searchMode ?? "text",
1202
- searchFields: options.searchFields
1137
+ searchFields: options.searchFields,
1138
+ allowedLookupCollections: options.allowedLookupCollections
1203
1139
  };
1204
1140
  if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1205
- console.warn('[mongokit] searchMode "regex" requires searchFields to be specified. Falling back to "text" mode.');
1141
+ warn('[mongokit] searchMode "regex" requires searchFields to be specified. Falling back to "text" mode.');
1206
1142
  this.options.searchMode = "text";
1207
1143
  }
1208
1144
  this.dangerousOperators = [
@@ -1242,7 +1178,7 @@ var QueryParser = class {
1242
1178
  parsedLimit = 20;
1243
1179
  }
1244
1180
  if (parsedLimit > this.options.maxLimit) {
1245
- console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1181
+ warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1246
1182
  parsedLimit = this.options.maxLimit;
1247
1183
  }
1248
1184
  const sanitizedSearch = this._sanitizeSearch(search);
@@ -1341,7 +1277,7 @@ var QueryParser = class {
1341
1277
  lookups.push(lookupConfig);
1342
1278
  }
1343
1279
  } catch (error) {
1344
- console.warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
1280
+ warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
1345
1281
  }
1346
1282
  }
1347
1283
  return lookups;
@@ -1352,8 +1288,13 @@ var QueryParser = class {
1352
1288
  _parseSingleLookup(collectionName, config) {
1353
1289
  if (!config) return null;
1354
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
+ }
1355
1296
  return {
1356
- from: this._pluralize(collectionName),
1297
+ from,
1357
1298
  localField: `${collectionName}${this._capitalize(config)}`,
1358
1299
  foreignField: config,
1359
1300
  as: collectionName,
@@ -1365,8 +1306,12 @@ var QueryParser = class {
1365
1306
  const from = opts.from || this._pluralize(collectionName);
1366
1307
  const localField = opts.localField;
1367
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
+ }
1368
1313
  if (!localField || !foreignField) {
1369
- console.warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1314
+ warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1370
1315
  return null;
1371
1316
  }
1372
1317
  return {
@@ -1375,7 +1320,7 @@ var QueryParser = class {
1375
1320
  foreignField,
1376
1321
  as: opts.as || collectionName,
1377
1322
  single: opts.single === true || opts.single === "true",
1378
- ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: opts.pipeline } : {}
1323
+ ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
1379
1324
  };
1380
1325
  }
1381
1326
  return null;
@@ -1413,7 +1358,7 @@ var QueryParser = class {
1413
1358
  pipeline.push({ $project: config });
1414
1359
  }
1415
1360
  } catch (error) {
1416
- console.warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1361
+ warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1417
1362
  }
1418
1363
  }
1419
1364
  return pipeline.length > 0 ? pipeline : void 0;
@@ -1479,7 +1424,7 @@ var QueryParser = class {
1479
1424
  const populateOptions = [];
1480
1425
  for (const [path, config] of Object.entries(populateObj)) {
1481
1426
  if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
1482
- console.warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1427
+ warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1483
1428
  continue;
1484
1429
  }
1485
1430
  const option = this._parseSinglePopulate(path, config);
@@ -1496,7 +1441,7 @@ var QueryParser = class {
1496
1441
  */
1497
1442
  _parseSinglePopulate(path, config, depth = 0) {
1498
1443
  if (depth > 5) {
1499
- console.warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1444
+ warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1500
1445
  return { path };
1501
1446
  }
1502
1447
  if (typeof config === "string") {
@@ -1568,14 +1513,14 @@ var QueryParser = class {
1568
1513
  */
1569
1514
  _parseFilters(filters, depth = 0) {
1570
1515
  if (depth > this.options.maxFilterDepth) {
1571
- console.warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1516
+ warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1572
1517
  return {};
1573
1518
  }
1574
1519
  const parsedFilters = {};
1575
1520
  const regexFields = {};
1576
1521
  for (const [key, value] of Object.entries(filters)) {
1577
1522
  if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
1578
- console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
1523
+ warn(`[mongokit] Blocked dangerous operator: ${key}`);
1579
1524
  continue;
1580
1525
  }
1581
1526
  if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate", "or", "OR", "$or"].includes(key)) {
@@ -1585,7 +1530,7 @@ var QueryParser = class {
1585
1530
  if (operatorMatch) {
1586
1531
  const [, , operator] = operatorMatch;
1587
1532
  if (this.dangerousOperators.includes("$" + operator)) {
1588
- console.warn(`[mongokit] Blocked dangerous operator: ${operator}`);
1533
+ warn(`[mongokit] Blocked dangerous operator: ${operator}`);
1589
1534
  continue;
1590
1535
  }
1591
1536
  this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
@@ -1624,7 +1569,7 @@ var QueryParser = class {
1624
1569
  }
1625
1570
  const mongoOperator = this._toMongoOperator(operator);
1626
1571
  if (this.dangerousOperators.includes(mongoOperator)) {
1627
- console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
1572
+ warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
1628
1573
  return;
1629
1574
  }
1630
1575
  if (mongoOperator === "$eq") {
@@ -1657,7 +1602,7 @@ var QueryParser = class {
1657
1602
  */
1658
1603
  _handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
1659
1604
  if (depth > this.options.maxFilterDepth) {
1660
- console.warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1605
+ warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1661
1606
  return;
1662
1607
  }
1663
1608
  if (!parsedFilters[field]) {
@@ -1716,11 +1661,11 @@ var QueryParser = class {
1716
1661
  if (pattern === null || pattern === void 0) return null;
1717
1662
  const patternStr = String(pattern);
1718
1663
  if (patternStr.length > this.options.maxRegexLength) {
1719
- console.warn(`[mongokit] Regex pattern too long, truncating`);
1664
+ warn(`[mongokit] Regex pattern too long, truncating`);
1720
1665
  return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
1721
1666
  }
1722
1667
  if (this.dangerousRegexPatterns.test(patternStr)) {
1723
- console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
1668
+ warn("[mongokit] Potentially dangerous regex pattern, escaping");
1724
1669
  return new RegExp(this._escapeRegex(patternStr), flags);
1725
1670
  }
1726
1671
  try {
@@ -1740,7 +1685,7 @@ var QueryParser = class {
1740
1685
  const sanitized = {};
1741
1686
  for (const [key, value] of Object.entries(config)) {
1742
1687
  if (this.dangerousOperators.includes(key)) {
1743
- console.warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1688
+ warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1744
1689
  continue;
1745
1690
  }
1746
1691
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1758,12 +1703,65 @@ var QueryParser = class {
1758
1703
  }
1759
1704
  return sanitized;
1760
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
+ }
1761
1759
  _sanitizeSearch(search) {
1762
1760
  if (search === null || search === void 0 || search === "") return void 0;
1763
1761
  let searchStr = String(search).trim();
1764
1762
  if (!searchStr) return void 0;
1765
1763
  if (searchStr.length > this.options.maxSearchLength) {
1766
- console.warn(`[mongokit] Search query too long, truncating`);
1764
+ warn(`[mongokit] Search query too long, truncating`);
1767
1765
  searchStr = searchStr.substring(0, this.options.maxSearchLength);
1768
1766
  }
1769
1767
  return searchStr;
@@ -1892,4 +1890,4 @@ var index_default = Repository;
1892
1890
  * ```
1893
1891
  */
1894
1892
 
1895
- export { AggregationBuilder, LookupBuilder, QueryParser, Repository, createRepository, index_default as default };
1893
+ export { AggregationBuilder, QueryParser, Repository, createRepository, index_default as default };