@classytic/mongokit 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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-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
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
  */
@@ -756,26 +666,38 @@ var Repository = class {
756
666
  * Get document by ID
757
667
  */
758
668
  async getById(id, options = {}) {
759
- const context = await this._buildContext("getById", { id, ...options });
669
+ const populateSpec = options.populateOptions || options.populate;
670
+ const context = await this._buildContext("getById", { id, ...options, populate: populateSpec });
760
671
  if (context._cacheHit) {
761
672
  return context._cachedResult;
762
673
  }
763
- const result = await getById(this.Model, id, context);
764
- await this._emitHook("after:getById", { context, result });
765
- 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
+ }
766
682
  }
767
683
  /**
768
684
  * Get single document by query
769
685
  */
770
686
  async getByQuery(query, options = {}) {
771
- const context = await this._buildContext("getByQuery", { query, ...options });
687
+ const populateSpec = options.populateOptions || options.populate;
688
+ const context = await this._buildContext("getByQuery", { query, ...options, populate: populateSpec });
772
689
  if (context._cacheHit) {
773
690
  return context._cachedResult;
774
691
  }
775
692
  const finalQuery = context.query || query;
776
- const result = await getByQuery(this.Model, finalQuery, context);
777
- await this._emitHook("after:getByQuery", { context, result });
778
- 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
+ }
779
701
  }
780
702
  /**
781
703
  * Unified pagination - auto-detects offset vs keyset based on params
@@ -805,17 +727,16 @@ var Repository = class {
805
727
  if (context._cacheHit) {
806
728
  return context._cachedResult;
807
729
  }
808
- const hasPageParam = params.page !== void 0 || params.pagination;
809
- const hasCursorParam = "cursor" in params || "after" in params;
810
- const hasSortParam = params.sort !== void 0;
811
- const useKeyset = !hasPageParam && (hasCursorParam || hasSortParam);
812
- const filters = context.filters || params.filters || {};
813
- const search = params.search;
814
- const sort = params.sort || "-createdAt";
815
- 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));
816
737
  let query = { ...filters };
817
738
  if (search) query.$text = { $search: search };
818
- const populateSpec = options.populateOptions || context.populate || options.populate;
739
+ const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
819
740
  const paginationOptions = {
820
741
  filters: query,
821
742
  sort: this._parseSort(sort),
@@ -825,23 +746,26 @@ var Repository = class {
825
746
  lean: context.lean ?? options.lean ?? true,
826
747
  session: options.session
827
748
  };
828
- let result;
829
- if (useKeyset) {
830
- result = await this._pagination.stream({
831
- ...paginationOptions,
832
- sort: paginationOptions.sort,
833
- // Required for keyset
834
- after: params.cursor || params.after
835
- });
836
- } else {
837
- const page = params.pagination?.page || params.page || 1;
838
- result = await this._pagination.paginate({
839
- ...paginationOptions,
840
- page
841
- });
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);
842
768
  }
843
- await this._emitHook("after:getAll", { context, result });
844
- return result;
845
769
  }
846
770
  /**
847
771
  * Get or create document
@@ -886,7 +810,7 @@ var Repository = class {
886
810
  await this._emitHook("after:delete", { context, result: result2 });
887
811
  return result2;
888
812
  }
889
- const result = await deleteById(this.Model, id, options);
813
+ const result = await deleteById(this.Model, id, { session: options.session, query: context.query });
890
814
  await this._emitHook("after:delete", { context, result });
891
815
  return result;
892
816
  } catch (error) {
@@ -942,8 +866,9 @@ var Repository = class {
942
866
  const context = await this._buildContext("lookupPopulate", options);
943
867
  try {
944
868
  const builder = new AggregationBuilder();
945
- if (options.filters && Object.keys(options.filters).length > 0) {
946
- builder.match(options.filters);
869
+ const filters = context.filters ?? options.filters;
870
+ if (filters && Object.keys(filters).length > 0) {
871
+ builder.match(filters);
947
872
  }
948
873
  builder.multiLookup(options.lookups);
949
874
  if (options.sort) {
@@ -955,12 +880,12 @@ var Repository = class {
955
880
  const SAFE_LIMIT = 1e3;
956
881
  const SAFE_MAX_OFFSET = 1e4;
957
882
  if (limit > SAFE_LIMIT) {
958
- console.warn(
883
+ warn(
959
884
  `[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`
960
885
  );
961
886
  }
962
887
  if (skip > SAFE_MAX_OFFSET) {
963
- console.warn(
888
+ warn(
964
889
  `[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`
965
890
  );
966
891
  }
@@ -1057,40 +982,52 @@ var Repository = class {
1057
982
  return new LookupBuilder(from);
1058
983
  }
1059
984
  /**
1060
- * 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
+ * ```
1061
1013
  */
1062
1014
  async withTransaction(callback, options = {}) {
1063
1015
  const session = await mongoose.startSession();
1064
- let started = false;
1065
1016
  try {
1066
- session.startTransaction();
1067
- started = true;
1068
- const result = await callback(session);
1069
- await session.commitTransaction();
1017
+ const result = await session.withTransaction(
1018
+ () => callback(session),
1019
+ options.transactionOptions
1020
+ );
1070
1021
  return result;
1071
1022
  } catch (error) {
1072
1023
  const err = error;
1073
1024
  if (options.allowFallback && this._isTransactionUnsupported(err)) {
1074
- if (typeof options.onFallback === "function") {
1075
- options.onFallback(err);
1076
- }
1077
- if (started) {
1078
- try {
1079
- await session.abortTransaction();
1080
- } catch {
1081
- }
1082
- }
1083
- return await callback(null);
1084
- }
1085
- if (started) {
1086
- try {
1087
- await session.abortTransaction();
1088
- } catch {
1089
- }
1025
+ options.onFallback?.(err);
1026
+ return await callback(session);
1090
1027
  }
1091
1028
  throw err;
1092
1029
  } finally {
1093
- session.endSession();
1030
+ await session.endSession();
1094
1031
  }
1095
1032
  }
1096
1033
  _isTransactionUnsupported(error) {
@@ -1197,10 +1134,11 @@ var QueryParser = class {
1197
1134
  enableLookups: options.enableLookups ?? true,
1198
1135
  enableAggregations: options.enableAggregations ?? false,
1199
1136
  searchMode: options.searchMode ?? "text",
1200
- searchFields: options.searchFields
1137
+ searchFields: options.searchFields,
1138
+ allowedLookupCollections: options.allowedLookupCollections
1201
1139
  };
1202
1140
  if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1203
- 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.');
1204
1142
  this.options.searchMode = "text";
1205
1143
  }
1206
1144
  this.dangerousOperators = [
@@ -1240,7 +1178,7 @@ var QueryParser = class {
1240
1178
  parsedLimit = 20;
1241
1179
  }
1242
1180
  if (parsedLimit > this.options.maxLimit) {
1243
- 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`);
1244
1182
  parsedLimit = this.options.maxLimit;
1245
1183
  }
1246
1184
  const sanitizedSearch = this._sanitizeSearch(search);
@@ -1339,7 +1277,7 @@ var QueryParser = class {
1339
1277
  lookups.push(lookupConfig);
1340
1278
  }
1341
1279
  } catch (error) {
1342
- console.warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
1280
+ warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
1343
1281
  }
1344
1282
  }
1345
1283
  return lookups;
@@ -1350,8 +1288,13 @@ var QueryParser = class {
1350
1288
  _parseSingleLookup(collectionName, config) {
1351
1289
  if (!config) return null;
1352
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
+ }
1353
1296
  return {
1354
- from: this._pluralize(collectionName),
1297
+ from,
1355
1298
  localField: `${collectionName}${this._capitalize(config)}`,
1356
1299
  foreignField: config,
1357
1300
  as: collectionName,
@@ -1363,8 +1306,12 @@ var QueryParser = class {
1363
1306
  const from = opts.from || this._pluralize(collectionName);
1364
1307
  const localField = opts.localField;
1365
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
+ }
1366
1313
  if (!localField || !foreignField) {
1367
- console.warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1314
+ warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1368
1315
  return null;
1369
1316
  }
1370
1317
  return {
@@ -1373,7 +1320,7 @@ var QueryParser = class {
1373
1320
  foreignField,
1374
1321
  as: opts.as || collectionName,
1375
1322
  single: opts.single === true || opts.single === "true",
1376
- ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: opts.pipeline } : {}
1323
+ ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
1377
1324
  };
1378
1325
  }
1379
1326
  return null;
@@ -1411,7 +1358,7 @@ var QueryParser = class {
1411
1358
  pipeline.push({ $project: config });
1412
1359
  }
1413
1360
  } catch (error) {
1414
- console.warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1361
+ warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1415
1362
  }
1416
1363
  }
1417
1364
  return pipeline.length > 0 ? pipeline : void 0;
@@ -1477,7 +1424,7 @@ var QueryParser = class {
1477
1424
  const populateOptions = [];
1478
1425
  for (const [path, config] of Object.entries(populateObj)) {
1479
1426
  if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
1480
- console.warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1427
+ warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1481
1428
  continue;
1482
1429
  }
1483
1430
  const option = this._parseSinglePopulate(path, config);
@@ -1494,7 +1441,7 @@ var QueryParser = class {
1494
1441
  */
1495
1442
  _parseSinglePopulate(path, config, depth = 0) {
1496
1443
  if (depth > 5) {
1497
- console.warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1444
+ warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1498
1445
  return { path };
1499
1446
  }
1500
1447
  if (typeof config === "string") {
@@ -1566,14 +1513,14 @@ var QueryParser = class {
1566
1513
  */
1567
1514
  _parseFilters(filters, depth = 0) {
1568
1515
  if (depth > this.options.maxFilterDepth) {
1569
- console.warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1516
+ warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1570
1517
  return {};
1571
1518
  }
1572
1519
  const parsedFilters = {};
1573
1520
  const regexFields = {};
1574
1521
  for (const [key, value] of Object.entries(filters)) {
1575
1522
  if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
1576
- console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
1523
+ warn(`[mongokit] Blocked dangerous operator: ${key}`);
1577
1524
  continue;
1578
1525
  }
1579
1526
  if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate", "or", "OR", "$or"].includes(key)) {
@@ -1583,7 +1530,7 @@ var QueryParser = class {
1583
1530
  if (operatorMatch) {
1584
1531
  const [, , operator] = operatorMatch;
1585
1532
  if (this.dangerousOperators.includes("$" + operator)) {
1586
- console.warn(`[mongokit] Blocked dangerous operator: ${operator}`);
1533
+ warn(`[mongokit] Blocked dangerous operator: ${operator}`);
1587
1534
  continue;
1588
1535
  }
1589
1536
  this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
@@ -1622,7 +1569,7 @@ var QueryParser = class {
1622
1569
  }
1623
1570
  const mongoOperator = this._toMongoOperator(operator);
1624
1571
  if (this.dangerousOperators.includes(mongoOperator)) {
1625
- console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
1572
+ warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
1626
1573
  return;
1627
1574
  }
1628
1575
  if (mongoOperator === "$eq") {
@@ -1655,7 +1602,7 @@ var QueryParser = class {
1655
1602
  */
1656
1603
  _handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
1657
1604
  if (depth > this.options.maxFilterDepth) {
1658
- console.warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1605
+ warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1659
1606
  return;
1660
1607
  }
1661
1608
  if (!parsedFilters[field]) {
@@ -1714,11 +1661,11 @@ var QueryParser = class {
1714
1661
  if (pattern === null || pattern === void 0) return null;
1715
1662
  const patternStr = String(pattern);
1716
1663
  if (patternStr.length > this.options.maxRegexLength) {
1717
- console.warn(`[mongokit] Regex pattern too long, truncating`);
1664
+ warn(`[mongokit] Regex pattern too long, truncating`);
1718
1665
  return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
1719
1666
  }
1720
1667
  if (this.dangerousRegexPatterns.test(patternStr)) {
1721
- console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
1668
+ warn("[mongokit] Potentially dangerous regex pattern, escaping");
1722
1669
  return new RegExp(this._escapeRegex(patternStr), flags);
1723
1670
  }
1724
1671
  try {
@@ -1738,7 +1685,7 @@ var QueryParser = class {
1738
1685
  const sanitized = {};
1739
1686
  for (const [key, value] of Object.entries(config)) {
1740
1687
  if (this.dangerousOperators.includes(key)) {
1741
- console.warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1688
+ warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1742
1689
  continue;
1743
1690
  }
1744
1691
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1756,12 +1703,65 @@ var QueryParser = class {
1756
1703
  }
1757
1704
  return sanitized;
1758
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
1759
  _sanitizeSearch(search) {
1760
1760
  if (search === null || search === void 0 || search === "") return void 0;
1761
1761
  let searchStr = String(search).trim();
1762
1762
  if (!searchStr) return void 0;
1763
1763
  if (searchStr.length > this.options.maxSearchLength) {
1764
- console.warn(`[mongokit] Search query too long, truncating`);
1764
+ warn(`[mongokit] Search query too long, truncating`);
1765
1765
  searchStr = searchStr.substring(0, this.options.maxSearchLength);
1766
1766
  }
1767
1767
  return searchStr;
@@ -1890,4 +1890,4 @@ var index_default = Repository;
1890
1890
  * ```
1891
1891
  */
1892
1892
 
1893
- export { AggregationBuilder, LookupBuilder, QueryParser, Repository, createRepository, index_default as default };
1893
+ export { AggregationBuilder, QueryParser, Repository, createRepository, index_default as default };