@classytic/mongokit 3.2.4 → 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.
- package/dist/actions/index.d.mts +2 -2
- package/dist/actions/index.mjs +1 -2
- package/dist/{aggregate-BAi4Do-X.mjs → aggregate-BClp040M.mjs} +80 -12
- package/dist/{aggregate-D9x87vej.d.mts → aggregate-BkOG9qwr.d.mts} +3 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/{cache-keys-C8Z9B5sw.mjs → cache-keys-CzFwVnLy.mjs} +28 -9
- package/dist/{custom-id.plugin-Dc4Y3Eie.d.mts → custom-id.plugin-BJ3FSnzt.d.mts} +51 -3
- package/dist/{custom-id.plugin-m0VW6yYm.mjs → custom-id.plugin-FInXDsUX.mjs} +1744 -76
- package/dist/index.d.mts +63 -15
- package/dist/index.mjs +5 -1144
- package/dist/{limits-DsNeCx4D.mjs → limits-s1-d8rWb.mjs} +2 -2
- package/dist/{mongooseToJsonSchema-1qQMScHL.d.mts → mongooseToJsonSchema-B6O2ED3n.d.mts} +1 -1
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +18 -5
- package/dist/plugins/index.d.mts +2 -2
- package/dist/plugins/index.mjs +1 -1
- package/dist/{types-Bnwv9NV6.d.mts → types-pVY0w1Pp.d.mts} +29 -3
- package/dist/utils/index.d.mts +22 -5
- package/dist/utils/index.mjs +2 -2
- package/package.json +4 -4
- package/dist/create-BuO6xt0v.mjs +0 -55
- /package/dist/{mongooseToJsonSchema-COdDEkIJ.mjs → mongooseToJsonSchema-D_i2Am_O.mjs} +0 -0
|
@@ -1,8 +1,1351 @@
|
|
|
1
1
|
import { i as createError, n as debug, r as warn } from "./logger-D8ily-PP.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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]
|
|
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")
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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:`,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
1382
|
-
"getById",
|
|
1383
|
-
"getByQuery",
|
|
2998
|
+
const filterOps = [
|
|
1384
2999
|
"getAll",
|
|
1385
3000
|
"aggregatePaginate",
|
|
1386
3001
|
"lookupPopulate"
|
|
1387
3002
|
];
|
|
1388
|
-
const
|
|
1389
|
-
"
|
|
1390
|
-
"
|
|
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 (
|
|
3044
|
+
if (filterOps.includes(op) || filterReadOps.includes(op)) context.filters = {
|
|
1410
3045
|
...context.filters,
|
|
1411
3046
|
[tenantField]: tenantId
|
|
1412
3047
|
};
|
|
1413
|
-
|
|
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)
|
|
1419
|
-
|
|
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
|
-
|
|
1994
|
-
return
|
|
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
|
-
|
|
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
|
-
})
|
|
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 (
|
|
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 (
|
|
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 };
|