@classytic/mongokit 3.2.5 → 3.3.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.
@@ -1,8 +1,1351 @@
1
1
  import { i as createError, n as debug, r as warn } from "./logger-D8ily-PP.mjs";
2
- import { i as upsert } from "./create-BuO6xt0v.mjs";
3
- import { a as modelPattern, i as listQueryKey, l as getFieldsForUser, n as byQueryKey, o as versionKey, t as byIdKey } from "./cache-keys-C8Z9B5sw.mjs";
2
+ import { _ as upsert, c as count, d as getByQuery, f as getOrCreate, h as createMany, i as deleteById, l as exists, m as create, n as distinct, o as update, r as LookupBuilder, u as getById } from "./aggregate-BClp040M.mjs";
3
+ import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
4
+ import { a as modelPattern, i as listQueryKey, l as getFieldsForUser, n as byQueryKey, o as versionKey, t as byIdKey } from "./cache-keys-CzFwVnLy.mjs";
4
5
  import mongoose from "mongoose";
5
6
 
7
+ //#region src/query/AggregationBuilder.ts
8
+ /**
9
+ * Normalize SortSpec to MongoDB's strict format (1 | -1)
10
+ * Converts 'asc' -> 1, 'desc' -> -1
11
+ */
12
+ function normalizeSortSpec(sortSpec) {
13
+ const normalized = {};
14
+ for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
15
+ else if (order === "desc") normalized[field] = -1;
16
+ else normalized[field] = order;
17
+ return normalized;
18
+ }
19
+ /**
20
+ * Fluent builder for MongoDB aggregation pipelines
21
+ * Optimized for complex queries at scale
22
+ */
23
+ var AggregationBuilder = class AggregationBuilder {
24
+ pipeline = [];
25
+ _diskUse = false;
26
+ /**
27
+ * Get the current pipeline
28
+ */
29
+ get() {
30
+ return [...this.pipeline];
31
+ }
32
+ /**
33
+ * Build and return the final pipeline
34
+ */
35
+ build() {
36
+ return this.get();
37
+ }
38
+ /**
39
+ * Build pipeline with execution options (allowDiskUse, etc.)
40
+ */
41
+ plan() {
42
+ return {
43
+ pipeline: this.get(),
44
+ allowDiskUse: this._diskUse
45
+ };
46
+ }
47
+ /**
48
+ * Build and execute the pipeline against a model
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * const results = await new AggregationBuilder()
53
+ * .match({ status: 'active' })
54
+ * .allowDiskUse()
55
+ * .exec(MyModel);
56
+ * ```
57
+ */
58
+ async exec(model, session) {
59
+ const agg = model.aggregate(this.build());
60
+ if (this._diskUse) agg.allowDiskUse(true);
61
+ if (session) agg.session(session);
62
+ return agg.exec();
63
+ }
64
+ /**
65
+ * Reset the pipeline
66
+ */
67
+ reset() {
68
+ this.pipeline = [];
69
+ this._diskUse = false;
70
+ return this;
71
+ }
72
+ /**
73
+ * Add a raw pipeline stage
74
+ */
75
+ addStage(stage) {
76
+ this.pipeline.push(stage);
77
+ return this;
78
+ }
79
+ /**
80
+ * Add multiple raw pipeline stages
81
+ */
82
+ addStages(stages) {
83
+ this.pipeline.push(...stages);
84
+ return this;
85
+ }
86
+ /**
87
+ * $match - Filter documents
88
+ * IMPORTANT: Place $match as early as possible for performance
89
+ */
90
+ match(query) {
91
+ this.pipeline.push({ $match: query });
92
+ return this;
93
+ }
94
+ /**
95
+ * $project - Include/exclude fields or compute new fields
96
+ */
97
+ project(projection) {
98
+ this.pipeline.push({ $project: projection });
99
+ return this;
100
+ }
101
+ /**
102
+ * $group - Group documents and compute aggregations
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * .group({
107
+ * _id: '$department',
108
+ * count: { $sum: 1 },
109
+ * avgSalary: { $avg: '$salary' }
110
+ * })
111
+ * ```
112
+ */
113
+ group(groupSpec) {
114
+ this.pipeline.push({ $group: groupSpec });
115
+ return this;
116
+ }
117
+ /**
118
+ * $sort - Sort documents
119
+ */
120
+ sort(sortSpec) {
121
+ if (typeof sortSpec === "string") {
122
+ const order = sortSpec.startsWith("-") ? -1 : 1;
123
+ const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
124
+ this.pipeline.push({ $sort: { [field]: order } });
125
+ } else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
126
+ return this;
127
+ }
128
+ /**
129
+ * $limit - Limit number of documents
130
+ */
131
+ limit(count) {
132
+ this.pipeline.push({ $limit: count });
133
+ return this;
134
+ }
135
+ /**
136
+ * $skip - Skip documents
137
+ */
138
+ skip(count) {
139
+ this.pipeline.push({ $skip: count });
140
+ return this;
141
+ }
142
+ /**
143
+ * $unwind - Deconstruct array field
144
+ */
145
+ unwind(path, preserveNullAndEmptyArrays = false) {
146
+ this.pipeline.push({ $unwind: {
147
+ path: path.startsWith("$") ? path : `$${path}`,
148
+ preserveNullAndEmptyArrays
149
+ } });
150
+ return this;
151
+ }
152
+ /**
153
+ * $addFields - Add new fields or replace existing fields
154
+ */
155
+ addFields(fields) {
156
+ this.pipeline.push({ $addFields: fields });
157
+ return this;
158
+ }
159
+ /**
160
+ * $set - Alias for $addFields
161
+ */
162
+ set(fields) {
163
+ return this.addFields(fields);
164
+ }
165
+ /**
166
+ * $unset - Remove fields
167
+ */
168
+ unset(fields) {
169
+ this.pipeline.push({ $unset: fields });
170
+ return this;
171
+ }
172
+ /**
173
+ * $replaceRoot - Replace the root document
174
+ */
175
+ replaceRoot(newRoot) {
176
+ this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
177
+ return this;
178
+ }
179
+ /**
180
+ * $lookup - Join with another collection (simple form)
181
+ *
182
+ * @param from - Collection to join with
183
+ * @param localField - Field from source collection
184
+ * @param foreignField - Field from target collection
185
+ * @param as - Output field name
186
+ * @param single - Unwrap array to single object
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * // Join employees with departments by slug
191
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
192
+ * ```
193
+ */
194
+ lookup(from, localField, foreignField, as, single) {
195
+ const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
196
+ this.pipeline.push(...stages);
197
+ return this;
198
+ }
199
+ /**
200
+ * $lookup - Join with another collection (advanced form with pipeline)
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * .lookupWithPipeline({
205
+ * from: 'products',
206
+ * localField: 'productIds',
207
+ * foreignField: 'sku',
208
+ * as: 'products',
209
+ * pipeline: [
210
+ * { $match: { status: 'active' } },
211
+ * { $project: { name: 1, price: 1 } }
212
+ * ]
213
+ * })
214
+ * ```
215
+ */
216
+ lookupWithPipeline(options) {
217
+ const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
218
+ if (options.as) builder.as(options.as);
219
+ if (options.single) builder.single(options.single);
220
+ if (options.pipeline) builder.pipeline(options.pipeline);
221
+ if (options.let) builder.let(options.let);
222
+ this.pipeline.push(...builder.build());
223
+ return this;
224
+ }
225
+ /**
226
+ * Multiple lookups at once
227
+ *
228
+ * @example
229
+ * ```typescript
230
+ * .multiLookup([
231
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
232
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
233
+ * ])
234
+ * ```
235
+ */
236
+ multiLookup(lookups) {
237
+ const stages = LookupBuilder.multiple(lookups);
238
+ this.pipeline.push(...stages);
239
+ return this;
240
+ }
241
+ /**
242
+ * $facet - Process multiple aggregation pipelines in a single stage
243
+ * Useful for computing multiple aggregations in parallel
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * .facet({
248
+ * totalCount: [{ $count: 'count' }],
249
+ * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
250
+ * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
251
+ * })
252
+ * ```
253
+ */
254
+ facet(facets) {
255
+ this.pipeline.push({ $facet: facets });
256
+ return this;
257
+ }
258
+ /**
259
+ * $bucket - Categorize documents into buckets
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * .bucket({
264
+ * groupBy: '$price',
265
+ * boundaries: [0, 50, 100, 200],
266
+ * default: 'Other',
267
+ * output: {
268
+ * count: { $sum: 1 },
269
+ * products: { $push: '$name' }
270
+ * }
271
+ * })
272
+ * ```
273
+ */
274
+ bucket(options) {
275
+ this.pipeline.push({ $bucket: options });
276
+ return this;
277
+ }
278
+ /**
279
+ * $bucketAuto - Automatically determine bucket boundaries
280
+ */
281
+ bucketAuto(options) {
282
+ this.pipeline.push({ $bucketAuto: options });
283
+ return this;
284
+ }
285
+ /**
286
+ * $setWindowFields - Perform window functions (MongoDB 5.0+)
287
+ * Useful for rankings, running totals, moving averages
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * .setWindowFields({
292
+ * partitionBy: '$department',
293
+ * sortBy: { salary: -1 },
294
+ * output: {
295
+ * rank: { $rank: {} },
296
+ * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
297
+ * }
298
+ * })
299
+ * ```
300
+ */
301
+ setWindowFields(options) {
302
+ const normalizedOptions = {
303
+ ...options,
304
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
305
+ };
306
+ this.pipeline.push({ $setWindowFields: normalizedOptions });
307
+ return this;
308
+ }
309
+ /**
310
+ * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
311
+ *
312
+ * @example
313
+ * ```typescript
314
+ * .unionWith({
315
+ * coll: 'archivedOrders',
316
+ * pipeline: [{ $match: { year: 2024 } }]
317
+ * })
318
+ * ```
319
+ */
320
+ unionWith(options) {
321
+ this.pipeline.push({ $unionWith: options });
322
+ return this;
323
+ }
324
+ /**
325
+ * $densify - Fill gaps in data (MongoDB 5.1+)
326
+ * Useful for time series data with missing points
327
+ */
328
+ densify(options) {
329
+ this.pipeline.push({ $densify: options });
330
+ return this;
331
+ }
332
+ /**
333
+ * $fill - Fill null or missing field values (MongoDB 5.3+)
334
+ */
335
+ fill(options) {
336
+ const normalizedOptions = {
337
+ ...options,
338
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
339
+ };
340
+ this.pipeline.push({ $fill: normalizedOptions });
341
+ return this;
342
+ }
343
+ /**
344
+ * Enable allowDiskUse for large aggregations that exceed 100MB memory limit
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * const results = await new AggregationBuilder()
349
+ * .match({ status: 'active' })
350
+ * .group({ _id: '$category', total: { $sum: '$amount' } })
351
+ * .allowDiskUse()
352
+ * .exec(Model);
353
+ * ```
354
+ */
355
+ allowDiskUse(enable = true) {
356
+ this._diskUse = enable;
357
+ return this;
358
+ }
359
+ /**
360
+ * Paginate - Add skip and limit for offset-based pagination
361
+ */
362
+ paginate(page, limit) {
363
+ const skip = (page - 1) * limit;
364
+ return this.skip(skip).limit(limit);
365
+ }
366
+ /**
367
+ * Count total documents (useful with $facet for pagination metadata)
368
+ */
369
+ count(outputField = "count") {
370
+ this.pipeline.push({ $count: outputField });
371
+ return this;
372
+ }
373
+ /**
374
+ * Sample - Randomly select N documents
375
+ */
376
+ sample(size) {
377
+ this.pipeline.push({ $sample: { size } });
378
+ return this;
379
+ }
380
+ /**
381
+ * Out - Write results to a collection
382
+ */
383
+ out(collection) {
384
+ this.pipeline.push({ $out: collection });
385
+ return this;
386
+ }
387
+ /**
388
+ * Merge - Merge results into a collection
389
+ */
390
+ merge(options) {
391
+ this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
392
+ return this;
393
+ }
394
+ /**
395
+ * GeoNear - Perform geospatial queries
396
+ */
397
+ geoNear(options) {
398
+ this.pipeline.push({ $geoNear: options });
399
+ return this;
400
+ }
401
+ /**
402
+ * GraphLookup - Perform recursive search (graph traversal)
403
+ */
404
+ graphLookup(options) {
405
+ this.pipeline.push({ $graphLookup: options });
406
+ return this;
407
+ }
408
+ /**
409
+ * $search - Atlas Search full-text search (Atlas only)
410
+ *
411
+ * @example
412
+ * ```typescript
413
+ * .search({
414
+ * index: 'default',
415
+ * text: {
416
+ * query: 'laptop computer',
417
+ * path: ['title', 'description'],
418
+ * fuzzy: { maxEdits: 2 }
419
+ * }
420
+ * })
421
+ * ```
422
+ */
423
+ search(options) {
424
+ this.pipeline.push({ $search: options });
425
+ return this;
426
+ }
427
+ /**
428
+ * $searchMeta - Get Atlas Search metadata (Atlas only)
429
+ */
430
+ searchMeta(options) {
431
+ this.pipeline.push({ $searchMeta: options });
432
+ return this;
433
+ }
434
+ /**
435
+ * $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
436
+ *
437
+ * Requires an Atlas Vector Search index on the target field.
438
+ * Must be the first stage in the pipeline.
439
+ *
440
+ * @example
441
+ * ```typescript
442
+ * const results = await new AggregationBuilder()
443
+ * .vectorSearch({
444
+ * index: 'vector_index',
445
+ * path: 'embedding',
446
+ * queryVector: await getEmbedding('running shoes'),
447
+ * limit: 10,
448
+ * numCandidates: 100,
449
+ * filter: { category: 'footwear' }
450
+ * })
451
+ * .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
452
+ * .exec(ProductModel);
453
+ * ```
454
+ */
455
+ vectorSearch(options) {
456
+ if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
457
+ const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
458
+ const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
459
+ this.pipeline.push({ $vectorSearch: {
460
+ index: options.index,
461
+ path: options.path,
462
+ queryVector: options.queryVector,
463
+ numCandidates,
464
+ limit: options.limit,
465
+ ...options.filter && { filter: options.filter },
466
+ ...options.exact && { exact: options.exact }
467
+ } });
468
+ return this;
469
+ }
470
+ /**
471
+ * Add vectorSearchScore as a field after $vectorSearch
472
+ * Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
473
+ */
474
+ withVectorScore(fieldName = "score") {
475
+ return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
476
+ }
477
+ /**
478
+ * Create a builder from an existing pipeline
479
+ */
480
+ static from(pipeline) {
481
+ const builder = new AggregationBuilder();
482
+ builder.pipeline = [...pipeline];
483
+ return builder;
484
+ }
485
+ /**
486
+ * Create a builder with initial match stage
487
+ */
488
+ static startWith(query) {
489
+ return new AggregationBuilder().match(query);
490
+ }
491
+ };
492
+
493
+ //#endregion
494
+ //#region src/Repository.ts
495
+ /**
496
+ * Repository Pattern - Data Access Layer
497
+ *
498
+ * Event-driven, plugin-based abstraction for MongoDB operations
499
+ * Inspired by Meta & Stripe's repository patterns
500
+ *
501
+ * @example
502
+ * ```typescript
503
+ * const userRepo = new Repository(UserModel, [
504
+ * timestampPlugin(),
505
+ * softDeletePlugin(),
506
+ * ]);
507
+ *
508
+ * // Create
509
+ * const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
510
+ *
511
+ * // Read with pagination
512
+ * const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
513
+ *
514
+ * // Update
515
+ * const updated = await userRepo.update(user._id, { name: 'John Doe' });
516
+ *
517
+ * // Delete
518
+ * await userRepo.delete(user._id);
519
+ * ```
520
+ */
521
+ /**
522
+ * Plugin phase priorities (lower = runs first)
523
+ * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
524
+ * to ensure filters are injected before cache keys are computed.
525
+ */
526
+ const HOOK_PRIORITY = {
527
+ POLICY: 100,
528
+ CACHE: 200,
529
+ OBSERVABILITY: 300,
530
+ DEFAULT: 500
531
+ };
532
+ /**
533
+ * Production-grade repository for MongoDB
534
+ * Event-driven, plugin-based, with smart pagination
535
+ */
536
+ var Repository = class {
537
+ Model;
538
+ model;
539
+ _hooks;
540
+ _pagination;
541
+ _hookMode;
542
+ _hasTextIndex = null;
543
+ constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
544
+ this.Model = Model;
545
+ this.model = Model.modelName;
546
+ this._hooks = /* @__PURE__ */ new Map();
547
+ this._pagination = new PaginationEngine(Model, paginationConfig);
548
+ this._hookMode = options.hooks ?? "async";
549
+ plugins.forEach((plugin) => this.use(plugin));
550
+ }
551
+ /**
552
+ * Register a plugin
553
+ */
554
+ use(plugin) {
555
+ if (typeof plugin === "function") plugin(this);
556
+ else if (plugin && typeof plugin.apply === "function") plugin.apply(this);
557
+ return this;
558
+ }
559
+ /**
560
+ * Register event listener with optional priority for phase ordering.
561
+ *
562
+ * @param event - Event name (e.g. 'before:getAll')
563
+ * @param listener - Hook function
564
+ * @param options - Optional { priority } — use HOOK_PRIORITY constants.
565
+ * Lower priority numbers run first.
566
+ * Default: HOOK_PRIORITY.DEFAULT (500)
567
+ */
568
+ on(event, listener, options) {
569
+ if (!this._hooks.has(event)) this._hooks.set(event, []);
570
+ const hooks = this._hooks.get(event);
571
+ const priority = options?.priority ?? HOOK_PRIORITY.DEFAULT;
572
+ hooks.push({
573
+ listener,
574
+ priority
575
+ });
576
+ hooks.sort((a, b) => a.priority - b.priority);
577
+ return this;
578
+ }
579
+ /**
580
+ * Remove a specific event listener
581
+ */
582
+ off(event, listener) {
583
+ const hooks = this._hooks.get(event);
584
+ if (hooks) {
585
+ const idx = hooks.findIndex((h) => h.listener === listener);
586
+ if (idx !== -1) hooks.splice(idx, 1);
587
+ }
588
+ return this;
589
+ }
590
+ /**
591
+ * Remove all listeners for an event, or all listeners entirely
592
+ */
593
+ removeAllListeners(event) {
594
+ if (event) this._hooks.delete(event);
595
+ else this._hooks.clear();
596
+ return this;
597
+ }
598
+ /**
599
+ * Emit event (sync - for backwards compatibility)
600
+ */
601
+ emit(event, data) {
602
+ const hooks = this._hooks.get(event) || [];
603
+ for (const { listener } of hooks) try {
604
+ const result = listener(data);
605
+ if (result && typeof result.then === "function") result.catch((error) => {
606
+ if (event === "error:hook") return;
607
+ const err = error instanceof Error ? error : new Error(String(error));
608
+ this.emit("error:hook", {
609
+ event,
610
+ error: err
611
+ });
612
+ });
613
+ } catch (error) {
614
+ if (event === "error:hook") continue;
615
+ const err = error instanceof Error ? error : new Error(String(error));
616
+ this.emit("error:hook", {
617
+ event,
618
+ error: err
619
+ });
620
+ }
621
+ }
622
+ /**
623
+ * Emit event and await all async handlers (sorted by priority)
624
+ */
625
+ async emitAsync(event, data) {
626
+ const hooks = this._hooks.get(event) || [];
627
+ for (const { listener } of hooks) await listener(data);
628
+ }
629
+ async _emitHook(event, data) {
630
+ if (this._hookMode === "async") {
631
+ await this.emitAsync(event, data);
632
+ return;
633
+ }
634
+ this.emit(event, data);
635
+ }
636
+ async _emitErrorHook(event, data) {
637
+ try {
638
+ await this._emitHook(event, data);
639
+ } catch (hookError) {
640
+ warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
641
+ }
642
+ }
643
+ /**
644
+ * Create single document
645
+ */
646
+ async create(data, options = {}) {
647
+ const context = await this._buildContext("create", {
648
+ data,
649
+ ...options
650
+ });
651
+ try {
652
+ const result = await create(this.Model, context.data || data, options);
653
+ await this._emitHook("after:create", {
654
+ context,
655
+ result
656
+ });
657
+ return result;
658
+ } catch (error) {
659
+ await this._emitErrorHook("error:create", {
660
+ context,
661
+ error
662
+ });
663
+ throw this._handleError(error);
664
+ }
665
+ }
666
+ /**
667
+ * Create multiple documents
668
+ */
669
+ async createMany(dataArray, options = {}) {
670
+ const context = await this._buildContext("createMany", {
671
+ dataArray,
672
+ ...options
673
+ });
674
+ try {
675
+ const result = await createMany(this.Model, context.dataArray || dataArray, options);
676
+ await this._emitHook("after:createMany", {
677
+ context,
678
+ result
679
+ });
680
+ return result;
681
+ } catch (error) {
682
+ await this._emitErrorHook("error:createMany", {
683
+ context,
684
+ error
685
+ });
686
+ throw this._handleError(error);
687
+ }
688
+ }
689
+ /**
690
+ * Get document by ID
691
+ */
692
+ async getById(id, options = {}) {
693
+ const populateSpec = options.populateOptions || options.populate;
694
+ const context = await this._buildContext("getById", {
695
+ id,
696
+ ...options,
697
+ populate: populateSpec
698
+ });
699
+ if (context._cacheHit) {
700
+ const cachedResult = context._cachedResult;
701
+ await this._emitHook("after:getById", {
702
+ context,
703
+ result: cachedResult,
704
+ fromCache: true
705
+ });
706
+ return cachedResult;
707
+ }
708
+ try {
709
+ const result = await getById(this.Model, id, context);
710
+ await this._emitHook("after:getById", {
711
+ context,
712
+ result
713
+ });
714
+ return result;
715
+ } catch (error) {
716
+ await this._emitErrorHook("error:getById", {
717
+ context,
718
+ error
719
+ });
720
+ throw this._handleError(error);
721
+ }
722
+ }
723
+ /**
724
+ * Get single document by query
725
+ */
726
+ async getByQuery(query, options = {}) {
727
+ const populateSpec = options.populateOptions || options.populate;
728
+ const context = await this._buildContext("getByQuery", {
729
+ query,
730
+ ...options,
731
+ populate: populateSpec
732
+ });
733
+ if (context._cacheHit) {
734
+ const cachedResult = context._cachedResult;
735
+ await this._emitHook("after:getByQuery", {
736
+ context,
737
+ result: cachedResult,
738
+ fromCache: true
739
+ });
740
+ return cachedResult;
741
+ }
742
+ const finalQuery = context.query || query;
743
+ try {
744
+ const result = await getByQuery(this.Model, finalQuery, context);
745
+ await this._emitHook("after:getByQuery", {
746
+ context,
747
+ result
748
+ });
749
+ return result;
750
+ } catch (error) {
751
+ await this._emitErrorHook("error:getByQuery", {
752
+ context,
753
+ error
754
+ });
755
+ throw this._handleError(error);
756
+ }
757
+ }
758
+ /**
759
+ * Unified pagination - auto-detects offset vs keyset based on params
760
+ *
761
+ * Auto-detection logic:
762
+ * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
763
+ * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
764
+ * - Else → defaults to offset pagination with page=1
765
+ *
766
+ * @example
767
+ * // Offset pagination (page-based)
768
+ * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
769
+ * await repo.getAll({ pagination: { page: 2, limit: 20 } });
770
+ *
771
+ * // Keyset pagination (cursor-based)
772
+ * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
773
+ * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
774
+ *
775
+ * // Simple query (defaults to page 1)
776
+ * await repo.getAll({ filters: { status: 'active' } });
777
+ *
778
+ * // Skip cache for fresh data
779
+ * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
780
+ */
781
+ async getAll(params = {}, options = {}) {
782
+ const normalizedParams = {
783
+ ...params,
784
+ page: params.page ?? params.pagination?.page,
785
+ limit: params.limit ?? params.pagination?.limit
786
+ };
787
+ const context = await this._buildContext("getAll", {
788
+ ...normalizedParams,
789
+ ...options
790
+ });
791
+ if (context._cacheHit) {
792
+ const cachedResult = context._cachedResult;
793
+ await this._emitHook("after:getAll", {
794
+ context,
795
+ result: cachedResult,
796
+ fromCache: true
797
+ });
798
+ return cachedResult;
799
+ }
800
+ const filters = context.filters ?? params.filters ?? {};
801
+ const search = context.search ?? params.search;
802
+ const sort = context.sort ?? params.sort ?? "-createdAt";
803
+ const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
804
+ const page = context.page ?? params.pagination?.page ?? params.page;
805
+ const after = context.after ?? params.cursor ?? params.after;
806
+ const mode = context.mode ?? params.mode;
807
+ let useKeyset = false;
808
+ if (mode) useKeyset = mode === "keyset";
809
+ else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
810
+ let query = { ...filters };
811
+ if (search) {
812
+ if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
813
+ if (this._hasTextIndex) query.$text = { $search: search };
814
+ else throw createError(400, `No text index found for ${this.model}. Cannot perform text search.`);
815
+ }
816
+ const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
817
+ const paginationOptions = {
818
+ filters: query,
819
+ sort: this._parseSort(sort),
820
+ limit,
821
+ populate: this._parsePopulate(populateSpec),
822
+ select: context.select || options.select,
823
+ lean: context.lean ?? options.lean ?? true,
824
+ session: options.session,
825
+ hint: context.hint ?? params.hint,
826
+ maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
827
+ readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
828
+ };
829
+ try {
830
+ let result;
831
+ if (useKeyset) result = await this._pagination.stream({
832
+ ...paginationOptions,
833
+ sort: paginationOptions.sort,
834
+ after
835
+ });
836
+ else result = await this._pagination.paginate({
837
+ ...paginationOptions,
838
+ page: page || 1,
839
+ countStrategy: context.countStrategy ?? params.countStrategy
840
+ });
841
+ await this._emitHook("after:getAll", {
842
+ context,
843
+ result
844
+ });
845
+ return result;
846
+ } catch (error) {
847
+ await this._emitErrorHook("error:getAll", {
848
+ context,
849
+ error
850
+ });
851
+ throw this._handleError(error);
852
+ }
853
+ }
854
+ /**
855
+ * Get or create document
856
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
857
+ */
858
+ async getOrCreate(query, createData, options = {}) {
859
+ const context = await this._buildContext("getOrCreate", {
860
+ query,
861
+ data: createData,
862
+ ...options
863
+ });
864
+ try {
865
+ const finalQuery = context.query || query;
866
+ const finalData = context.data || createData;
867
+ const result = await getOrCreate(this.Model, finalQuery, finalData, options);
868
+ await this._emitHook("after:getOrCreate", {
869
+ context,
870
+ result
871
+ });
872
+ return result;
873
+ } catch (error) {
874
+ await this._emitErrorHook("error:getOrCreate", {
875
+ context,
876
+ error
877
+ });
878
+ throw this._handleError(error);
879
+ }
880
+ }
881
+ /**
882
+ * Count documents
883
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
884
+ */
885
+ async count(query = {}, options = {}) {
886
+ const context = await this._buildContext("count", {
887
+ query,
888
+ ...options
889
+ });
890
+ try {
891
+ const finalQuery = context.query || query;
892
+ const result = await count(this.Model, finalQuery, options);
893
+ await this._emitHook("after:count", {
894
+ context,
895
+ result
896
+ });
897
+ return result;
898
+ } catch (error) {
899
+ await this._emitErrorHook("error:count", {
900
+ context,
901
+ error
902
+ });
903
+ throw this._handleError(error);
904
+ }
905
+ }
906
+ /**
907
+ * Check if document exists
908
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
909
+ */
910
+ async exists(query, options = {}) {
911
+ const context = await this._buildContext("exists", {
912
+ query,
913
+ ...options
914
+ });
915
+ try {
916
+ const finalQuery = context.query || query;
917
+ const result = await exists(this.Model, finalQuery, options);
918
+ await this._emitHook("after:exists", {
919
+ context,
920
+ result
921
+ });
922
+ return result;
923
+ } catch (error) {
924
+ await this._emitErrorHook("error:exists", {
925
+ context,
926
+ error
927
+ });
928
+ throw this._handleError(error);
929
+ }
930
+ }
931
+ /**
932
+ * Update document by ID
933
+ */
934
+ async update(id, data, options = {}) {
935
+ const context = await this._buildContext("update", {
936
+ id,
937
+ data,
938
+ ...options
939
+ });
940
+ try {
941
+ const result = await update(this.Model, id, context.data || data, context);
942
+ await this._emitHook("after:update", {
943
+ context,
944
+ result
945
+ });
946
+ return result;
947
+ } catch (error) {
948
+ await this._emitErrorHook("error:update", {
949
+ context,
950
+ error
951
+ });
952
+ throw this._handleError(error);
953
+ }
954
+ }
955
+ /**
956
+ * Delete document by ID
957
+ */
958
+ async delete(id, options = {}) {
959
+ const context = await this._buildContext("delete", {
960
+ id,
961
+ ...options
962
+ });
963
+ try {
964
+ if (context.softDeleted) {
965
+ const result = {
966
+ success: true,
967
+ message: "Soft deleted successfully",
968
+ id: String(id),
969
+ soft: true
970
+ };
971
+ await this._emitHook("after:delete", {
972
+ context,
973
+ result
974
+ });
975
+ return result;
976
+ }
977
+ const result = await deleteById(this.Model, id, {
978
+ session: options.session,
979
+ query: context.query
980
+ });
981
+ await this._emitHook("after:delete", {
982
+ context,
983
+ result
984
+ });
985
+ return result;
986
+ } catch (error) {
987
+ await this._emitErrorHook("error:delete", {
988
+ context,
989
+ error
990
+ });
991
+ throw this._handleError(error);
992
+ }
993
+ }
994
+ /**
995
+ * Execute aggregation pipeline
996
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
997
+ *
998
+ * @param pipeline - Aggregation pipeline stages
999
+ * @param options - Aggregation options including governance controls
1000
+ */
1001
+ async aggregate(pipeline, options = {}) {
1002
+ const context = await this._buildContext("aggregate", {
1003
+ pipeline,
1004
+ ...options
1005
+ });
1006
+ const maxStages = options.maxPipelineStages;
1007
+ if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
1008
+ try {
1009
+ const finalPipeline = [...pipeline];
1010
+ if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
1011
+ const aggregation = this.Model.aggregate(finalPipeline);
1012
+ if (options.session) aggregation.session(options.session);
1013
+ if (options.allowDiskUse) aggregation.allowDiskUse(true);
1014
+ if (options.readPreference) aggregation.read(options.readPreference);
1015
+ if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
1016
+ if (options.comment) aggregation.option({ comment: options.comment });
1017
+ if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
1018
+ if (options.collation) aggregation.collation(options.collation);
1019
+ const result = await aggregation.exec();
1020
+ await this._emitHook("after:aggregate", {
1021
+ context,
1022
+ result
1023
+ });
1024
+ return result;
1025
+ } catch (error) {
1026
+ await this._emitErrorHook("error:aggregate", {
1027
+ context,
1028
+ error
1029
+ });
1030
+ throw this._handleError(error);
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Aggregate pipeline with pagination
1035
+ * Best for: Complex queries, grouping, joins
1036
+ *
1037
+ * Policy hooks (multi-tenant, soft-delete) inject context.filters which are
1038
+ * prepended as a $match stage to the pipeline, ensuring tenant isolation.
1039
+ */
1040
+ async aggregatePaginate(options = {}) {
1041
+ const context = await this._buildContext("aggregatePaginate", options);
1042
+ const finalPipeline = [...context.pipeline || options.pipeline || []];
1043
+ if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
1044
+ const aggOptions = {
1045
+ ...context,
1046
+ pipeline: finalPipeline
1047
+ };
1048
+ try {
1049
+ const result = await this._pagination.aggregatePaginate(aggOptions);
1050
+ await this._emitHook("after:aggregatePaginate", {
1051
+ context,
1052
+ result
1053
+ });
1054
+ return result;
1055
+ } catch (error) {
1056
+ await this._emitErrorHook("error:aggregatePaginate", {
1057
+ context,
1058
+ error
1059
+ });
1060
+ throw this._handleError(error);
1061
+ }
1062
+ }
1063
+ /**
1064
+ * Get distinct values
1065
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1066
+ */
1067
+ async distinct(field, query = {}, options = {}) {
1068
+ const context = await this._buildContext("distinct", {
1069
+ query,
1070
+ ...options
1071
+ });
1072
+ try {
1073
+ const finalQuery = context.query || query;
1074
+ const readPreference = context.readPreference ?? options.readPreference;
1075
+ const result = await distinct(this.Model, field, finalQuery, {
1076
+ session: options.session,
1077
+ readPreference
1078
+ });
1079
+ await this._emitHook("after:distinct", {
1080
+ context,
1081
+ result
1082
+ });
1083
+ return result;
1084
+ } catch (error) {
1085
+ await this._emitErrorHook("error:distinct", {
1086
+ context,
1087
+ error
1088
+ });
1089
+ throw this._handleError(error);
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Query with custom field lookups ($lookup)
1094
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
1095
+ *
1096
+ * @example
1097
+ * ```typescript
1098
+ * // Join employees with departments using slug instead of ObjectId
1099
+ * const employees = await employeeRepo.lookupPopulate({
1100
+ * filters: { status: 'active' },
1101
+ * lookups: [
1102
+ * {
1103
+ * from: 'departments',
1104
+ * localField: 'departmentSlug',
1105
+ * foreignField: 'slug',
1106
+ * as: 'department',
1107
+ * single: true
1108
+ * }
1109
+ * ],
1110
+ * sort: '-createdAt',
1111
+ * page: 1,
1112
+ * limit: 50
1113
+ * });
1114
+ * ```
1115
+ */
1116
+ async lookupPopulate(options) {
1117
+ const context = await this._buildContext("lookupPopulate", options);
1118
+ try {
1119
+ const builder = new AggregationBuilder();
1120
+ const filters = context.filters ?? options.filters;
1121
+ if (filters && Object.keys(filters).length > 0) builder.match(filters);
1122
+ builder.multiLookup(options.lookups);
1123
+ const sort = context.sort ?? options.sort;
1124
+ if (sort) builder.sort(this._parseSort(sort));
1125
+ const page = context.page ?? options.page ?? 1;
1126
+ const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
1127
+ const skip = (page - 1) * limit;
1128
+ const SAFE_LIMIT = 1e3;
1129
+ const SAFE_MAX_OFFSET = 1e4;
1130
+ if (limit > SAFE_LIMIT) warn(`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`);
1131
+ if (skip > SAFE_MAX_OFFSET) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`);
1132
+ const dataStages = [{ $skip: skip }, { $limit: limit }];
1133
+ const selectSpec = context.select ?? options.select;
1134
+ if (selectSpec) {
1135
+ let projection;
1136
+ if (typeof selectSpec === "string") {
1137
+ projection = {};
1138
+ const fields = selectSpec.split(",").map((f) => f.trim());
1139
+ for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1140
+ else projection[field] = 1;
1141
+ } else if (Array.isArray(selectSpec)) {
1142
+ projection = {};
1143
+ for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1144
+ else projection[field] = 1;
1145
+ } else projection = selectSpec;
1146
+ dataStages.push({ $project: projection });
1147
+ }
1148
+ builder.facet({
1149
+ metadata: [{ $count: "total" }],
1150
+ data: dataStages
1151
+ });
1152
+ const pipeline = builder.build();
1153
+ const aggregation = this.Model.aggregate(pipeline).session(options.session || null);
1154
+ const readPref = context.readPreference ?? options.readPreference;
1155
+ if (readPref) aggregation.read(readPref);
1156
+ const result = (await aggregation)[0] || {
1157
+ metadata: [],
1158
+ data: []
1159
+ };
1160
+ const total = result.metadata[0]?.total || 0;
1161
+ const data = result.data || [];
1162
+ await this._emitHook("after:lookupPopulate", {
1163
+ context,
1164
+ result: data
1165
+ });
1166
+ return {
1167
+ data,
1168
+ total,
1169
+ page,
1170
+ limit
1171
+ };
1172
+ } catch (error) {
1173
+ await this._emitErrorHook("error:lookupPopulate", {
1174
+ context,
1175
+ error
1176
+ });
1177
+ throw this._handleError(error);
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Create an aggregation builder for this model
1182
+ * Useful for building complex custom aggregations
1183
+ *
1184
+ * @example
1185
+ * ```typescript
1186
+ * const pipeline = repo.buildAggregation()
1187
+ * .match({ status: 'active' })
1188
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1189
+ * .group({ _id: '$department', count: { $sum: 1 } })
1190
+ * .sort({ count: -1 })
1191
+ * .build();
1192
+ *
1193
+ * const results = await repo.Model.aggregate(pipeline);
1194
+ * ```
1195
+ */
1196
+ buildAggregation() {
1197
+ return new AggregationBuilder();
1198
+ }
1199
+ /**
1200
+ * Create a lookup builder
1201
+ * Useful for building $lookup stages independently
1202
+ *
1203
+ * @example
1204
+ * ```typescript
1205
+ * const lookupStages = repo.buildLookup('departments')
1206
+ * .localField('deptSlug')
1207
+ * .foreignField('slug')
1208
+ * .as('department')
1209
+ * .single()
1210
+ * .build();
1211
+ *
1212
+ * const pipeline = [
1213
+ * { $match: { status: 'active' } },
1214
+ * ...lookupStages
1215
+ * ];
1216
+ * ```
1217
+ */
1218
+ buildLookup(from) {
1219
+ return new LookupBuilder(from);
1220
+ }
1221
+ /**
1222
+ * Execute callback within a transaction with automatic retry on transient failures.
1223
+ *
1224
+ * Uses the MongoDB driver's `session.withTransaction()` which automatically retries
1225
+ * on `TransientTransactionError` and `UnknownTransactionCommitResult`.
1226
+ *
1227
+ * The callback always receives a `ClientSession`. When `allowFallback` is true
1228
+ * and the MongoDB deployment doesn't support transactions (e.g., standalone),
1229
+ * the callback runs without a transaction on the same session.
1230
+ *
1231
+ * @param callback - Receives a `ClientSession` to pass to repository operations
1232
+ * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
1233
+ * @param options.onFallback - Called when falling back to non-transactional execution
1234
+ * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
1235
+ *
1236
+ * @example
1237
+ * ```typescript
1238
+ * const result = await repo.withTransaction(async (session) => {
1239
+ * const order = await repo.create({ total: 100 }, { session });
1240
+ * await paymentRepo.create({ orderId: order._id }, { session });
1241
+ * return order;
1242
+ * });
1243
+ *
1244
+ * // With fallback for standalone/dev environments
1245
+ * await repo.withTransaction(callback, {
1246
+ * allowFallback: true,
1247
+ * onFallback: (err) => logger.warn('Running without transaction', err),
1248
+ * });
1249
+ * ```
1250
+ */
1251
+ async withTransaction(callback, options = {}) {
1252
+ const session = await this.Model.db.startSession();
1253
+ try {
1254
+ return await session.withTransaction(() => callback(session), options.transactionOptions);
1255
+ } catch (error) {
1256
+ const err = error;
1257
+ if (options.allowFallback && this._isTransactionUnsupported(err)) {
1258
+ options.onFallback?.(err);
1259
+ return await callback(session);
1260
+ }
1261
+ throw err;
1262
+ } finally {
1263
+ await session.endSession();
1264
+ }
1265
+ }
1266
+ _isTransactionUnsupported(error) {
1267
+ const code = error.code;
1268
+ if (code === 263 || code === 20) return true;
1269
+ const message = (error.message || "").toLowerCase();
1270
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
1271
+ }
1272
+ /**
1273
+ * Execute custom query with event emission
1274
+ */
1275
+ async _executeQuery(buildQuery) {
1276
+ const operation = buildQuery.name || "custom";
1277
+ const context = await this._buildContext(operation, {});
1278
+ try {
1279
+ const result = await buildQuery(this.Model);
1280
+ await this._emitHook(`after:${operation}`, {
1281
+ context,
1282
+ result
1283
+ });
1284
+ return result;
1285
+ } catch (error) {
1286
+ await this._emitErrorHook(`error:${operation}`, {
1287
+ context,
1288
+ error
1289
+ });
1290
+ throw this._handleError(error);
1291
+ }
1292
+ }
1293
+ /**
1294
+ * Build operation context and run before hooks (sorted by priority).
1295
+ *
1296
+ * Hook execution order is deterministic:
1297
+ * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
1298
+ * 2. CACHE (200) — cache lookup (after policy filters are injected)
1299
+ * 3. OBSERVABILITY (300) — audit logging, metrics
1300
+ * 4. DEFAULT (500) — user-registered hooks
1301
+ */
1302
+ async _buildContext(operation, options) {
1303
+ const context = {
1304
+ operation,
1305
+ model: this.model,
1306
+ ...options
1307
+ };
1308
+ const event = `before:${operation}`;
1309
+ const hooks = this._hooks.get(event) || [];
1310
+ for (const { listener } of hooks) await listener(context);
1311
+ return context;
1312
+ }
1313
+ /**
1314
+ * Parse sort string or object
1315
+ */
1316
+ _parseSort(sort) {
1317
+ if (!sort) return { createdAt: -1 };
1318
+ if (typeof sort === "object") {
1319
+ if (Object.keys(sort).length === 0) return { createdAt: -1 };
1320
+ return sort;
1321
+ }
1322
+ const sortObj = {};
1323
+ const fields = sort.split(",").map((s) => s.trim());
1324
+ for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
1325
+ else sortObj[field] = 1;
1326
+ return sortObj;
1327
+ }
1328
+ /**
1329
+ * Parse populate specification
1330
+ */
1331
+ _parsePopulate(populate) {
1332
+ if (!populate) return [];
1333
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
1334
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
1335
+ return [populate];
1336
+ }
1337
+ /**
1338
+ * Handle errors with proper HTTP status codes
1339
+ */
1340
+ _handleError(error) {
1341
+ if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
1342
+ if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
1343
+ if (error.status && error.message) return error;
1344
+ return createError(500, error.message || "Internal Server Error");
1345
+ }
1346
+ };
1347
+
1348
+ //#endregion
6
1349
  //#region src/plugins/field-filter.plugin.ts
7
1350
  /**
8
1351
  * Field Filter Plugin
@@ -202,24 +1545,34 @@ function softDeletePlugin(options = {}) {
202
1545
  if (pathName === "_id" || pathName === deletedField) continue;
203
1546
  if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
204
1547
  }
205
- } catch {}
1548
+ } catch (err) {
1549
+ warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
1550
+ }
206
1551
  if (ttlDays !== void 0 && ttlDays > 0) {
207
1552
  const ttlSeconds = ttlDays * 24 * 60 * 60;
208
1553
  repo.Model.collection.createIndex({ [deletedField]: 1 }, {
209
1554
  expireAfterSeconds: ttlSeconds,
210
1555
  partialFilterExpression: { [deletedField]: { $type: "date" } }
211
1556
  }).catch((err) => {
212
- if (!err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1557
+ if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
213
1558
  });
214
1559
  }
215
1560
  repo.on("before:delete", async (context) => {
216
1561
  if (options.soft !== false) {
217
1562
  const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
218
1563
  if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
219
- await repo.Model.findByIdAndUpdate(context.id, updateData, { session: context.session });
1564
+ const deleteQuery = {
1565
+ _id: context.id,
1566
+ ...context.query || {}
1567
+ };
1568
+ if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
1569
+ const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
1570
+ error.status = 404;
1571
+ throw error;
1572
+ }
220
1573
  context.softDeleted = true;
221
1574
  }
222
- });
1575
+ }, { priority: HOOK_PRIORITY.POLICY });
223
1576
  repo.on("before:getAll", (context) => {
224
1577
  if (options.soft !== false) {
225
1578
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
@@ -228,7 +1581,7 @@ function softDeletePlugin(options = {}) {
228
1581
  ...deleteFilter
229
1582
  };
230
1583
  }
231
- });
1584
+ }, { priority: HOOK_PRIORITY.POLICY });
232
1585
  repo.on("before:getById", (context) => {
233
1586
  if (options.soft !== false) {
234
1587
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
@@ -237,7 +1590,7 @@ function softDeletePlugin(options = {}) {
237
1590
  ...deleteFilter
238
1591
  };
239
1592
  }
240
- });
1593
+ }, { priority: HOOK_PRIORITY.POLICY });
241
1594
  repo.on("before:getByQuery", (context) => {
242
1595
  if (options.soft !== false) {
243
1596
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
@@ -246,14 +1599,76 @@ function softDeletePlugin(options = {}) {
246
1599
  ...deleteFilter
247
1600
  };
248
1601
  }
249
- });
1602
+ }, { priority: HOOK_PRIORITY.POLICY });
1603
+ repo.on("before:count", (context) => {
1604
+ if (options.soft !== false) {
1605
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1606
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1607
+ ...context.query || {},
1608
+ ...deleteFilter
1609
+ };
1610
+ }
1611
+ }, { priority: HOOK_PRIORITY.POLICY });
1612
+ repo.on("before:exists", (context) => {
1613
+ if (options.soft !== false) {
1614
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1615
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1616
+ ...context.query || {},
1617
+ ...deleteFilter
1618
+ };
1619
+ }
1620
+ }, { priority: HOOK_PRIORITY.POLICY });
1621
+ repo.on("before:getOrCreate", (context) => {
1622
+ if (options.soft !== false) {
1623
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1624
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1625
+ ...context.query || {},
1626
+ ...deleteFilter
1627
+ };
1628
+ }
1629
+ }, { priority: HOOK_PRIORITY.POLICY });
1630
+ repo.on("before:distinct", (context) => {
1631
+ if (options.soft !== false) {
1632
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1633
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1634
+ ...context.query || {},
1635
+ ...deleteFilter
1636
+ };
1637
+ }
1638
+ }, { priority: HOOK_PRIORITY.POLICY });
1639
+ repo.on("before:aggregate", (context) => {
1640
+ if (options.soft !== false) {
1641
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1642
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1643
+ ...context.query || {},
1644
+ ...deleteFilter
1645
+ };
1646
+ }
1647
+ }, { priority: HOOK_PRIORITY.POLICY });
1648
+ repo.on("before:aggregatePaginate", (context) => {
1649
+ if (options.soft !== false) {
1650
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1651
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
1652
+ ...context.filters || {},
1653
+ ...deleteFilter
1654
+ };
1655
+ }
1656
+ }, { priority: HOOK_PRIORITY.POLICY });
250
1657
  if (addRestoreMethod) {
251
1658
  const restoreMethod = async function(id, restoreOptions = {}) {
1659
+ const context = await this._buildContext.call(this, "restore", {
1660
+ id,
1661
+ ...restoreOptions
1662
+ });
252
1663
  const updateData = {
253
1664
  [deletedField]: null,
254
1665
  [deletedByField]: null
255
1666
  };
256
- const result = await this.Model.findByIdAndUpdate(id, { $set: updateData }, {
1667
+ const restoreQuery = {
1668
+ _id: id,
1669
+ ...context.query || {}
1670
+ };
1671
+ const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
257
1672
  returnDocument: "after",
258
1673
  session: restoreOptions.session
259
1674
  });
@@ -264,7 +1679,8 @@ function softDeletePlugin(options = {}) {
264
1679
  }
265
1680
  await this.emitAsync("after:restore", {
266
1681
  id,
267
- result
1682
+ result,
1683
+ context
268
1684
  });
269
1685
  return result;
270
1686
  };
@@ -273,10 +1689,16 @@ function softDeletePlugin(options = {}) {
273
1689
  }
274
1690
  if (addGetDeletedMethod) {
275
1691
  const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
1692
+ const context = await this._buildContext.call(this, "getDeleted", {
1693
+ ...params,
1694
+ ...getDeletedOptions
1695
+ });
276
1696
  const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
277
1697
  const combinedFilters = {
278
1698
  ...params.filters || {},
279
- ...deletedFilter
1699
+ ...deletedFilter,
1700
+ ...context.filters || {},
1701
+ ...context.query || {}
280
1702
  };
281
1703
  const page = params.page || 1;
282
1704
  const limit = params.limit || 20;
@@ -491,10 +1913,17 @@ function uniqueField(field, errorMessage) {
491
1913
  name: `unique-${field}`,
492
1914
  operations: ["create", "update"],
493
1915
  validate: async (context, repo) => {
494
- if (!context.data || !context.data[field] || !repo) return;
1916
+ if (!context.data || !context.data[field]) return;
1917
+ if (!repo) {
1918
+ warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
1919
+ return;
1920
+ }
495
1921
  const query = { [field]: context.data[field] };
496
1922
  const getByQuery = repo.getByQuery;
497
- if (typeof getByQuery !== "function") return;
1923
+ if (typeof getByQuery !== "function") {
1924
+ warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
1925
+ return;
1926
+ }
498
1927
  const existing = await getByQuery.call(repo, query, {
499
1928
  select: "_id",
500
1929
  lean: true,
@@ -647,6 +2076,59 @@ function mongoOperationsPlugin() {
647
2076
  repo.registerMethod("setMax", async function(id, field, value, options = {}) {
648
2077
  return applyOperator.call(this, id, field, value, "$max", options);
649
2078
  });
2079
+ /**
2080
+ * Atomic update with multiple MongoDB operators in a single call
2081
+ *
2082
+ * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
2083
+ * into one atomic database operation.
2084
+ *
2085
+ * @example
2086
+ * // Combine $inc + $set in one atomic call
2087
+ * await repo.atomicUpdate(id, {
2088
+ * $inc: { views: 1, commentCount: 1 },
2089
+ * $set: { lastActiveAt: new Date() }
2090
+ * });
2091
+ *
2092
+ * // Multiple operators: $inc + $set + $push
2093
+ * await repo.atomicUpdate(id, {
2094
+ * $inc: { 'metrics.total': 1 },
2095
+ * $set: { updatedAt: new Date() },
2096
+ * $push: { history: { action: 'update', at: new Date() } }
2097
+ * });
2098
+ *
2099
+ * // $push with $each modifier
2100
+ * await repo.atomicUpdate(id, {
2101
+ * $push: { tags: { $each: ['featured', 'popular'] } },
2102
+ * $inc: { tagCount: 2 }
2103
+ * });
2104
+ *
2105
+ * // With arrayFilters for positional updates
2106
+ * await repo.atomicUpdate(id, {
2107
+ * $set: { 'items.$[elem].quantity': 5 }
2108
+ * }, { arrayFilters: [{ 'elem._id': itemId }] });
2109
+ */
2110
+ repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
2111
+ const validOperators = new Set([
2112
+ "$inc",
2113
+ "$set",
2114
+ "$unset",
2115
+ "$push",
2116
+ "$pull",
2117
+ "$addToSet",
2118
+ "$pop",
2119
+ "$rename",
2120
+ "$min",
2121
+ "$max",
2122
+ "$mul",
2123
+ "$setOnInsert",
2124
+ "$bit",
2125
+ "$currentDate"
2126
+ ]);
2127
+ const keys = Object.keys(operators);
2128
+ if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
2129
+ for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
2130
+ return this.update(id, operators, options);
2131
+ });
650
2132
  }
651
2133
  };
652
2134
  }
@@ -677,12 +2159,13 @@ function batchOperationsPlugin() {
677
2159
  const context = await this._buildContext.call(this, "updateMany", {
678
2160
  query,
679
2161
  data,
680
- options
2162
+ ...options
681
2163
  });
682
2164
  try {
683
- await this.emitAsync("before:updateMany", context);
2165
+ const finalQuery = context.query || query;
2166
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
684
2167
  if (Array.isArray(data) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
685
- const result = await this.Model.updateMany(query, data, {
2168
+ const result = await this.Model.updateMany(finalQuery, data, {
686
2169
  runValidators: true,
687
2170
  session: options.session,
688
2171
  ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
@@ -701,16 +2184,66 @@ function batchOperationsPlugin() {
701
2184
  }
702
2185
  });
703
2186
  /**
2187
+ * Execute heterogeneous bulk write operations in a single database call.
2188
+ *
2189
+ * Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
2190
+ * operations mixed together for maximum efficiency.
2191
+ *
2192
+ * @example
2193
+ * await repo.bulkWrite([
2194
+ * { insertOne: { document: { name: 'New Item', price: 10 } } },
2195
+ * { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
2196
+ * { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
2197
+ * { deleteOne: { filter: { _id: id2 } } },
2198
+ * ]);
2199
+ */
2200
+ repo.registerMethod("bulkWrite", async function(operations, options = {}) {
2201
+ const context = await this._buildContext.call(this, "bulkWrite", {
2202
+ operations,
2203
+ ...options
2204
+ });
2205
+ try {
2206
+ const finalOps = context.operations || operations;
2207
+ if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
2208
+ const result = await this.Model.bulkWrite(finalOps, {
2209
+ ordered: options.ordered ?? true,
2210
+ session: options.session
2211
+ });
2212
+ const bulkResult = {
2213
+ ok: result.ok,
2214
+ insertedCount: result.insertedCount,
2215
+ upsertedCount: result.upsertedCount,
2216
+ matchedCount: result.matchedCount,
2217
+ modifiedCount: result.modifiedCount,
2218
+ deletedCount: result.deletedCount,
2219
+ insertedIds: result.insertedIds,
2220
+ upsertedIds: result.upsertedIds
2221
+ };
2222
+ await this.emitAsync("after:bulkWrite", {
2223
+ context,
2224
+ result: bulkResult
2225
+ });
2226
+ return bulkResult;
2227
+ } catch (error) {
2228
+ this.emit("error:bulkWrite", {
2229
+ context,
2230
+ error
2231
+ });
2232
+ throw this._handleError.call(this, error);
2233
+ }
2234
+ });
2235
+ /**
704
2236
  * Delete multiple documents
705
2237
  */
706
2238
  repo.registerMethod("deleteMany", async function(query, options = {}) {
707
2239
  const context = await this._buildContext.call(this, "deleteMany", {
708
2240
  query,
709
- options
2241
+ ...options
710
2242
  });
711
2243
  try {
712
- await this.emitAsync("before:deleteMany", context);
713
- const result = await this.Model.deleteMany(query, { session: options.session }).exec();
2244
+ const finalQuery = context.query || query;
2245
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
2246
+ const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
714
2247
  await this.emitAsync("after:deleteMany", {
715
2248
  context,
716
2249
  result
@@ -886,7 +2419,8 @@ function cachePlugin(options) {
886
2419
  hits: 0,
887
2420
  misses: 0,
888
2421
  sets: 0,
889
- invalidations: 0
2422
+ invalidations: 0,
2423
+ errors: 0
890
2424
  };
891
2425
  const log = (msg, data) => {
892
2426
  if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
@@ -895,10 +2429,20 @@ function cachePlugin(options) {
895
2429
  name: "cache",
896
2430
  apply(repo) {
897
2431
  const model = repo.model;
2432
+ const byIdKeyRegistry = /* @__PURE__ */ new Map();
2433
+ function trackByIdKey(docId, cacheKey) {
2434
+ let keys = byIdKeyRegistry.get(docId);
2435
+ if (!keys) {
2436
+ keys = /* @__PURE__ */ new Set();
2437
+ byIdKeyRegistry.set(docId, keys);
2438
+ }
2439
+ keys.add(cacheKey);
2440
+ }
898
2441
  async function getVersion() {
899
2442
  try {
900
2443
  return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
901
- } catch {
2444
+ } catch (e) {
2445
+ log(`Cache error in getVersion for ${model}:`, e);
902
2446
  return 0;
903
2447
  }
904
2448
  }
@@ -917,20 +2461,28 @@ function cachePlugin(options) {
917
2461
  }
918
2462
  }
919
2463
  /**
920
- * Invalidate a specific document by ID
2464
+ * Invalidate a specific document by ID (all shape variants).
2465
+ * Deletes every tracked shape-variant key individually via del(),
2466
+ * so adapters without pattern-based clear() still get full invalidation.
921
2467
  */
922
2468
  async function invalidateById(id) {
923
- const key = byIdKey(config.prefix, model, id);
924
2469
  try {
925
- await config.adapter.del(key);
2470
+ const baseKey = byIdKey(config.prefix, model, id);
2471
+ await config.adapter.del(baseKey);
2472
+ const trackedKeys = byIdKeyRegistry.get(id);
2473
+ if (trackedKeys) {
2474
+ for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
2475
+ byIdKeyRegistry.delete(id);
2476
+ }
926
2477
  stats.invalidations++;
927
- log(`Invalidated byId cache:`, key);
2478
+ log(`Invalidated byId cache for:`, id);
928
2479
  } catch (e) {
929
2480
  log(`Failed to invalidate byId cache:`, e);
930
2481
  }
931
2482
  }
932
2483
  /**
933
2484
  * before:getById - Check cache for document
2485
+ * Runs at CACHE priority (200) — after policy hooks inject filters
934
2486
  */
935
2487
  repo.on("before:getById", async (context) => {
936
2488
  if (context.skipCache) {
@@ -938,7 +2490,11 @@ function cachePlugin(options) {
938
2490
  return;
939
2491
  }
940
2492
  const id = String(context.id);
941
- const key = byIdKey(config.prefix, model, id);
2493
+ const key = byIdKey(config.prefix, model, id, {
2494
+ select: context.select,
2495
+ populate: context.populate,
2496
+ lean: context.lean
2497
+ });
942
2498
  try {
943
2499
  const cached = await config.adapter.get(key);
944
2500
  if (cached !== null) {
@@ -952,19 +2508,21 @@ function cachePlugin(options) {
952
2508
  }
953
2509
  } catch (e) {
954
2510
  log(`Cache error for getById:`, e);
955
- stats.misses++;
2511
+ stats.errors++;
956
2512
  }
957
- });
2513
+ }, { priority: HOOK_PRIORITY.CACHE });
958
2514
  /**
959
2515
  * before:getByQuery - Check cache for single-doc query
2516
+ * Runs at CACHE priority (200) — after policy hooks inject filters
960
2517
  */
961
2518
  repo.on("before:getByQuery", async (context) => {
962
2519
  if (context.skipCache) {
963
2520
  log(`Skipping cache for getByQuery`);
964
2521
  return;
965
2522
  }
2523
+ const collectionVersion = await getVersion();
966
2524
  const query = context.query || {};
967
- const key = byQueryKey(config.prefix, model, query, {
2525
+ const key = byQueryKey(config.prefix, model, collectionVersion, query, {
968
2526
  select: context.select,
969
2527
  populate: context.populate
970
2528
  });
@@ -981,11 +2539,12 @@ function cachePlugin(options) {
981
2539
  }
982
2540
  } catch (e) {
983
2541
  log(`Cache error for getByQuery:`, e);
984
- stats.misses++;
2542
+ stats.errors++;
985
2543
  }
986
- });
2544
+ }, { priority: HOOK_PRIORITY.CACHE });
987
2545
  /**
988
2546
  * before:getAll - Check cache for list query
2547
+ * Runs at CACHE priority (200) — after policy hooks inject filters
989
2548
  */
990
2549
  repo.on("before:getAll", async (context) => {
991
2550
  if (context.skipCache) {
@@ -1006,7 +2565,13 @@ function cachePlugin(options) {
1006
2565
  after: context.after,
1007
2566
  select: context.select,
1008
2567
  populate: context.populate,
1009
- search: context.search
2568
+ search: context.search,
2569
+ mode: context.mode,
2570
+ lean: context.lean,
2571
+ readPreference: context.readPreference,
2572
+ hint: context.hint,
2573
+ maxTimeMS: context.maxTimeMS,
2574
+ countStrategy: context.countStrategy
1010
2575
  };
1011
2576
  const key = listQueryKey(config.prefix, model, collectionVersion, params);
1012
2577
  try {
@@ -1022,9 +2587,9 @@ function cachePlugin(options) {
1022
2587
  }
1023
2588
  } catch (e) {
1024
2589
  log(`Cache error for getAll:`, e);
1025
- stats.misses++;
2590
+ stats.errors++;
1026
2591
  }
1027
- });
2592
+ }, { priority: HOOK_PRIORITY.CACHE });
1028
2593
  /**
1029
2594
  * after:getById - Cache the result
1030
2595
  */
@@ -1034,10 +2599,15 @@ function cachePlugin(options) {
1034
2599
  if (context.skipCache) return;
1035
2600
  if (result === null) return;
1036
2601
  const id = String(context.id);
1037
- const key = byIdKey(config.prefix, model, id);
2602
+ const key = byIdKey(config.prefix, model, id, {
2603
+ select: context.select,
2604
+ populate: context.populate,
2605
+ lean: context.lean
2606
+ });
1038
2607
  const ttl = context.cacheTtl ?? config.byIdTtl;
1039
2608
  try {
1040
2609
  await config.adapter.set(key, result, ttl);
2610
+ trackByIdKey(id, key);
1041
2611
  stats.sets++;
1042
2612
  log(`Cached getById result:`, key);
1043
2613
  } catch (e) {
@@ -1052,8 +2622,9 @@ function cachePlugin(options) {
1052
2622
  if (context._cacheHit) return;
1053
2623
  if (context.skipCache) return;
1054
2624
  if (result === null) return;
2625
+ const collectionVersion = await getVersion();
1055
2626
  const query = context.query || {};
1056
- const key = byQueryKey(config.prefix, model, query, {
2627
+ const key = byQueryKey(config.prefix, model, collectionVersion, query, {
1057
2628
  select: context.select,
1058
2629
  populate: context.populate
1059
2630
  });
@@ -1084,7 +2655,13 @@ function cachePlugin(options) {
1084
2655
  after: context.after,
1085
2656
  select: context.select,
1086
2657
  populate: context.populate,
1087
- search: context.search
2658
+ search: context.search,
2659
+ mode: context.mode,
2660
+ lean: context.lean,
2661
+ readPreference: context.readPreference,
2662
+ hint: context.hint,
2663
+ maxTimeMS: context.maxTimeMS,
2664
+ countStrategy: context.countStrategy
1088
2665
  };
1089
2666
  const key = listQueryKey(config.prefix, model, collectionVersion, params);
1090
2667
  const ttl = context.cacheTtl ?? config.queryTtl;
@@ -1137,6 +2714,12 @@ function cachePlugin(options) {
1137
2714
  await bumpVersion();
1138
2715
  });
1139
2716
  /**
2717
+ * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
2718
+ */
2719
+ repo.on("after:bulkWrite", async () => {
2720
+ await bumpVersion();
2721
+ });
2722
+ /**
1140
2723
  * Invalidate cache for a specific document
1141
2724
  * Use when document was updated outside this service
1142
2725
  *
@@ -1194,6 +2777,7 @@ function cachePlugin(options) {
1194
2777
  stats.misses = 0;
1195
2778
  stats.sets = 0;
1196
2779
  stats.invalidations = 0;
2780
+ stats.errors = 0;
1197
2781
  };
1198
2782
  }
1199
2783
  };
@@ -1300,22 +2884,12 @@ function cascadePlugin(options) {
1300
2884
  }
1301
2885
  } else for (const relation of relations) await cascadeDelete(relation);
1302
2886
  });
1303
- repo.on("after:deleteMany", async (payload) => {
1304
- const { context, result } = payload;
1305
- const query = context.query;
1306
- if (!query || Object.keys(query).length === 0) {
1307
- logger?.warn?.("Cascade deleteMany skipped: empty query", { model: context.model });
1308
- return;
1309
- }
1310
- logger?.warn?.("Cascade deleteMany: use before:deleteMany hook for complete cascade support", { model: context.model });
1311
- });
1312
2887
  repo.on("before:deleteMany", async (context) => {
1313
2888
  const query = context.query;
1314
2889
  if (!query || Object.keys(query).length === 0) return;
1315
2890
  context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
1316
2891
  });
1317
- const originalAfterDeleteMany = repo._hooks.get("after:deleteMany") || [];
1318
- repo._hooks.set("after:deleteMany", [...originalAfterDeleteMany, async (payload) => {
2892
+ repo.on("after:deleteMany", async (payload) => {
1319
2893
  const { context } = payload;
1320
2894
  const ids = context._cascadeIds;
1321
2895
  if (!ids || ids.length === 0) return;
@@ -1369,29 +2943,90 @@ function cascadePlugin(options) {
1369
2943
  throw err;
1370
2944
  }
1371
2945
  } else for (const relation of relations) await cascadeDeleteMany(relation);
1372
- }]);
2946
+ });
1373
2947
  }
1374
2948
  };
1375
2949
  }
1376
2950
 
1377
2951
  //#endregion
1378
2952
  //#region src/plugins/multi-tenant.plugin.ts
2953
+ /**
2954
+ * Multi-Tenant Plugin
2955
+ *
2956
+ * Automatically injects tenant isolation filters into all queries.
2957
+ * Ensures data isolation by adding organizationId (or custom tenant field)
2958
+ * to every read and write operation.
2959
+ *
2960
+ * Uses HOOK_PRIORITY.POLICY (100) to ensure tenant filters are injected
2961
+ * BEFORE cache keys are computed (HOOK_PRIORITY.CACHE = 200).
2962
+ *
2963
+ * @example
2964
+ * ```typescript
2965
+ * // Basic — scopes every operation by organizationId from context
2966
+ * const repo = new Repository(Invoice, [
2967
+ * multiTenantPlugin({ tenantField: 'organizationId' }),
2968
+ * ]);
2969
+ *
2970
+ * const invoices = await repo.getAll(
2971
+ * { filters: { status: 'paid' } },
2972
+ * { organizationId: 'org_123' }
2973
+ * );
2974
+ * // Actual query: { status: 'paid', organizationId: 'org_123' }
2975
+ *
2976
+ * // Super admin bypass — skip scoping based on context
2977
+ * const repo = new Repository(Invoice, [
2978
+ * multiTenantPlugin({
2979
+ * tenantField: 'organizationId',
2980
+ * skipWhen: (context) => context.role === 'superadmin',
2981
+ * }),
2982
+ * ]);
2983
+ *
2984
+ * // Admin sees all orgs
2985
+ * await repo.getAll({ page: 1, limit: 10 }, { role: 'superadmin' });
2986
+ *
2987
+ * // Automatic context — resolve tenant from AsyncLocalStorage
2988
+ * const repo = new Repository(Invoice, [
2989
+ * multiTenantPlugin({
2990
+ * tenantField: 'organizationId',
2991
+ * resolveContext: () => asyncLocalStorage.getStore()?.tenantId,
2992
+ * }),
2993
+ * ]);
2994
+ * ```
2995
+ */
1379
2996
  function multiTenantPlugin(options = {}) {
1380
2997
  const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext } = options;
1381
- const readOps = [
1382
- "getById",
1383
- "getByQuery",
2998
+ const filterOps = [
1384
2999
  "getAll",
1385
3000
  "aggregatePaginate",
1386
3001
  "lookupPopulate"
1387
3002
  ];
1388
- const writeOps = [
1389
- "create",
1390
- "createMany",
3003
+ const queryReadOps = [
3004
+ "getById",
3005
+ "getByQuery",
3006
+ "count",
3007
+ "exists",
3008
+ "getOrCreate",
3009
+ "distinct",
3010
+ "aggregate"
3011
+ ];
3012
+ const constrainedWriteOps = [
1391
3013
  "update",
1392
- "delete"
3014
+ "delete",
3015
+ "restore"
3016
+ ];
3017
+ const filterReadOps = ["getDeleted"];
3018
+ const createOps = ["create", "createMany"];
3019
+ const batchQueryOps = ["updateMany", "deleteMany"];
3020
+ const bulkOps = ["bulkWrite"];
3021
+ const allOps = [
3022
+ ...filterOps,
3023
+ ...filterReadOps,
3024
+ ...queryReadOps,
3025
+ ...constrainedWriteOps,
3026
+ ...createOps,
3027
+ ...batchQueryOps,
3028
+ ...bulkOps
1393
3029
  ];
1394
- const allOps = [...readOps, ...writeOps];
1395
3030
  return {
1396
3031
  name: "multi-tenant",
1397
3032
  apply(repo) {
@@ -1406,21 +3041,47 @@ function multiTenantPlugin(options = {}) {
1406
3041
  }
1407
3042
  if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
1408
3043
  if (!tenantId) return;
1409
- if (readOps.includes(op)) if (op === "getAll" || op === "aggregatePaginate" || op === "lookupPopulate") context.filters = {
3044
+ if (filterOps.includes(op) || filterReadOps.includes(op)) context.filters = {
1410
3045
  ...context.filters,
1411
3046
  [tenantField]: tenantId
1412
3047
  };
1413
- else context.query = {
3048
+ if (queryReadOps.includes(op)) context.query = {
1414
3049
  ...context.query,
1415
3050
  [tenantField]: tenantId
1416
3051
  };
1417
3052
  if (op === "create" && context.data) context.data[tenantField] = tenantId;
1418
- if (op === "createMany" && context.dataArray) for (const doc of context.dataArray) doc[tenantField] = tenantId;
1419
- if (op === "update" || op === "delete") context.query = {
3053
+ if (op === "createMany" && context.dataArray) {
3054
+ for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = tenantId;
3055
+ }
3056
+ if (constrainedWriteOps.includes(op)) context.query = {
1420
3057
  ...context.query,
1421
3058
  [tenantField]: tenantId
1422
3059
  };
1423
- });
3060
+ if (batchQueryOps.includes(op)) context.query = {
3061
+ ...context.query,
3062
+ [tenantField]: tenantId
3063
+ };
3064
+ if (op === "bulkWrite" && context.operations) {
3065
+ const ops = context.operations;
3066
+ for (const subOp of ops) {
3067
+ for (const key of [
3068
+ "updateOne",
3069
+ "updateMany",
3070
+ "deleteOne",
3071
+ "deleteMany",
3072
+ "replaceOne"
3073
+ ]) {
3074
+ const opBody = subOp[key];
3075
+ if (opBody?.filter) opBody.filter = {
3076
+ ...opBody.filter,
3077
+ [tenantField]: tenantId
3078
+ };
3079
+ }
3080
+ const insertBody = subOp.insertOne;
3081
+ if (insertBody?.document) insertBody.document[tenantField] = tenantId;
3082
+ }
3083
+ }
3084
+ }, { priority: HOOK_PRIORITY.POLICY });
1424
3085
  }
1425
3086
  }
1426
3087
  };
@@ -1449,7 +3110,7 @@ function observabilityPlugin(options) {
1449
3110
  for (const op of ops) {
1450
3111
  repo.on(`before:${op}`, (context) => {
1451
3112
  timers.set(context, performance.now());
1452
- });
3113
+ }, { priority: 300 });
1453
3114
  repo.on(`after:${op}`, ({ context }) => {
1454
3115
  const start = timers.get(context);
1455
3116
  if (start == null) return;
@@ -1986,12 +3647,14 @@ const counterSchema = new mongoose.Schema({
1986
3647
  versionKey: false
1987
3648
  });
1988
3649
  /**
1989
- * Get or create the Counter model.
3650
+ * Get or create the Counter model on the given connection.
3651
+ * Falls back to the default mongoose connection if none is provided.
1990
3652
  * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
1991
3653
  */
1992
- function getCounterModel() {
1993
- if (mongoose.models._MongoKitCounter) return mongoose.models._MongoKitCounter;
1994
- return mongoose.model("_MongoKitCounter", counterSchema);
3654
+ function getCounterModel(connection) {
3655
+ const conn = connection ?? mongoose.connection;
3656
+ if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
3657
+ return conn.model("_MongoKitCounter", counterSchema);
1995
3658
  }
1996
3659
  /**
1997
3660
  * Atomically increment and return the next sequence value for a given key.
@@ -2010,11 +3673,13 @@ function getCounterModel() {
2010
3673
  * const startSeq = await getNextSequence('invoices', 5);
2011
3674
  * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
2012
3675
  */
2013
- async function getNextSequence(counterKey, increment = 1) {
2014
- return (await getCounterModel().findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
3676
+ async function getNextSequence(counterKey, increment = 1, connection) {
3677
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
2015
3678
  upsert: true,
2016
3679
  returnDocument: "after"
2017
- })).seq;
3680
+ });
3681
+ if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
3682
+ return result.seq;
2018
3683
  }
2019
3684
  /**
2020
3685
  * Generator: Simple sequential counter.
@@ -2033,8 +3698,8 @@ async function getNextSequence(counterKey, increment = 1) {
2033
3698
  function sequentialId(options) {
2034
3699
  const { prefix, model, padding = 4, separator = "-", counterKey } = options;
2035
3700
  const key = counterKey || model.modelName;
2036
- return async (_context) => {
2037
- const seq = await getNextSequence(key);
3701
+ return async (context) => {
3702
+ const seq = await getNextSequence(key, 1, context._counterConnection);
2038
3703
  return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
2039
3704
  };
2040
3705
  }
@@ -2061,7 +3726,7 @@ function sequentialId(options) {
2061
3726
  */
2062
3727
  function dateSequentialId(options) {
2063
3728
  const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
2064
- return async (_context) => {
3729
+ return async (context) => {
2065
3730
  const now = /* @__PURE__ */ new Date();
2066
3731
  const year = String(now.getFullYear());
2067
3732
  const month = String(now.getMonth() + 1).padStart(2, "0");
@@ -2082,7 +3747,7 @@ function dateSequentialId(options) {
2082
3747
  counterKey = `${model.modelName}:${year}-${month}`;
2083
3748
  break;
2084
3749
  }
2085
- const seq = await getNextSequence(counterKey);
3750
+ const seq = await getNextSequence(counterKey, 1, context._counterConnection);
2086
3751
  return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
2087
3752
  };
2088
3753
  }
@@ -2143,13 +3808,16 @@ function customIdPlugin(options) {
2143
3808
  return {
2144
3809
  name: "custom-id",
2145
3810
  apply(repo) {
3811
+ const repoConnection = repo.Model.db;
2146
3812
  repo.on("before:create", async (context) => {
2147
3813
  if (!context.data) return;
2148
3814
  if (generateOnlyIfEmpty && context.data[fieldName]) return;
3815
+ context._counterConnection = repoConnection;
2149
3816
  context.data[fieldName] = await options.generator(context);
2150
3817
  });
2151
3818
  repo.on("before:createMany", async (context) => {
2152
3819
  if (!context.dataArray) return;
3820
+ context._counterConnection = repoConnection;
2153
3821
  const docsNeedingIds = [];
2154
3822
  for (const doc of context.dataArray) {
2155
3823
  if (generateOnlyIfEmpty && doc[fieldName]) continue;
@@ -2166,4 +3834,4 @@ function customIdPlugin(options) {
2166
3834
  }
2167
3835
 
2168
3836
  //#endregion
2169
- export { methodRegistryPlugin as C, fieldFilterPlugin as D, timestampPlugin as E, validationChainPlugin as S, auditLogPlugin as T, autoInject as _, sequentialId as a, requireField as b, auditTrailPlugin as c, cascadePlugin as d, cachePlugin as f, mongoOperationsPlugin as g, batchOperationsPlugin as h, prefixedId as i, observabilityPlugin as l, aggregateHelpersPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, subdocumentPlugin as p, getNextSequence as r, AuditTrailQuery as s, customIdPlugin as t, multiTenantPlugin as u, blockIf as v, softDeletePlugin as w, uniqueField as x, immutableField as y };
3837
+ export { AggregationBuilder as A, methodRegistryPlugin as C, fieldFilterPlugin as D, timestampPlugin as E, HOOK_PRIORITY as O, validationChainPlugin as S, auditLogPlugin as T, autoInject as _, sequentialId as a, requireField as b, auditTrailPlugin as c, cascadePlugin as d, cachePlugin as f, mongoOperationsPlugin as g, batchOperationsPlugin as h, prefixedId as i, Repository as k, observabilityPlugin as l, aggregateHelpersPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, subdocumentPlugin as p, getNextSequence as r, AuditTrailQuery as s, customIdPlugin as t, multiTenantPlugin as u, blockIf as v, softDeletePlugin as w, uniqueField as x, immutableField as y };