@classytic/mongokit 3.0.6 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,902 +1,709 @@
1
- import mongoose4 from 'mongoose';
1
+ import { getById, getByQuery, getOrCreate, count, exists, update, deleteById, aggregate, distinct } from './chunks/chunk-SAKSLT47.js';
2
+ export { actions_exports as actions } from './chunks/chunk-SAKSLT47.js';
3
+ import { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
4
+ export { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
5
+ export { aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, fieldFilterPlugin, immutableField, methodRegistryPlugin, mongoOperationsPlugin, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin } from './chunks/chunk-CSLJ2PL2.js';
6
+ import { create, createMany } from './chunks/chunk-CF6FLC2G.js';
7
+ export { buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, createMemoryCache, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from './chunks/chunk-IT7DCOKR.js';
8
+ export { createFieldPreset, filterResponseData, getFieldsForUser, getMongooseProjection } from './chunks/chunk-2ZN65ZOP.js';
9
+ import { createError } from './chunks/chunk-VJXDGP3C.js';
10
+ export { createError } from './chunks/chunk-VJXDGP3C.js';
11
+ import mongoose from 'mongoose';
2
12
 
3
- var __defProp = Object.defineProperty;
4
- var __export = (target, all) => {
5
- for (var name in all)
6
- __defProp(target, name, { get: all[name], enumerable: true });
7
- };
8
-
9
- // src/utils/error.ts
10
- function createError(status, message) {
11
- const error = new Error(message);
12
- error.status = status;
13
- return error;
14
- }
15
-
16
- // src/actions/create.ts
17
- var create_exports = {};
18
- __export(create_exports, {
19
- create: () => create,
20
- createDefault: () => createDefault,
21
- createMany: () => createMany,
22
- upsert: () => upsert
23
- });
24
- async function create(Model, data, options = {}) {
25
- const document = new Model(data);
26
- await document.save({ session: options.session });
27
- return document;
28
- }
29
- async function createMany(Model, dataArray, options = {}) {
30
- return Model.insertMany(dataArray, {
31
- session: options.session,
32
- ordered: options.ordered !== false
33
- });
34
- }
35
- async function createDefault(Model, overrides = {}, options = {}) {
36
- const defaults = {};
37
- Model.schema.eachPath((path, schemaType) => {
38
- const schemaOptions = schemaType.options;
39
- if (schemaOptions.default !== void 0 && path !== "_id") {
40
- defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
41
- }
42
- });
43
- return create(Model, { ...defaults, ...overrides }, options);
44
- }
45
- async function upsert(Model, query, data, options = {}) {
46
- return Model.findOneAndUpdate(
47
- query,
48
- { $setOnInsert: data },
49
- {
50
- upsert: true,
51
- new: true,
52
- runValidators: true,
53
- session: options.session,
54
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
55
- }
56
- );
57
- }
58
-
59
- // src/actions/read.ts
60
- var read_exports = {};
61
- __export(read_exports, {
62
- count: () => count,
63
- exists: () => exists,
64
- getAll: () => getAll,
65
- getById: () => getById,
66
- getByQuery: () => getByQuery,
67
- getOrCreate: () => getOrCreate,
68
- tryGetByQuery: () => tryGetByQuery
69
- });
70
- function parsePopulate(populate) {
71
- if (!populate) return [];
72
- if (typeof populate === "string") {
73
- return populate.split(",").map((p) => p.trim());
74
- }
75
- if (Array.isArray(populate)) {
76
- return populate.map((p) => typeof p === "string" ? p.trim() : p);
77
- }
78
- return [populate];
79
- }
80
- async function getById(Model, id, options = {}) {
81
- const query = options.query ? Model.findOne({ _id: id, ...options.query }) : Model.findById(id);
82
- if (options.select) query.select(options.select);
83
- if (options.populate) query.populate(parsePopulate(options.populate));
84
- if (options.lean) query.lean();
85
- if (options.session) query.session(options.session);
86
- const document = await query.exec();
87
- if (!document && options.throwOnNotFound !== false) {
88
- throw createError(404, "Document not found");
89
- }
90
- return document;
91
- }
92
- async function getByQuery(Model, query, options = {}) {
93
- const mongoQuery = Model.findOne(query);
94
- if (options.select) mongoQuery.select(options.select);
95
- if (options.populate) mongoQuery.populate(parsePopulate(options.populate));
96
- if (options.lean) mongoQuery.lean();
97
- if (options.session) mongoQuery.session(options.session);
98
- const document = await mongoQuery.exec();
99
- if (!document && options.throwOnNotFound !== false) {
100
- throw createError(404, "Document not found");
101
- }
102
- return document;
103
- }
104
- async function tryGetByQuery(Model, query, options = {}) {
105
- return getByQuery(Model, query, { ...options, throwOnNotFound: false });
106
- }
107
- async function getAll(Model, query = {}, options = {}) {
108
- let mongoQuery = Model.find(query);
109
- if (options.select) mongoQuery = mongoQuery.select(options.select);
110
- if (options.populate) mongoQuery = mongoQuery.populate(parsePopulate(options.populate));
111
- if (options.sort) mongoQuery = mongoQuery.sort(options.sort);
112
- if (options.limit) mongoQuery = mongoQuery.limit(options.limit);
113
- if (options.skip) mongoQuery = mongoQuery.skip(options.skip);
114
- mongoQuery = mongoQuery.lean(options.lean !== false);
115
- if (options.session) mongoQuery = mongoQuery.session(options.session);
116
- return mongoQuery.exec();
117
- }
118
- async function getOrCreate(Model, query, createData, options = {}) {
119
- return Model.findOneAndUpdate(
120
- query,
121
- { $setOnInsert: createData },
122
- {
123
- upsert: true,
124
- new: true,
125
- runValidators: true,
126
- session: options.session,
127
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
128
- }
129
- );
130
- }
131
- async function count(Model, query = {}, options = {}) {
132
- return Model.countDocuments(query).session(options.session ?? null);
133
- }
134
- async function exists(Model, query, options = {}) {
135
- return Model.exists(query).session(options.session ?? null);
136
- }
137
-
138
- // src/actions/update.ts
139
- var update_exports = {};
140
- __export(update_exports, {
141
- increment: () => increment,
142
- pullFromArray: () => pullFromArray,
143
- pushToArray: () => pushToArray,
144
- update: () => update,
145
- updateByQuery: () => updateByQuery,
146
- updateMany: () => updateMany,
147
- updateWithConstraints: () => updateWithConstraints,
148
- updateWithValidation: () => updateWithValidation
149
- });
150
- function assertUpdatePipelineAllowed(update2, updatePipeline) {
151
- if (Array.isArray(update2) && updatePipeline !== true) {
152
- throw createError(
153
- 400,
154
- "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates."
155
- );
13
+ // src/query/LookupBuilder.ts
14
+ var LookupBuilder = class _LookupBuilder {
15
+ options = {};
16
+ constructor(from) {
17
+ if (from) this.options.from = from;
156
18
  }
157
- }
158
- function parsePopulate2(populate) {
159
- if (!populate) return [];
160
- if (typeof populate === "string") {
161
- return populate.split(",").map((p) => p.trim());
19
+ /**
20
+ * Set the collection to join with
21
+ */
22
+ from(collection) {
23
+ this.options.from = collection;
24
+ return this;
162
25
  }
163
- if (Array.isArray(populate)) {
164
- return populate.map((p) => typeof p === "string" ? p.trim() : p);
26
+ /**
27
+ * Set the local field (source collection)
28
+ * IMPORTANT: This field should be indexed for optimal performance
29
+ */
30
+ localField(field) {
31
+ this.options.localField = field;
32
+ return this;
165
33
  }
166
- return [populate];
167
- }
168
- async function update(Model, id, data, options = {}) {
169
- assertUpdatePipelineAllowed(data, options.updatePipeline);
170
- const document = await Model.findByIdAndUpdate(id, data, {
171
- new: true,
172
- runValidators: true,
173
- session: options.session,
174
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
175
- }).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
176
- if (!document) {
177
- throw createError(404, "Document not found");
178
- }
179
- return document;
180
- }
181
- async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
182
- assertUpdatePipelineAllowed(data, options.updatePipeline);
183
- const query = { _id: id, ...constraints };
184
- const document = await Model.findOneAndUpdate(query, data, {
185
- new: true,
186
- runValidators: true,
187
- session: options.session,
188
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
189
- }).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
190
- return document;
191
- }
192
- async function updateWithValidation(Model, id, data, validationOptions = {}, options = {}) {
193
- const { buildConstraints, validateUpdate } = validationOptions;
194
- assertUpdatePipelineAllowed(data, options.updatePipeline);
195
- if (buildConstraints) {
196
- const constraints = buildConstraints(data);
197
- const document = await updateWithConstraints(Model, id, data, constraints, options);
198
- if (document) {
199
- return { success: true, data: document };
200
- }
34
+ /**
35
+ * Set the foreign field (target collection)
36
+ * IMPORTANT: This field should be indexed (preferably unique) for optimal performance
37
+ */
38
+ foreignField(field) {
39
+ this.options.foreignField = field;
40
+ return this;
201
41
  }
202
- const existing = await Model.findById(id).select(options.select || "").lean();
203
- if (!existing) {
204
- return {
205
- success: false,
206
- error: {
207
- code: 404,
208
- message: "Document not found"
209
- }
210
- };
42
+ /**
43
+ * Set the output field name
44
+ * Defaults to the collection name if not specified
45
+ */
46
+ as(fieldName) {
47
+ this.options.as = fieldName;
48
+ return this;
211
49
  }
212
- if (validateUpdate) {
213
- const validation = validateUpdate(existing, data);
214
- if (!validation.valid) {
215
- return {
216
- success: false,
217
- error: {
218
- code: 403,
219
- message: validation.message || "Update not allowed",
220
- violations: validation.violations
221
- }
222
- };
223
- }
50
+ /**
51
+ * Mark this lookup as returning a single document
52
+ * Automatically unwraps the array result to a single object or null
53
+ */
54
+ single(isSingle = true) {
55
+ this.options.single = isSingle;
56
+ return this;
224
57
  }
225
- const updated = await update(Model, id, data, options);
226
- return { success: true, data: updated };
227
- }
228
- async function updateMany(Model, query, data, options = {}) {
229
- assertUpdatePipelineAllowed(data, options.updatePipeline);
230
- const result = await Model.updateMany(query, data, {
231
- runValidators: true,
232
- session: options.session,
233
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
234
- });
235
- return {
236
- matchedCount: result.matchedCount,
237
- modifiedCount: result.modifiedCount
238
- };
239
- }
240
- async function updateByQuery(Model, query, data, options = {}) {
241
- assertUpdatePipelineAllowed(data, options.updatePipeline);
242
- const document = await Model.findOneAndUpdate(query, data, {
243
- new: true,
244
- runValidators: true,
245
- session: options.session,
246
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
247
- }).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
248
- if (!document && options.throwOnNotFound !== false) {
249
- throw createError(404, "Document not found");
250
- }
251
- return document;
252
- }
253
- async function increment(Model, id, field, value = 1, options = {}) {
254
- return update(Model, id, { $inc: { [field]: value } }, options);
255
- }
256
- async function pushToArray(Model, id, field, value, options = {}) {
257
- return update(Model, id, { $push: { [field]: value } }, options);
258
- }
259
- async function pullFromArray(Model, id, field, value, options = {}) {
260
- return update(Model, id, { $pull: { [field]: value } }, options);
261
- }
262
-
263
- // src/actions/delete.ts
264
- var delete_exports = {};
265
- __export(delete_exports, {
266
- deleteById: () => deleteById,
267
- deleteByQuery: () => deleteByQuery,
268
- deleteMany: () => deleteMany,
269
- restore: () => restore,
270
- softDelete: () => softDelete
271
- });
272
- async function deleteById(Model, id, options = {}) {
273
- const document = await Model.findByIdAndDelete(id).session(options.session ?? null);
274
- if (!document) {
275
- throw createError(404, "Document not found");
276
- }
277
- return { success: true, message: "Deleted successfully" };
278
- }
279
- async function deleteMany(Model, query, options = {}) {
280
- const result = await Model.deleteMany(query).session(options.session ?? null);
281
- return {
282
- success: true,
283
- count: result.deletedCount,
284
- message: "Deleted successfully"
285
- };
286
- }
287
- async function deleteByQuery(Model, query, options = {}) {
288
- const document = await Model.findOneAndDelete(query).session(options.session ?? null);
289
- if (!document && options.throwOnNotFound !== false) {
290
- throw createError(404, "Document not found");
58
+ /**
59
+ * Add a pipeline to filter/transform joined documents
60
+ * Useful for filtering, sorting, or limiting joined results
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * lookup.pipeline([
65
+ * { $match: { status: 'active' } },
66
+ * { $sort: { priority: -1 } },
67
+ * { $limit: 5 }
68
+ * ]);
69
+ * ```
70
+ */
71
+ pipeline(stages) {
72
+ this.options.pipeline = stages;
73
+ return this;
291
74
  }
292
- return { success: true, message: "Deleted successfully" };
293
- }
294
- async function softDelete(Model, id, options = {}) {
295
- const document = await Model.findByIdAndUpdate(
296
- id,
297
- {
298
- deleted: true,
299
- deletedAt: /* @__PURE__ */ new Date(),
300
- deletedBy: options.userId
301
- },
302
- { new: true, session: options.session }
303
- );
304
- if (!document) {
305
- throw createError(404, "Document not found");
306
- }
307
- return { success: true, message: "Soft deleted successfully" };
308
- }
309
- async function restore(Model, id, options = {}) {
310
- const document = await Model.findByIdAndUpdate(
311
- id,
312
- {
313
- deleted: false,
314
- deletedAt: null,
315
- deletedBy: null
316
- },
317
- { new: true, session: options.session }
318
- );
319
- if (!document) {
320
- throw createError(404, "Document not found");
321
- }
322
- return { success: true, message: "Restored successfully" };
323
- }
324
-
325
- // src/actions/aggregate.ts
326
- var aggregate_exports = {};
327
- __export(aggregate_exports, {
328
- aggregate: () => aggregate,
329
- aggregatePaginate: () => aggregatePaginate,
330
- average: () => average,
331
- countBy: () => countBy,
332
- distinct: () => distinct,
333
- facet: () => facet,
334
- groupBy: () => groupBy,
335
- lookup: () => lookup,
336
- minMax: () => minMax,
337
- sum: () => sum,
338
- unwind: () => unwind
339
- });
340
- async function aggregate(Model, pipeline, options = {}) {
341
- const aggregation = Model.aggregate(pipeline);
342
- if (options.session) {
343
- aggregation.session(options.session);
344
- }
345
- return aggregation.exec();
346
- }
347
- async function aggregatePaginate(Model, pipeline, options = {}) {
348
- const page = parseInt(String(options.page || 1), 10);
349
- const limit = parseInt(String(options.limit || 10), 10);
350
- const skip = (page - 1) * limit;
351
- const SAFE_LIMIT = 1e3;
352
- if (limit > SAFE_LIMIT) {
353
- console.warn(
354
- `[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`
355
- );
356
- }
357
- const facetPipeline = [
358
- ...pipeline,
359
- {
360
- $facet: {
361
- docs: [{ $skip: skip }, { $limit: limit }],
362
- total: [{ $count: "count" }]
75
+ /**
76
+ * Set let variables for use in pipeline
77
+ * Allows referencing local document fields in the pipeline
78
+ */
79
+ let(variables) {
80
+ this.options.let = variables;
81
+ return this;
82
+ }
83
+ /**
84
+ * Build the $lookup aggregation stage(s)
85
+ * Returns an array of pipeline stages including $lookup and optional $unwind
86
+ *
87
+ * IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
88
+ * 1. Simple form: { from, localField, foreignField, as }
89
+ * 2. Pipeline form: { from, let, pipeline, as }
90
+ *
91
+ * When pipeline or let is specified, we use the pipeline form.
92
+ * Otherwise, we use the simpler localField/foreignField form.
93
+ */
94
+ build() {
95
+ const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
96
+ if (!from) {
97
+ throw new Error('LookupBuilder: "from" collection is required');
98
+ }
99
+ const outputField = as || from;
100
+ const stages = [];
101
+ const usePipelineForm = pipeline || letVars;
102
+ let lookupStage;
103
+ if (usePipelineForm) {
104
+ if (!pipeline || pipeline.length === 0) {
105
+ if (!localField || !foreignField) {
106
+ throw new Error(
107
+ "LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline"
108
+ );
109
+ }
110
+ const autoPipeline = [
111
+ {
112
+ $match: {
113
+ $expr: {
114
+ $eq: [`$${foreignField}`, `$$${localField}`]
115
+ }
116
+ }
117
+ }
118
+ ];
119
+ lookupStage = {
120
+ $lookup: {
121
+ from,
122
+ let: { [localField]: `$${localField}`, ...letVars || {} },
123
+ pipeline: autoPipeline,
124
+ as: outputField
125
+ }
126
+ };
127
+ } else {
128
+ lookupStage = {
129
+ $lookup: {
130
+ from,
131
+ ...letVars && { let: letVars },
132
+ pipeline,
133
+ as: outputField
134
+ }
135
+ };
363
136
  }
364
- }
365
- ];
366
- const aggregation = Model.aggregate(facetPipeline);
367
- if (options.session) {
368
- aggregation.session(options.session);
369
- }
370
- const [result] = await aggregation.exec();
371
- const docs = result.docs || [];
372
- const total = result.total[0]?.count || 0;
373
- const pages = Math.ceil(total / limit);
374
- return {
375
- docs,
376
- total,
377
- page,
378
- limit,
379
- pages,
380
- hasNext: page < pages,
381
- hasPrev: page > 1
382
- };
383
- }
384
- async function groupBy(Model, field, options = {}) {
385
- const pipeline = [
386
- { $group: { _id: `$${field}`, count: { $sum: 1 } } },
387
- { $sort: { count: -1 } }
388
- ];
389
- if (options.limit) {
390
- pipeline.push({ $limit: options.limit });
391
- }
392
- return aggregate(Model, pipeline, options);
393
- }
394
- async function countBy(Model, field, query = {}, options = {}) {
395
- const pipeline = [];
396
- if (Object.keys(query).length > 0) {
397
- pipeline.push({ $match: query });
398
- }
399
- pipeline.push(
400
- { $group: { _id: `$${field}`, count: { $sum: 1 } } },
401
- { $sort: { count: -1 } }
402
- );
403
- return aggregate(Model, pipeline, options);
404
- }
405
- async function lookup(Model, lookupOptions) {
406
- const { from, localField, foreignField, as, pipeline = [], query = {}, options = {} } = lookupOptions;
407
- const aggPipeline = [];
408
- if (Object.keys(query).length > 0) {
409
- aggPipeline.push({ $match: query });
410
- }
411
- aggPipeline.push({
412
- $lookup: {
413
- from,
414
- localField,
415
- foreignField,
416
- as,
417
- ...pipeline.length > 0 ? { pipeline } : {}
418
- }
419
- });
420
- return aggregate(Model, aggPipeline, options);
421
- }
422
- async function unwind(Model, field, options = {}) {
423
- const pipeline = [
424
- {
425
- $unwind: {
426
- path: `$${field}`,
427
- preserveNullAndEmptyArrays: options.preserveEmpty !== false
137
+ } else {
138
+ if (!localField || !foreignField) {
139
+ throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
428
140
  }
141
+ lookupStage = {
142
+ $lookup: {
143
+ from,
144
+ localField,
145
+ foreignField,
146
+ as: outputField
147
+ }
148
+ };
429
149
  }
430
- ];
431
- return aggregate(Model, pipeline, { session: options.session });
432
- }
433
- async function facet(Model, facets, options = {}) {
434
- const pipeline = [{ $facet: facets }];
435
- return aggregate(Model, pipeline, options);
436
- }
437
- async function distinct(Model, field, query = {}, options = {}) {
438
- return Model.distinct(field, query).session(options.session ?? null);
439
- }
440
- async function sum(Model, field, query = {}, options = {}) {
441
- const pipeline = [];
442
- if (Object.keys(query).length > 0) {
443
- pipeline.push({ $match: query });
444
- }
445
- pipeline.push({
446
- $group: {
447
- _id: null,
448
- total: { $sum: `$${field}` }
449
- }
450
- });
451
- const result = await aggregate(Model, pipeline, options);
452
- return result[0]?.total || 0;
453
- }
454
- async function average(Model, field, query = {}, options = {}) {
455
- const pipeline = [];
456
- if (Object.keys(query).length > 0) {
457
- pipeline.push({ $match: query });
458
- }
459
- pipeline.push({
460
- $group: {
461
- _id: null,
462
- average: { $avg: `$${field}` }
463
- }
464
- });
465
- const result = await aggregate(Model, pipeline, options);
466
- return result[0]?.average || 0;
467
- }
468
- async function minMax(Model, field, query = {}, options = {}) {
469
- const pipeline = [];
470
- if (Object.keys(query).length > 0) {
471
- pipeline.push({ $match: query });
472
- }
473
- pipeline.push({
474
- $group: {
475
- _id: null,
476
- min: { $min: `$${field}` },
477
- max: { $max: `$${field}` }
150
+ stages.push(lookupStage);
151
+ if (single) {
152
+ stages.push({
153
+ $unwind: {
154
+ path: `$${outputField}`,
155
+ preserveNullAndEmptyArrays: true
156
+ // Keep documents even if no match found
157
+ }
158
+ });
478
159
  }
479
- });
480
- const result = await aggregate(Model, pipeline, options);
481
- return result[0] || { min: null, max: null };
482
- }
483
- function encodeCursor(doc, primaryField, sort, version = 1) {
484
- const primaryValue = doc[primaryField];
485
- const idValue = doc._id;
486
- const payload = {
487
- v: serializeValue(primaryValue),
488
- t: getValueType(primaryValue),
489
- id: serializeValue(idValue),
490
- idType: getValueType(idValue),
491
- sort,
492
- ver: version
493
- };
494
- return Buffer.from(JSON.stringify(payload)).toString("base64");
495
- }
496
- function decodeCursor(token) {
497
- try {
498
- const json = Buffer.from(token, "base64").toString("utf-8");
499
- const payload = JSON.parse(json);
500
- return {
501
- value: rehydrateValue(payload.v, payload.t),
502
- id: rehydrateValue(payload.id, payload.idType),
503
- sort: payload.sort,
504
- version: payload.ver
505
- };
506
- } catch {
507
- throw new Error("Invalid cursor token");
160
+ return stages;
508
161
  }
509
- }
510
- function validateCursorSort(cursorSort, currentSort) {
511
- const cursorSortStr = JSON.stringify(cursorSort);
512
- const currentSortStr = JSON.stringify(currentSort);
513
- if (cursorSortStr !== currentSortStr) {
514
- throw new Error("Cursor sort does not match current query sort");
162
+ /**
163
+ * Build and return only the $lookup stage (without $unwind)
164
+ * Useful when you want to handle unwrapping yourself
165
+ */
166
+ buildLookupOnly() {
167
+ const stages = this.build();
168
+ return stages[0];
515
169
  }
516
- }
517
- function validateCursorVersion(cursorVersion, expectedVersion) {
518
- if (cursorVersion !== expectedVersion) {
519
- throw new Error(`Cursor version ${cursorVersion} does not match expected version ${expectedVersion}`);
170
+ /**
171
+ * Static helper: Create a simple lookup in one line
172
+ */
173
+ static simple(from, localField, foreignField, options = {}) {
174
+ return new _LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
520
175
  }
521
- }
522
- function serializeValue(value) {
523
- if (value instanceof Date) return value.toISOString();
524
- if (value instanceof mongoose4.Types.ObjectId) return value.toString();
525
- return value;
526
- }
527
- function getValueType(value) {
528
- if (value instanceof Date) return "date";
529
- if (value instanceof mongoose4.Types.ObjectId) return "objectid";
530
- if (typeof value === "boolean") return "boolean";
531
- if (typeof value === "number") return "number";
532
- if (typeof value === "string") return "string";
533
- return "unknown";
534
- }
535
- function rehydrateValue(serialized, type) {
536
- switch (type) {
537
- case "date":
538
- return new Date(serialized);
539
- case "objectid":
540
- return new mongoose4.Types.ObjectId(serialized);
541
- case "boolean":
542
- return Boolean(serialized);
543
- case "number":
544
- return Number(serialized);
545
- default:
546
- return serialized;
176
+ /**
177
+ * Static helper: Create multiple lookups at once
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * const pipeline = LookupBuilder.multiple([
182
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
183
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
184
+ * ]);
185
+ * ```
186
+ */
187
+ static multiple(lookups) {
188
+ return lookups.flatMap((lookup) => {
189
+ const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
190
+ if (lookup.as) builder.as(lookup.as);
191
+ if (lookup.single) builder.single(lookup.single);
192
+ if (lookup.pipeline) builder.pipeline(lookup.pipeline);
193
+ if (lookup.let) builder.let(lookup.let);
194
+ return builder.build();
195
+ });
547
196
  }
548
- }
197
+ /**
198
+ * Static helper: Create a nested lookup (lookup within lookup)
199
+ * Useful for multi-level joins like Order -> Product -> Category
200
+ *
201
+ * @example
202
+ * ```typescript
203
+ * // Join orders with products, then products with categories
204
+ * const pipeline = LookupBuilder.nested([
205
+ * { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
206
+ * { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
207
+ * ]);
208
+ * ```
209
+ */
210
+ static nested(lookups) {
211
+ return lookups.flatMap((lookup, index) => {
212
+ const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
213
+ if (lookup.as) builder.as(lookup.as);
214
+ if (lookup.single !== void 0) builder.single(lookup.single);
215
+ if (lookup.pipeline) builder.pipeline(lookup.pipeline);
216
+ if (lookup.let) builder.let(lookup.let);
217
+ return builder.build();
218
+ });
219
+ }
220
+ };
549
221
 
550
- // src/pagination/utils/sort.ts
551
- function normalizeSort(sort) {
222
+ // src/query/AggregationBuilder.ts
223
+ function normalizeSortSpec(sortSpec) {
552
224
  const normalized = {};
553
- Object.keys(sort).forEach((key) => {
554
- if (key !== "_id") normalized[key] = sort[key];
555
- });
556
- if (sort._id !== void 0) {
557
- normalized._id = sort._id;
225
+ for (const [field, order] of Object.entries(sortSpec)) {
226
+ if (order === "asc") {
227
+ normalized[field] = 1;
228
+ } else if (order === "desc") {
229
+ normalized[field] = -1;
230
+ } else {
231
+ normalized[field] = order;
232
+ }
558
233
  }
559
234
  return normalized;
560
235
  }
561
- function validateKeysetSort(sort) {
562
- const keys = Object.keys(sort);
563
- if (keys.length === 1 && keys[0] !== "_id") {
564
- const field = keys[0];
565
- const direction = sort[field];
566
- return normalizeSort({ [field]: direction, _id: direction });
567
- }
568
- if (keys.length === 1 && keys[0] === "_id") {
569
- return normalizeSort(sort);
570
- }
571
- if (keys.length === 2) {
572
- if (!keys.includes("_id")) {
573
- throw new Error("Keyset pagination requires _id as tie-breaker");
574
- }
575
- const primaryField = keys.find((k) => k !== "_id");
576
- const primaryDirection = sort[primaryField];
577
- const idDirection = sort._id;
578
- if (primaryDirection !== idDirection) {
579
- throw new Error("_id direction must match primary field direction");
580
- }
581
- return normalizeSort(sort);
236
+ var AggregationBuilder = class _AggregationBuilder {
237
+ pipeline = [];
238
+ /**
239
+ * Get the current pipeline
240
+ */
241
+ get() {
242
+ return [...this.pipeline];
582
243
  }
583
- throw new Error("Keyset pagination only supports single field + _id");
584
- }
585
- function getPrimaryField(sort) {
586
- const keys = Object.keys(sort);
587
- return keys.find((k) => k !== "_id") || "_id";
588
- }
589
-
590
- // src/pagination/utils/filter.ts
591
- function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
592
- const primaryField = Object.keys(sort).find((k) => k !== "_id") || "_id";
593
- const direction = sort[primaryField];
594
- const operator = direction === 1 ? "$gt" : "$lt";
595
- return {
596
- ...baseFilters,
597
- $or: [
598
- { [primaryField]: { [operator]: cursorValue } },
599
- {
600
- [primaryField]: cursorValue,
601
- _id: { [operator]: cursorId }
602
- }
603
- ]
604
- };
605
- }
606
-
607
- // src/pagination/utils/limits.ts
608
- function validateLimit(limit, config) {
609
- const parsed = Number(limit);
610
- if (!Number.isFinite(parsed) || parsed < 1) {
611
- return config.defaultLimit || 10;
244
+ /**
245
+ * Build and return the final pipeline
246
+ */
247
+ build() {
248
+ return this.get();
612
249
  }
613
- return Math.min(Math.floor(parsed), config.maxLimit || 100);
614
- }
615
- function validatePage(page, config) {
616
- const parsed = Number(page);
617
- if (!Number.isFinite(parsed) || parsed < 1) {
618
- return 1;
250
+ /**
251
+ * Reset the pipeline
252
+ */
253
+ reset() {
254
+ this.pipeline = [];
255
+ return this;
619
256
  }
620
- const sanitized = Math.floor(parsed);
621
- if (sanitized > (config.maxPage || 1e4)) {
622
- throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage || 1e4}`);
257
+ /**
258
+ * Add a raw pipeline stage
259
+ */
260
+ addStage(stage) {
261
+ this.pipeline.push(stage);
262
+ return this;
623
263
  }
624
- return sanitized;
625
- }
626
- function shouldWarnDeepPagination(page, threshold) {
627
- return page > threshold;
628
- }
629
- function calculateSkip(page, limit) {
630
- return (page - 1) * limit;
631
- }
632
- function calculateTotalPages(total, limit) {
633
- return Math.ceil(total / limit);
634
- }
635
-
636
- // src/pagination/PaginationEngine.ts
637
- var PaginationEngine = class {
638
- Model;
639
- config;
640
264
  /**
641
- * Create a new pagination engine
642
- *
643
- * @param Model - Mongoose model to paginate
644
- * @param config - Pagination configuration
265
+ * Add multiple raw pipeline stages
645
266
  */
646
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
647
- constructor(Model, config = {}) {
648
- this.Model = Model;
649
- this.config = {
650
- defaultLimit: config.defaultLimit || 10,
651
- maxLimit: config.maxLimit || 100,
652
- maxPage: config.maxPage || 1e4,
653
- deepPageThreshold: config.deepPageThreshold || 100,
654
- cursorVersion: config.cursorVersion || 1,
655
- useEstimatedCount: config.useEstimatedCount || false
656
- };
267
+ addStages(stages) {
268
+ this.pipeline.push(...stages);
269
+ return this;
657
270
  }
271
+ // ============================================================
272
+ // CORE AGGREGATION STAGES
273
+ // ============================================================
658
274
  /**
659
- * Offset-based pagination using skip/limit
660
- * Best for small datasets and when users need random page access
661
- * O(n) performance - slower for deep pages
662
- *
663
- * @param options - Pagination options
664
- * @returns Pagination result with total count
665
- *
666
- * @example
667
- * const result = await engine.paginate({
668
- * filters: { status: 'active' },
669
- * sort: { createdAt: -1 },
670
- * page: 1,
671
- * limit: 20
672
- * });
673
- * console.log(result.docs, result.total, result.hasNext);
275
+ * $match - Filter documents
276
+ * IMPORTANT: Place $match as early as possible for performance
674
277
  */
675
- async paginate(options = {}) {
676
- const {
677
- filters = {},
678
- sort = { _id: -1 },
679
- page = 1,
680
- limit = this.config.defaultLimit,
681
- select,
682
- populate = [],
683
- lean = true,
684
- session
685
- } = options;
686
- const sanitizedPage = validatePage(page, this.config);
687
- const sanitizedLimit = validateLimit(limit, this.config);
688
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
689
- let query = this.Model.find(filters);
690
- if (select) query = query.select(select);
691
- if (populate && (Array.isArray(populate) ? populate.length : populate)) {
692
- query = query.populate(populate);
693
- }
694
- query = query.sort(sort).skip(skip).limit(sanitizedLimit).lean(lean);
695
- if (session) query = query.session(session);
696
- const hasFilters = Object.keys(filters).length > 0;
697
- const useEstimated = this.config.useEstimatedCount && !hasFilters;
698
- const [docs, total] = await Promise.all([
699
- query.exec(),
700
- useEstimated ? this.Model.estimatedDocumentCount() : this.Model.countDocuments(filters).session(session ?? null)
701
- ]);
702
- const totalPages = calculateTotalPages(total, sanitizedLimit);
703
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : void 0;
704
- return {
705
- method: "offset",
706
- docs,
707
- page: sanitizedPage,
708
- limit: sanitizedLimit,
709
- total,
710
- pages: totalPages,
711
- hasNext: sanitizedPage < totalPages,
712
- hasPrev: sanitizedPage > 1,
713
- ...warning && { warning }
714
- };
278
+ match(query) {
279
+ this.pipeline.push({ $match: query });
280
+ return this;
715
281
  }
716
282
  /**
717
- * Keyset (cursor-based) pagination for high-performance streaming
718
- * Best for large datasets, infinite scroll, real-time feeds
719
- * O(1) performance - consistent speed regardless of position
720
- *
721
- * @param options - Pagination options (sort is required)
722
- * @returns Pagination result with next cursor
723
- *
724
- * @example
725
- * // First page
726
- * const page1 = await engine.stream({
727
- * sort: { createdAt: -1 },
728
- * limit: 20
729
- * });
730
- *
731
- * // Next page using cursor
732
- * const page2 = await engine.stream({
733
- * sort: { createdAt: -1 },
734
- * after: page1.next,
735
- * limit: 20
736
- * });
283
+ * $project - Include/exclude fields or compute new fields
737
284
  */
738
- async stream(options) {
739
- const {
740
- filters = {},
741
- sort,
742
- after,
743
- limit = this.config.defaultLimit,
744
- select,
745
- populate = [],
746
- lean = true,
747
- session
748
- } = options;
749
- if (!sort) {
750
- throw createError(400, "sort is required for keyset pagination");
751
- }
752
- const sanitizedLimit = validateLimit(limit, this.config);
753
- const normalizedSort = validateKeysetSort(sort);
754
- let query = { ...filters };
755
- if (after) {
756
- const cursor = decodeCursor(after);
757
- validateCursorVersion(cursor.version, this.config.cursorVersion);
758
- validateCursorSort(cursor.sort, normalizedSort);
759
- query = buildKeysetFilter(query, normalizedSort, cursor.value, cursor.id);
760
- }
761
- let mongoQuery = this.Model.find(query);
762
- if (select) mongoQuery = mongoQuery.select(select);
763
- if (populate && (Array.isArray(populate) ? populate.length : populate)) {
764
- mongoQuery = mongoQuery.populate(populate);
765
- }
766
- mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
767
- if (session) mongoQuery = mongoQuery.session(session);
768
- const docs = await mongoQuery.exec();
769
- const hasMore = docs.length > sanitizedLimit;
770
- if (hasMore) docs.pop();
771
- const primaryField = getPrimaryField(normalizedSort);
772
- const nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null;
773
- return {
774
- method: "keyset",
775
- docs,
776
- limit: sanitizedLimit,
777
- hasMore,
778
- next: nextCursor
779
- };
285
+ project(projection) {
286
+ this.pipeline.push({ $project: projection });
287
+ return this;
780
288
  }
781
289
  /**
782
- * Aggregate pipeline with pagination
783
- * Best for complex queries requiring aggregation stages
784
- * Uses $facet to combine results and count in single query
785
- *
786
- * @param options - Aggregation options
787
- * @returns Pagination result with total count
290
+ * $group - Group documents and compute aggregations
788
291
  *
789
292
  * @example
790
- * const result = await engine.aggregatePaginate({
791
- * pipeline: [
792
- * { $match: { status: 'active' } },
793
- * { $group: { _id: '$category', count: { $sum: 1 } } },
794
- * { $sort: { count: -1 } }
795
- * ],
796
- * page: 1,
797
- * limit: 20
798
- * });
293
+ * ```typescript
294
+ * .group({
295
+ * _id: '$department',
296
+ * count: { $sum: 1 },
297
+ * avgSalary: { $avg: '$salary' }
298
+ * })
299
+ * ```
799
300
  */
800
- async aggregatePaginate(options = {}) {
801
- const {
802
- pipeline = [],
803
- page = 1,
804
- limit = this.config.defaultLimit,
805
- session
806
- } = options;
807
- const sanitizedPage = validatePage(page, this.config);
808
- const sanitizedLimit = validateLimit(limit, this.config);
809
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
810
- const facetPipeline = [
811
- ...pipeline,
812
- {
813
- $facet: {
814
- docs: [{ $skip: skip }, { $limit: sanitizedLimit }],
815
- total: [{ $count: "count" }]
816
- }
817
- }
818
- ];
819
- const aggregation = this.Model.aggregate(facetPipeline);
820
- if (session) aggregation.session(session);
821
- const [result] = await aggregation.exec();
822
- const docs = result.docs;
823
- const total = result.total[0]?.count || 0;
824
- const totalPages = calculateTotalPages(total, sanitizedLimit);
825
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
826
- return {
827
- method: "aggregate",
828
- docs,
829
- page: sanitizedPage,
830
- limit: sanitizedLimit,
831
- total,
832
- pages: totalPages,
833
- hasNext: sanitizedPage < totalPages,
834
- hasPrev: sanitizedPage > 1,
835
- ...warning && { warning }
836
- };
837
- }
838
- };
839
-
840
- // src/Repository.ts
841
- var Repository = class {
842
- Model;
843
- model;
844
- _hooks;
845
- _pagination;
846
- _hookMode;
847
- constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
848
- this.Model = Model;
849
- this.model = Model.modelName;
850
- this._hooks = /* @__PURE__ */ new Map();
851
- this._pagination = new PaginationEngine(Model, paginationConfig);
852
- this._hookMode = options.hooks ?? "async";
853
- plugins.forEach((plugin) => this.use(plugin));
301
+ group(groupSpec) {
302
+ this.pipeline.push({ $group: groupSpec });
303
+ return this;
854
304
  }
855
305
  /**
856
- * Register a plugin
306
+ * $sort - Sort documents
857
307
  */
858
- use(plugin) {
859
- if (typeof plugin === "function") {
860
- plugin(this);
861
- } else if (plugin && typeof plugin.apply === "function") {
862
- plugin.apply(this);
308
+ sort(sortSpec) {
309
+ if (typeof sortSpec === "string") {
310
+ const order = sortSpec.startsWith("-") ? -1 : 1;
311
+ const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
312
+ this.pipeline.push({ $sort: { [field]: order } });
313
+ } else {
314
+ this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
863
315
  }
864
316
  return this;
865
317
  }
866
318
  /**
867
- * Register event listener
319
+ * $limit - Limit number of documents
868
320
  */
869
- on(event, listener) {
870
- if (!this._hooks.has(event)) {
871
- this._hooks.set(event, []);
872
- }
873
- this._hooks.get(event).push(listener);
321
+ limit(count2) {
322
+ this.pipeline.push({ $limit: count2 });
874
323
  return this;
875
324
  }
876
325
  /**
877
- * Emit event (sync - for backwards compatibility)
326
+ * $skip - Skip documents
878
327
  */
879
- emit(event, data) {
880
- const listeners = this._hooks.get(event) || [];
881
- for (const listener of listeners) {
882
- try {
883
- const result = listener(data);
884
- if (result && typeof result.then === "function") {
885
- void result.catch((error) => {
886
- if (event === "error:hook") return;
887
- const err = error instanceof Error ? error : new Error(String(error));
888
- this.emit("error:hook", { event, error: err });
889
- });
890
- }
891
- } catch (error) {
892
- if (event === "error:hook") continue;
893
- const err = error instanceof Error ? error : new Error(String(error));
894
- this.emit("error:hook", { event, error: err });
895
- }
896
- }
328
+ skip(count2) {
329
+ this.pipeline.push({ $skip: count2 });
330
+ return this;
897
331
  }
898
332
  /**
899
- * Emit event and await all async handlers
333
+ * $unwind - Deconstruct array field
334
+ */
335
+ unwind(path, preserveNullAndEmptyArrays = false) {
336
+ this.pipeline.push({
337
+ $unwind: {
338
+ path: path.startsWith("$") ? path : `$${path}`,
339
+ preserveNullAndEmptyArrays
340
+ }
341
+ });
342
+ return this;
343
+ }
344
+ /**
345
+ * $addFields - Add new fields or replace existing fields
346
+ */
347
+ addFields(fields) {
348
+ this.pipeline.push({ $addFields: fields });
349
+ return this;
350
+ }
351
+ /**
352
+ * $set - Alias for $addFields
353
+ */
354
+ set(fields) {
355
+ return this.addFields(fields);
356
+ }
357
+ /**
358
+ * $unset - Remove fields
359
+ */
360
+ unset(fields) {
361
+ this.pipeline.push({ $unset: fields });
362
+ return this;
363
+ }
364
+ /**
365
+ * $replaceRoot - Replace the root document
366
+ */
367
+ replaceRoot(newRoot) {
368
+ this.pipeline.push({
369
+ $replaceRoot: {
370
+ newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot
371
+ }
372
+ });
373
+ return this;
374
+ }
375
+ // ============================================================
376
+ // LOOKUP (JOINS)
377
+ // ============================================================
378
+ /**
379
+ * $lookup - Join with another collection (simple form)
380
+ *
381
+ * @param from - Collection to join with
382
+ * @param localField - Field from source collection
383
+ * @param foreignField - Field from target collection
384
+ * @param as - Output field name
385
+ * @param single - Unwrap array to single object
386
+ *
387
+ * @example
388
+ * ```typescript
389
+ * // Join employees with departments by slug
390
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
391
+ * ```
392
+ */
393
+ lookup(from, localField, foreignField, as, single) {
394
+ const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
395
+ this.pipeline.push(...stages);
396
+ return this;
397
+ }
398
+ /**
399
+ * $lookup - Join with another collection (advanced form with pipeline)
400
+ *
401
+ * @example
402
+ * ```typescript
403
+ * .lookupWithPipeline({
404
+ * from: 'products',
405
+ * localField: 'productIds',
406
+ * foreignField: 'sku',
407
+ * as: 'products',
408
+ * pipeline: [
409
+ * { $match: { status: 'active' } },
410
+ * { $project: { name: 1, price: 1 } }
411
+ * ]
412
+ * })
413
+ * ```
414
+ */
415
+ lookupWithPipeline(options) {
416
+ const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
417
+ if (options.as) builder.as(options.as);
418
+ if (options.single) builder.single(options.single);
419
+ if (options.pipeline) builder.pipeline(options.pipeline);
420
+ if (options.let) builder.let(options.let);
421
+ this.pipeline.push(...builder.build());
422
+ return this;
423
+ }
424
+ /**
425
+ * Multiple lookups at once
426
+ *
427
+ * @example
428
+ * ```typescript
429
+ * .multiLookup([
430
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
431
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
432
+ * ])
433
+ * ```
434
+ */
435
+ multiLookup(lookups) {
436
+ const stages = LookupBuilder.multiple(lookups);
437
+ this.pipeline.push(...stages);
438
+ return this;
439
+ }
440
+ // ============================================================
441
+ // ADVANCED OPERATORS (MongoDB 6+)
442
+ // ============================================================
443
+ /**
444
+ * $facet - Process multiple aggregation pipelines in a single stage
445
+ * Useful for computing multiple aggregations in parallel
446
+ *
447
+ * @example
448
+ * ```typescript
449
+ * .facet({
450
+ * totalCount: [{ $count: 'count' }],
451
+ * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
452
+ * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
453
+ * })
454
+ * ```
455
+ */
456
+ facet(facets) {
457
+ this.pipeline.push({ $facet: facets });
458
+ return this;
459
+ }
460
+ /**
461
+ * $bucket - Categorize documents into buckets
462
+ *
463
+ * @example
464
+ * ```typescript
465
+ * .bucket({
466
+ * groupBy: '$price',
467
+ * boundaries: [0, 50, 100, 200],
468
+ * default: 'Other',
469
+ * output: {
470
+ * count: { $sum: 1 },
471
+ * products: { $push: '$name' }
472
+ * }
473
+ * })
474
+ * ```
475
+ */
476
+ bucket(options) {
477
+ this.pipeline.push({ $bucket: options });
478
+ return this;
479
+ }
480
+ /**
481
+ * $bucketAuto - Automatically determine bucket boundaries
482
+ */
483
+ bucketAuto(options) {
484
+ this.pipeline.push({ $bucketAuto: options });
485
+ return this;
486
+ }
487
+ /**
488
+ * $setWindowFields - Perform window functions (MongoDB 5.0+)
489
+ * Useful for rankings, running totals, moving averages
490
+ *
491
+ * @example
492
+ * ```typescript
493
+ * .setWindowFields({
494
+ * partitionBy: '$department',
495
+ * sortBy: { salary: -1 },
496
+ * output: {
497
+ * rank: { $rank: {} },
498
+ * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
499
+ * }
500
+ * })
501
+ * ```
502
+ */
503
+ setWindowFields(options) {
504
+ const normalizedOptions = {
505
+ ...options,
506
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
507
+ };
508
+ this.pipeline.push({ $setWindowFields: normalizedOptions });
509
+ return this;
510
+ }
511
+ /**
512
+ * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * .unionWith({
517
+ * coll: 'archivedOrders',
518
+ * pipeline: [{ $match: { year: 2024 } }]
519
+ * })
520
+ * ```
521
+ */
522
+ unionWith(options) {
523
+ this.pipeline.push({ $unionWith: options });
524
+ return this;
525
+ }
526
+ /**
527
+ * $densify - Fill gaps in data (MongoDB 5.1+)
528
+ * Useful for time series data with missing points
529
+ */
530
+ densify(options) {
531
+ this.pipeline.push({ $densify: options });
532
+ return this;
533
+ }
534
+ /**
535
+ * $fill - Fill null or missing field values (MongoDB 5.3+)
536
+ */
537
+ fill(options) {
538
+ const normalizedOptions = {
539
+ ...options,
540
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
541
+ };
542
+ this.pipeline.push({ $fill: normalizedOptions });
543
+ return this;
544
+ }
545
+ // ============================================================
546
+ // UTILITY METHODS
547
+ // ============================================================
548
+ /**
549
+ * Paginate - Add skip and limit for offset-based pagination
550
+ */
551
+ paginate(page, limit) {
552
+ const skip = (page - 1) * limit;
553
+ return this.skip(skip).limit(limit);
554
+ }
555
+ /**
556
+ * Count total documents (useful with $facet for pagination metadata)
557
+ */
558
+ count(outputField = "count") {
559
+ this.pipeline.push({ $count: outputField });
560
+ return this;
561
+ }
562
+ /**
563
+ * Sample - Randomly select N documents
564
+ */
565
+ sample(size) {
566
+ this.pipeline.push({ $sample: { size } });
567
+ return this;
568
+ }
569
+ /**
570
+ * Out - Write results to a collection
571
+ */
572
+ out(collection) {
573
+ this.pipeline.push({ $out: collection });
574
+ return this;
575
+ }
576
+ /**
577
+ * Merge - Merge results into a collection
578
+ */
579
+ merge(options) {
580
+ this.pipeline.push({
581
+ $merge: typeof options === "string" ? { into: options } : options
582
+ });
583
+ return this;
584
+ }
585
+ /**
586
+ * GeoNear - Perform geospatial queries
587
+ */
588
+ geoNear(options) {
589
+ this.pipeline.push({ $geoNear: options });
590
+ return this;
591
+ }
592
+ /**
593
+ * GraphLookup - Perform recursive search (graph traversal)
594
+ */
595
+ graphLookup(options) {
596
+ this.pipeline.push({ $graphLookup: options });
597
+ return this;
598
+ }
599
+ // ============================================================
600
+ // ATLAS SEARCH (MongoDB Atlas only)
601
+ // ============================================================
602
+ /**
603
+ * $search - Atlas Search full-text search (Atlas only)
604
+ *
605
+ * @example
606
+ * ```typescript
607
+ * .search({
608
+ * index: 'default',
609
+ * text: {
610
+ * query: 'laptop computer',
611
+ * path: ['title', 'description'],
612
+ * fuzzy: { maxEdits: 2 }
613
+ * }
614
+ * })
615
+ * ```
616
+ */
617
+ search(options) {
618
+ this.pipeline.push({ $search: options });
619
+ return this;
620
+ }
621
+ /**
622
+ * $searchMeta - Get Atlas Search metadata (Atlas only)
623
+ */
624
+ searchMeta(options) {
625
+ this.pipeline.push({ $searchMeta: options });
626
+ return this;
627
+ }
628
+ // ============================================================
629
+ // HELPER FACTORY METHODS
630
+ // ============================================================
631
+ /**
632
+ * Create a builder from an existing pipeline
633
+ */
634
+ static from(pipeline) {
635
+ const builder = new _AggregationBuilder();
636
+ builder.pipeline = [...pipeline];
637
+ return builder;
638
+ }
639
+ /**
640
+ * Create a builder with initial match stage
641
+ */
642
+ static startWith(query) {
643
+ return new _AggregationBuilder().match(query);
644
+ }
645
+ };
646
+
647
+ // src/Repository.ts
648
+ var Repository = class {
649
+ Model;
650
+ model;
651
+ _hooks;
652
+ _pagination;
653
+ _hookMode;
654
+ constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
655
+ this.Model = Model;
656
+ this.model = Model.modelName;
657
+ this._hooks = /* @__PURE__ */ new Map();
658
+ this._pagination = new PaginationEngine(Model, paginationConfig);
659
+ this._hookMode = options.hooks ?? "async";
660
+ plugins.forEach((plugin) => this.use(plugin));
661
+ }
662
+ /**
663
+ * Register a plugin
664
+ */
665
+ use(plugin) {
666
+ if (typeof plugin === "function") {
667
+ plugin(this);
668
+ } else if (plugin && typeof plugin.apply === "function") {
669
+ plugin.apply(this);
670
+ }
671
+ return this;
672
+ }
673
+ /**
674
+ * Register event listener
675
+ */
676
+ on(event, listener) {
677
+ if (!this._hooks.has(event)) {
678
+ this._hooks.set(event, []);
679
+ }
680
+ this._hooks.get(event).push(listener);
681
+ return this;
682
+ }
683
+ /**
684
+ * Emit event (sync - for backwards compatibility)
685
+ */
686
+ emit(event, data) {
687
+ const listeners = this._hooks.get(event) || [];
688
+ for (const listener of listeners) {
689
+ try {
690
+ const result = listener(data);
691
+ if (result && typeof result.then === "function") {
692
+ void result.catch((error) => {
693
+ if (event === "error:hook") return;
694
+ const err = error instanceof Error ? error : new Error(String(error));
695
+ this.emit("error:hook", { event, error: err });
696
+ });
697
+ }
698
+ } catch (error) {
699
+ if (event === "error:hook") continue;
700
+ const err = error instanceof Error ? error : new Error(String(error));
701
+ this.emit("error:hook", { event, error: err });
702
+ }
703
+ }
704
+ }
705
+ /**
706
+ * Emit event and await all async handlers
900
707
  */
901
708
  async emitAsync(event, data) {
902
709
  const listeners = this._hooks.get(event) || [];
@@ -965,7 +772,8 @@ var Repository = class {
965
772
  if (context._cacheHit) {
966
773
  return context._cachedResult;
967
774
  }
968
- const result = await getByQuery(this.Model, query, context);
775
+ const finalQuery = context.query || query;
776
+ const result = await getByQuery(this.Model, finalQuery, context);
969
777
  await this._emitHook("after:getByQuery", { context, result });
970
778
  return result;
971
779
  }
@@ -999,8 +807,8 @@ var Repository = class {
999
807
  }
1000
808
  const hasPageParam = params.page !== void 0 || params.pagination;
1001
809
  const hasCursorParam = "cursor" in params || "after" in params;
1002
- const hasExplicitSort = params.sort !== void 0;
1003
- const useKeyset = !hasPageParam && (hasCursorParam || hasExplicitSort);
810
+ const hasSortParam = params.sort !== void 0;
811
+ const useKeyset = !hasPageParam && (hasCursorParam || hasSortParam);
1004
812
  const filters = context.filters || params.filters || {};
1005
813
  const search = params.search;
1006
814
  const sort = params.sort || "-createdAt";
@@ -1106,1573 +914,246 @@ var Repository = class {
1106
914
  return distinct(this.Model, field, query, options);
1107
915
  }
1108
916
  /**
1109
- * Execute callback within a transaction
917
+ * Query with custom field lookups ($lookup)
918
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
919
+ *
920
+ * @example
921
+ * ```typescript
922
+ * // Join employees with departments using slug instead of ObjectId
923
+ * const employees = await employeeRepo.lookupPopulate({
924
+ * filters: { status: 'active' },
925
+ * lookups: [
926
+ * {
927
+ * from: 'departments',
928
+ * localField: 'departmentSlug',
929
+ * foreignField: 'slug',
930
+ * as: 'department',
931
+ * single: true
932
+ * }
933
+ * ],
934
+ * sort: '-createdAt',
935
+ * page: 1,
936
+ * limit: 50
937
+ * });
938
+ * ```
1110
939
  */
1111
- async withTransaction(callback, options = {}) {
1112
- const session = await mongoose4.startSession();
1113
- let started = false;
940
+ async lookupPopulate(options) {
941
+ const context = await this._buildContext("lookupPopulate", options);
1114
942
  try {
1115
- session.startTransaction();
1116
- started = true;
1117
- const result = await callback(session);
1118
- await session.commitTransaction();
1119
- return result;
1120
- } catch (error) {
1121
- const err = error;
1122
- if (options.allowFallback && this._isTransactionUnsupported(err)) {
1123
- if (typeof options.onFallback === "function") {
1124
- options.onFallback(err);
1125
- }
1126
- if (started && session.inTransaction()) {
1127
- try {
1128
- await session.abortTransaction();
1129
- } catch {
1130
- }
1131
- }
1132
- return await callback(null);
943
+ const builder = new AggregationBuilder();
944
+ if (options.filters && Object.keys(options.filters).length > 0) {
945
+ builder.match(options.filters);
1133
946
  }
1134
- if (started && session.inTransaction()) {
1135
- await session.abortTransaction();
947
+ builder.multiLookup(options.lookups);
948
+ if (options.sort) {
949
+ builder.sort(this._parseSort(options.sort));
1136
950
  }
1137
- throw err;
1138
- } finally {
1139
- session.endSession();
1140
- }
1141
- }
1142
- _isTransactionUnsupported(error) {
1143
- const message = (error.message || "").toLowerCase();
1144
- return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
1145
- }
1146
- /**
1147
- * Execute custom query with event emission
1148
- */
1149
- async _executeQuery(buildQuery) {
1150
- const operation = buildQuery.name || "custom";
1151
- const context = await this._buildContext(operation, {});
1152
- try {
1153
- const result = await buildQuery(this.Model);
1154
- await this._emitHook(`after:${operation}`, { context, result });
1155
- return result;
1156
- } catch (error) {
1157
- await this._emitErrorHook(`error:${operation}`, { context, error });
1158
- throw this._handleError(error);
1159
- }
1160
- }
1161
- /**
1162
- * Build operation context and run before hooks
1163
- */
1164
- async _buildContext(operation, options) {
1165
- const context = { operation, model: this.model, ...options };
1166
- const event = `before:${operation}`;
1167
- const hooks = this._hooks.get(event) || [];
1168
- for (const hook of hooks) {
1169
- await hook(context);
1170
- }
1171
- return context;
1172
- }
1173
- /**
1174
- * Parse sort string or object
1175
- */
1176
- _parseSort(sort) {
1177
- if (!sort) return { createdAt: -1 };
1178
- if (typeof sort === "object") return sort;
1179
- const sortOrder = sort.startsWith("-") ? -1 : 1;
1180
- const sortField = sort.startsWith("-") ? sort.substring(1) : sort;
1181
- return { [sortField]: sortOrder };
1182
- }
1183
- /**
1184
- * Parse populate specification
1185
- */
1186
- _parsePopulate(populate) {
1187
- if (!populate) return [];
1188
- if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
1189
- if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
1190
- return [populate];
1191
- }
1192
- /**
1193
- * Handle errors with proper HTTP status codes
1194
- */
1195
- _handleError(error) {
1196
- if (error instanceof mongoose4.Error.ValidationError) {
1197
- const messages = Object.values(error.errors).map((err) => err.message);
1198
- return createError(400, `Validation Error: ${messages.join(", ")}`);
1199
- }
1200
- if (error instanceof mongoose4.Error.CastError) {
1201
- return createError(400, `Invalid ${error.path}: ${error.value}`);
1202
- }
1203
- if (error.status && error.message) return error;
1204
- return createError(500, error.message || "Internal Server Error");
1205
- }
1206
- };
1207
-
1208
- // src/utils/field-selection.ts
1209
- function getFieldsForUser(user, preset) {
1210
- if (!preset) {
1211
- throw new Error("Field preset is required");
1212
- }
1213
- const fields = [...preset.public || []];
1214
- if (user) {
1215
- fields.push(...preset.authenticated || []);
1216
- const roles = Array.isArray(user.roles) ? user.roles : user.roles ? [user.roles] : [];
1217
- if (roles.includes("admin") || roles.includes("superadmin")) {
1218
- fields.push(...preset.admin || []);
1219
- }
1220
- }
1221
- return [...new Set(fields)];
1222
- }
1223
- function getMongooseProjection(user, preset) {
1224
- const fields = getFieldsForUser(user, preset);
1225
- return fields.join(" ");
1226
- }
1227
- function filterObject(obj, allowedFields) {
1228
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
1229
- return obj;
1230
- }
1231
- const filtered = {};
1232
- for (const field of allowedFields) {
1233
- if (field in obj) {
1234
- filtered[field] = obj[field];
1235
- }
1236
- }
1237
- return filtered;
1238
- }
1239
- function filterResponseData(data, preset, user = null) {
1240
- const allowedFields = getFieldsForUser(user, preset);
1241
- if (Array.isArray(data)) {
1242
- return data.map((item) => filterObject(item, allowedFields));
1243
- }
1244
- return filterObject(data, allowedFields);
1245
- }
1246
- function createFieldPreset(config) {
1247
- return {
1248
- public: config.public || [],
1249
- authenticated: config.authenticated || [],
1250
- admin: config.admin || []
1251
- };
1252
- }
1253
-
1254
- // src/plugins/field-filter.plugin.ts
1255
- function fieldFilterPlugin(fieldPreset) {
1256
- return {
1257
- name: "fieldFilter",
1258
- apply(repo) {
1259
- const applyFieldFiltering = (context) => {
1260
- if (!fieldPreset) return;
1261
- const user = context.context?.user || context.user;
1262
- const fields = getFieldsForUser(user, fieldPreset);
1263
- const presetSelect = fields.join(" ");
1264
- if (context.select) {
1265
- context.select = `${presetSelect} ${context.select}`;
1266
- } else {
1267
- context.select = presetSelect;
1268
- }
1269
- };
1270
- repo.on("before:getAll", applyFieldFiltering);
1271
- repo.on("before:getById", applyFieldFiltering);
1272
- repo.on("before:getByQuery", applyFieldFiltering);
1273
- }
1274
- };
1275
- }
1276
-
1277
- // src/plugins/timestamp.plugin.ts
1278
- function timestampPlugin() {
1279
- return {
1280
- name: "timestamp",
1281
- apply(repo) {
1282
- repo.on("before:create", (context) => {
1283
- if (!context.data) return;
1284
- const now = /* @__PURE__ */ new Date();
1285
- if (!context.data.createdAt) context.data.createdAt = now;
1286
- if (!context.data.updatedAt) context.data.updatedAt = now;
1287
- });
1288
- repo.on("before:update", (context) => {
1289
- if (!context.data) return;
1290
- context.data.updatedAt = /* @__PURE__ */ new Date();
1291
- });
1292
- }
1293
- };
1294
- }
1295
-
1296
- // src/plugins/audit-log.plugin.ts
1297
- function auditLogPlugin(logger) {
1298
- return {
1299
- name: "auditLog",
1300
- apply(repo) {
1301
- repo.on("after:create", ({ context, result }) => {
1302
- logger?.info?.("Document created", {
1303
- model: context.model || repo.model,
1304
- id: result?._id,
1305
- userId: context.user?._id || context.user?.id,
1306
- organizationId: context.organizationId
1307
- });
1308
- });
1309
- repo.on("after:update", ({ context, result }) => {
1310
- logger?.info?.("Document updated", {
1311
- model: context.model || repo.model,
1312
- id: context.id || result?._id,
1313
- userId: context.user?._id || context.user?.id,
1314
- organizationId: context.organizationId
1315
- });
1316
- });
1317
- repo.on("after:delete", ({ context }) => {
1318
- logger?.info?.("Document deleted", {
1319
- model: context.model || repo.model,
1320
- id: context.id,
1321
- userId: context.user?._id || context.user?.id,
1322
- organizationId: context.organizationId
1323
- });
1324
- });
1325
- repo.on("error:create", ({ context, error }) => {
1326
- logger?.error?.("Create failed", {
1327
- model: context.model || repo.model,
1328
- error: error.message,
1329
- userId: context.user?._id || context.user?.id
1330
- });
1331
- });
1332
- repo.on("error:update", ({ context, error }) => {
1333
- logger?.error?.("Update failed", {
1334
- model: context.model || repo.model,
1335
- id: context.id,
1336
- error: error.message,
1337
- userId: context.user?._id || context.user?.id
1338
- });
1339
- });
1340
- repo.on("error:delete", ({ context, error }) => {
1341
- logger?.error?.("Delete failed", {
1342
- model: context.model || repo.model,
1343
- id: context.id,
1344
- error: error.message,
1345
- userId: context.user?._id || context.user?.id
1346
- });
1347
- });
1348
- }
1349
- };
1350
- }
1351
-
1352
- // src/plugins/soft-delete.plugin.ts
1353
- function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
1354
- if (includeDeleted) {
1355
- return {};
1356
- }
1357
- if (filterMode === "exists") {
1358
- return { [deletedField]: { $exists: false } };
1359
- }
1360
- return { [deletedField]: null };
1361
- }
1362
- function buildGetDeletedFilter(deletedField, filterMode) {
1363
- if (filterMode === "exists") {
1364
- return { [deletedField]: { $exists: true, $ne: null } };
1365
- }
1366
- return { [deletedField]: { $ne: null } };
1367
- }
1368
- function softDeletePlugin(options = {}) {
1369
- const deletedField = options.deletedField || "deletedAt";
1370
- const deletedByField = options.deletedByField || "deletedBy";
1371
- const filterMode = options.filterMode || "null";
1372
- const addRestoreMethod = options.addRestoreMethod !== false;
1373
- const addGetDeletedMethod = options.addGetDeletedMethod !== false;
1374
- const ttlDays = options.ttlDays;
1375
- return {
1376
- name: "softDelete",
1377
- apply(repo) {
1378
- if (ttlDays !== void 0 && ttlDays > 0) {
1379
- const ttlSeconds = ttlDays * 24 * 60 * 60;
1380
- repo.Model.collection.createIndex(
1381
- { [deletedField]: 1 },
1382
- {
1383
- expireAfterSeconds: ttlSeconds,
1384
- partialFilterExpression: { [deletedField]: { $type: "date" } }
1385
- }
1386
- ).catch((err) => {
1387
- if (!err.message.includes("already exists")) {
1388
- console.warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1389
- }
1390
- });
1391
- }
1392
- repo.on("before:delete", async (context) => {
1393
- if (options.soft !== false) {
1394
- const updateData = {
1395
- [deletedField]: /* @__PURE__ */ new Date()
1396
- };
1397
- if (context.user) {
1398
- updateData[deletedByField] = context.user._id || context.user.id;
1399
- }
1400
- await repo.Model.findByIdAndUpdate(context.id, updateData, { session: context.session });
1401
- context.softDeleted = true;
1402
- }
1403
- });
1404
- repo.on("before:getAll", (context) => {
1405
- if (options.soft !== false) {
1406
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1407
- if (Object.keys(deleteFilter).length > 0) {
1408
- const existingFilters = context.filters || {};
1409
- context.filters = {
1410
- ...existingFilters,
1411
- ...deleteFilter
1412
- };
1413
- }
1414
- }
1415
- });
1416
- repo.on("before:getById", (context) => {
1417
- if (options.soft !== false) {
1418
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1419
- if (Object.keys(deleteFilter).length > 0) {
1420
- context.query = {
1421
- ...context.query || {},
1422
- ...deleteFilter
1423
- };
1424
- }
1425
- }
1426
- });
1427
- repo.on("before:getByQuery", (context) => {
1428
- if (options.soft !== false) {
1429
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1430
- if (Object.keys(deleteFilter).length > 0) {
1431
- context.query = {
1432
- ...context.query || {},
1433
- ...deleteFilter
1434
- };
1435
- }
1436
- }
1437
- });
1438
- if (addRestoreMethod) {
1439
- const restoreMethod = async function(id, restoreOptions = {}) {
1440
- const updateData = {
1441
- [deletedField]: null,
1442
- [deletedByField]: null
1443
- };
1444
- const result = await this.Model.findByIdAndUpdate(id, { $set: updateData }, {
1445
- new: true,
1446
- session: restoreOptions.session
1447
- });
1448
- if (!result) {
1449
- const error = new Error(`Document with id '${id}' not found`);
1450
- error.status = 404;
1451
- throw error;
1452
- }
1453
- await this.emitAsync("after:restore", { id, result });
1454
- return result;
1455
- };
1456
- if (typeof repo.registerMethod === "function") {
1457
- repo.registerMethod("restore", restoreMethod);
1458
- } else {
1459
- repo.restore = restoreMethod.bind(repo);
1460
- }
1461
- }
1462
- if (addGetDeletedMethod) {
1463
- const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
1464
- const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
1465
- const combinedFilters = {
1466
- ...params.filters || {},
1467
- ...deletedFilter
1468
- };
1469
- const page = params.page || 1;
1470
- const limit = params.limit || 20;
1471
- const skip = (page - 1) * limit;
1472
- let sortSpec = { [deletedField]: -1 };
1473
- if (params.sort) {
1474
- if (typeof params.sort === "string") {
1475
- const sortOrder = params.sort.startsWith("-") ? -1 : 1;
1476
- const sortField = params.sort.startsWith("-") ? params.sort.substring(1) : params.sort;
1477
- sortSpec = { [sortField]: sortOrder };
1478
- } else {
1479
- sortSpec = params.sort;
1480
- }
1481
- }
1482
- let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
1483
- if (getDeletedOptions.session) {
1484
- query = query.session(getDeletedOptions.session);
1485
- }
1486
- if (getDeletedOptions.select) {
1487
- const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
1488
- query = query.select(selectValue);
1489
- }
1490
- if (getDeletedOptions.populate) {
1491
- const populateSpec = getDeletedOptions.populate;
1492
- if (typeof populateSpec === "string") {
1493
- query = query.populate(populateSpec.split(",").map((p) => p.trim()));
1494
- } else if (Array.isArray(populateSpec)) {
1495
- query = query.populate(populateSpec);
1496
- } else {
1497
- query = query.populate(populateSpec);
1498
- }
1499
- }
1500
- if (getDeletedOptions.lean !== false) {
1501
- query = query.lean();
1502
- }
1503
- const [docs, total] = await Promise.all([
1504
- query.exec(),
1505
- this.Model.countDocuments(combinedFilters)
1506
- ]);
1507
- const pages = Math.ceil(total / limit);
1508
- return {
1509
- method: "offset",
1510
- docs,
1511
- page,
1512
- limit,
1513
- total,
1514
- pages,
1515
- hasNext: page < pages,
1516
- hasPrev: page > 1
1517
- };
1518
- };
1519
- if (typeof repo.registerMethod === "function") {
1520
- repo.registerMethod("getDeleted", getDeletedMethod);
1521
- } else {
1522
- repo.getDeleted = getDeletedMethod.bind(repo);
1523
- }
1524
- }
1525
- }
1526
- };
1527
- }
1528
-
1529
- // src/plugins/method-registry.plugin.ts
1530
- function methodRegistryPlugin() {
1531
- return {
1532
- name: "method-registry",
1533
- apply(repo) {
1534
- const registeredMethods = [];
1535
- repo.registerMethod = function(name, fn) {
1536
- if (repo[name]) {
1537
- throw new Error(
1538
- `Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`
1539
- );
1540
- }
1541
- if (!name || typeof name !== "string") {
1542
- throw new Error("Method name must be a non-empty string");
1543
- }
1544
- if (typeof fn !== "function") {
1545
- throw new Error(`Method '${name}' must be a function`);
1546
- }
1547
- repo[name] = fn.bind(repo);
1548
- registeredMethods.push(name);
1549
- repo.emit("method:registered", { name, fn });
1550
- };
1551
- repo.hasMethod = function(name) {
1552
- return typeof repo[name] === "function";
1553
- };
1554
- repo.getRegisteredMethods = function() {
1555
- return [...registeredMethods];
1556
- };
1557
- }
1558
- };
1559
- }
1560
-
1561
- // src/plugins/validation-chain.plugin.ts
1562
- function validationChainPlugin(validators = [], options = {}) {
1563
- const { stopOnFirstError = true } = options;
1564
- validators.forEach((v, idx) => {
1565
- if (!v.name || typeof v.name !== "string") {
1566
- throw new Error(`Validator at index ${idx} missing 'name' (string)`);
1567
- }
1568
- if (typeof v.validate !== "function") {
1569
- throw new Error(`Validator '${v.name}' missing 'validate' function`);
1570
- }
1571
- });
1572
- const validatorsByOperation = {
1573
- create: [],
1574
- update: [],
1575
- delete: [],
1576
- createMany: []
1577
- };
1578
- const allOperationsValidators = [];
1579
- validators.forEach((v) => {
1580
- if (!v.operations || v.operations.length === 0) {
1581
- allOperationsValidators.push(v);
1582
- } else {
1583
- v.operations.forEach((op) => {
1584
- if (validatorsByOperation[op]) {
1585
- validatorsByOperation[op].push(v);
1586
- }
1587
- });
1588
- }
1589
- });
1590
- return {
1591
- name: "validation-chain",
1592
- apply(repo) {
1593
- const getValidatorsForOperation = (operation) => {
1594
- const specific = validatorsByOperation[operation] || [];
1595
- return [...allOperationsValidators, ...specific];
1596
- };
1597
- const runValidators = async (operation, context) => {
1598
- const operationValidators = getValidatorsForOperation(operation);
1599
- const errors = [];
1600
- for (const validator of operationValidators) {
1601
- try {
1602
- await validator.validate(context, repo);
1603
- } catch (error) {
1604
- if (stopOnFirstError) {
1605
- throw error;
1606
- }
1607
- errors.push({
1608
- validator: validator.name,
1609
- error: error.message || String(error)
1610
- });
1611
- }
1612
- }
1613
- if (errors.length > 0) {
1614
- const err = createError(
1615
- 400,
1616
- `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`
1617
- );
1618
- err.validationErrors = errors;
1619
- throw err;
1620
- }
1621
- };
1622
- repo.on("before:create", async (context) => runValidators("create", context));
1623
- repo.on("before:createMany", async (context) => runValidators("createMany", context));
1624
- repo.on("before:update", async (context) => runValidators("update", context));
1625
- repo.on("before:delete", async (context) => runValidators("delete", context));
1626
- }
1627
- };
1628
- }
1629
- function blockIf(name, operations, condition, errorMessage) {
1630
- return {
1631
- name,
1632
- operations,
1633
- validate: (context) => {
1634
- if (condition(context)) {
1635
- throw createError(403, errorMessage);
1636
- }
1637
- }
1638
- };
1639
- }
1640
- function requireField(field, operations = ["create"]) {
1641
- return {
1642
- name: `require-${field}`,
1643
- operations,
1644
- validate: (context) => {
1645
- if (!context.data || context.data[field] === void 0 || context.data[field] === null) {
1646
- throw createError(400, `Field '${field}' is required`);
1647
- }
1648
- }
1649
- };
1650
- }
1651
- function autoInject(field, getter, operations = ["create"]) {
1652
- return {
1653
- name: `auto-inject-${field}`,
1654
- operations,
1655
- validate: (context) => {
1656
- if (context.data && !(field in context.data)) {
1657
- const value = getter(context);
1658
- if (value !== null && value !== void 0) {
1659
- context.data[field] = value;
1660
- }
1661
- }
1662
- }
1663
- };
1664
- }
1665
- function immutableField(field) {
1666
- return {
1667
- name: `immutable-${field}`,
1668
- operations: ["update"],
1669
- validate: (context) => {
1670
- if (context.data && field in context.data) {
1671
- throw createError(400, `Field '${field}' cannot be modified`);
1672
- }
1673
- }
1674
- };
1675
- }
1676
- function uniqueField(field, errorMessage) {
1677
- return {
1678
- name: `unique-${field}`,
1679
- operations: ["create", "update"],
1680
- validate: async (context, repo) => {
1681
- if (!context.data || !context.data[field] || !repo) return;
1682
- const query = { [field]: context.data[field] };
1683
- const getByQuery2 = repo.getByQuery;
1684
- if (typeof getByQuery2 !== "function") return;
1685
- const existing = await getByQuery2.call(repo, query, {
1686
- select: "_id",
1687
- lean: true,
1688
- throwOnNotFound: false
1689
- });
1690
- if (existing && String(existing._id) !== String(context.id)) {
1691
- throw createError(409, errorMessage || `${field} already exists`);
1692
- }
1693
- }
1694
- };
1695
- }
1696
-
1697
- // src/plugins/mongo-operations.plugin.ts
1698
- function mongoOperationsPlugin() {
1699
- return {
1700
- name: "mongo-operations",
1701
- apply(repo) {
1702
- if (!repo.registerMethod) {
1703
- throw new Error(
1704
- "mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array."
951
+ const page = options.page || 1;
952
+ const limit = options.limit || this._pagination.config.defaultLimit || 20;
953
+ const skip = (page - 1) * limit;
954
+ const SAFE_LIMIT = 1e3;
955
+ const SAFE_MAX_OFFSET = 1e4;
956
+ if (limit > SAFE_LIMIT) {
957
+ console.warn(
958
+ `[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`
1705
959
  );
1706
960
  }
1707
- repo.registerMethod("upsert", async function(query, data, options = {}) {
1708
- return upsert(this.Model, query, data, options);
1709
- });
1710
- const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
1711
- if (typeof value !== "number") {
1712
- throw createError(400, `${operationName} value must be a number`);
1713
- }
1714
- return this.update(id, { [operator]: { [field]: value } }, options);
1715
- };
1716
- repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
1717
- return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
1718
- });
1719
- repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
1720
- return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
1721
- });
1722
- const applyOperator = function(id, field, value, operator, options) {
1723
- return this.update(id, { [operator]: { [field]: value } }, options);
1724
- };
1725
- repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
1726
- return applyOperator.call(this, id, field, value, "$push", options);
1727
- });
1728
- repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
1729
- return applyOperator.call(this, id, field, value, "$pull", options);
1730
- });
1731
- repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
1732
- return applyOperator.call(this, id, field, value, "$addToSet", options);
1733
- });
1734
- repo.registerMethod("setField", async function(id, field, value, options = {}) {
1735
- return applyOperator.call(this, id, field, value, "$set", options);
1736
- });
1737
- repo.registerMethod("unsetField", async function(id, fields, options = {}) {
1738
- const fieldArray = Array.isArray(fields) ? fields : [fields];
1739
- const unsetObj = fieldArray.reduce((acc, field) => {
1740
- acc[field] = "";
1741
- return acc;
1742
- }, {});
1743
- return this.update(id, { $unset: unsetObj }, options);
1744
- });
1745
- repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
1746
- return this.update(id, { $rename: { [oldName]: newName } }, options);
1747
- });
1748
- repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
1749
- return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
1750
- });
1751
- repo.registerMethod("setMin", async function(id, field, value, options = {}) {
1752
- return applyOperator.call(this, id, field, value, "$min", options);
1753
- });
1754
- repo.registerMethod("setMax", async function(id, field, value, options = {}) {
1755
- return applyOperator.call(this, id, field, value, "$max", options);
1756
- });
1757
- }
1758
- };
1759
- }
1760
-
1761
- // src/plugins/batch-operations.plugin.ts
1762
- function batchOperationsPlugin() {
1763
- return {
1764
- name: "batch-operations",
1765
- apply(repo) {
1766
- if (!repo.registerMethod) {
1767
- throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
1768
- }
1769
- repo.registerMethod("updateMany", async function(query, data, options = {}) {
1770
- const _buildContext = this._buildContext;
1771
- const context = await _buildContext.call(this, "updateMany", { query, data, options });
1772
- try {
1773
- this.emit("before:updateMany", context);
1774
- if (Array.isArray(data) && options.updatePipeline !== true) {
1775
- throw createError(
1776
- 400,
1777
- "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates."
1778
- );
1779
- }
1780
- const result = await this.Model.updateMany(query, data, {
1781
- runValidators: true,
1782
- session: options.session,
1783
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
1784
- }).exec();
1785
- this.emit("after:updateMany", { context, result });
1786
- return result;
1787
- } catch (error) {
1788
- this.emit("error:updateMany", { context, error });
1789
- const _handleError = this._handleError;
1790
- throw _handleError.call(this, error);
1791
- }
1792
- });
1793
- repo.registerMethod("deleteMany", async function(query, options = {}) {
1794
- const _buildContext = this._buildContext;
1795
- const context = await _buildContext.call(this, "deleteMany", { query, options });
1796
- try {
1797
- this.emit("before:deleteMany", context);
1798
- const result = await this.Model.deleteMany(query, {
1799
- session: options.session
1800
- }).exec();
1801
- this.emit("after:deleteMany", { context, result });
1802
- return result;
1803
- } catch (error) {
1804
- this.emit("error:deleteMany", { context, error });
1805
- const _handleError = this._handleError;
1806
- throw _handleError.call(this, error);
1807
- }
1808
- });
1809
- }
1810
- };
1811
- }
1812
-
1813
- // src/plugins/aggregate-helpers.plugin.ts
1814
- function aggregateHelpersPlugin() {
1815
- return {
1816
- name: "aggregate-helpers",
1817
- apply(repo) {
1818
- if (!repo.registerMethod) {
1819
- throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
1820
- }
1821
- repo.registerMethod("groupBy", async function(field, options = {}) {
1822
- const pipeline = [
1823
- { $group: { _id: `$${field}`, count: { $sum: 1 } } },
1824
- { $sort: { count: -1 } }
1825
- ];
1826
- if (options.limit) {
1827
- pipeline.push({ $limit: options.limit });
1828
- }
1829
- const aggregate2 = this.aggregate;
1830
- return aggregate2.call(this, pipeline, options);
1831
- });
1832
- const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
1833
- const pipeline = [
1834
- { $match: query },
1835
- { $group: { _id: null, [resultKey]: { [operator]: `$${field}` } } }
1836
- ];
1837
- const aggregate2 = this.aggregate;
1838
- const result = await aggregate2.call(this, pipeline, options);
1839
- return result[0]?.[resultKey] || 0;
1840
- };
1841
- repo.registerMethod("sum", async function(field, query = {}, options = {}) {
1842
- return aggregateOperation.call(this, field, "$sum", "total", query, options);
1843
- });
1844
- repo.registerMethod("average", async function(field, query = {}, options = {}) {
1845
- return aggregateOperation.call(this, field, "$avg", "avg", query, options);
1846
- });
1847
- repo.registerMethod("min", async function(field, query = {}, options = {}) {
1848
- return aggregateOperation.call(this, field, "$min", "min", query, options);
1849
- });
1850
- repo.registerMethod("max", async function(field, query = {}, options = {}) {
1851
- return aggregateOperation.call(this, field, "$max", "max", query, options);
1852
- });
1853
- }
1854
- };
1855
- }
1856
-
1857
- // src/plugins/subdocument.plugin.ts
1858
- function subdocumentPlugin() {
1859
- return {
1860
- name: "subdocument",
1861
- apply(repo) {
1862
- if (!repo.registerMethod) {
1863
- throw new Error("subdocumentPlugin requires methodRegistryPlugin");
1864
- }
1865
- repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
1866
- const update2 = this.update;
1867
- return update2.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
1868
- });
1869
- repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
1870
- const _executeQuery = this._executeQuery;
1871
- return _executeQuery.call(this, async (Model) => {
1872
- const parent = await Model.findById(parentId).session(options.session).exec();
1873
- if (!parent) throw createError(404, "Parent not found");
1874
- const parentObj = parent;
1875
- const arrayField = parentObj[arrayPath];
1876
- if (!arrayField || typeof arrayField.id !== "function") {
1877
- throw createError(404, "Array field not found");
1878
- }
1879
- const sub = arrayField.id(subId);
1880
- if (!sub) throw createError(404, "Subdocument not found");
1881
- return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
1882
- });
1883
- });
1884
- repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
1885
- const _executeQuery = this._executeQuery;
1886
- return _executeQuery.call(this, async (Model) => {
1887
- const query = { _id: parentId, [`${arrayPath}._id`]: subId };
1888
- const update2 = { $set: { [`${arrayPath}.$`]: { ...updateData, _id: subId } } };
1889
- const result = await Model.findOneAndUpdate(query, update2, {
1890
- new: true,
1891
- runValidators: true,
1892
- session: options.session
1893
- }).exec();
1894
- if (!result) throw createError(404, "Parent or subdocument not found");
1895
- return result;
1896
- });
1897
- });
1898
- repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
1899
- const update2 = this.update;
1900
- return update2.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
1901
- });
1902
- }
1903
- };
1904
- }
1905
-
1906
- // src/utils/cache-keys.ts
1907
- function hashString(str) {
1908
- let hash = 5381;
1909
- for (let i = 0; i < str.length; i++) {
1910
- hash = (hash << 5) + hash ^ str.charCodeAt(i);
1911
- }
1912
- return (hash >>> 0).toString(16);
1913
- }
1914
- function stableStringify(obj) {
1915
- if (obj === null || obj === void 0) return "";
1916
- if (typeof obj !== "object") return String(obj);
1917
- if (Array.isArray(obj)) {
1918
- return "[" + obj.map(stableStringify).join(",") + "]";
1919
- }
1920
- const sorted = Object.keys(obj).sort().map((key) => `${key}:${stableStringify(obj[key])}`);
1921
- return "{" + sorted.join(",") + "}";
1922
- }
1923
- function byIdKey(prefix, model, id) {
1924
- return `${prefix}:id:${model}:${id}`;
1925
- }
1926
- function byQueryKey(prefix, model, query, options) {
1927
- const hashInput = stableStringify({ q: query, s: options?.select, p: options?.populate });
1928
- return `${prefix}:one:${model}:${hashString(hashInput)}`;
1929
- }
1930
- function listQueryKey(prefix, model, version, params) {
1931
- const hashInput = stableStringify({
1932
- f: params.filters,
1933
- s: params.sort,
1934
- pg: params.page,
1935
- lm: params.limit,
1936
- af: params.after,
1937
- sl: params.select,
1938
- pp: params.populate
1939
- });
1940
- return `${prefix}:list:${model}:${version}:${hashString(hashInput)}`;
1941
- }
1942
- function versionKey(prefix, model) {
1943
- return `${prefix}:ver:${model}`;
1944
- }
1945
- function modelPattern(prefix, model) {
1946
- return `${prefix}:*:${model}:*`;
1947
- }
1948
-
1949
- // src/plugins/cache.plugin.ts
1950
- function cachePlugin(options) {
1951
- const config = {
1952
- adapter: options.adapter,
1953
- ttl: options.ttl ?? 60,
1954
- byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
1955
- queryTtl: options.queryTtl ?? options.ttl ?? 60,
1956
- prefix: options.prefix ?? "mk",
1957
- debug: options.debug ?? false,
1958
- skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
1959
- };
1960
- const stats = {
1961
- hits: 0,
1962
- misses: 0,
1963
- sets: 0,
1964
- invalidations: 0
1965
- };
1966
- let collectionVersion = 0;
1967
- const log = (msg, data) => {
1968
- if (config.debug) {
1969
- console.log(`[mongokit:cache] ${msg}`, data ?? "");
1970
- }
1971
- };
1972
- return {
1973
- name: "cache",
1974
- apply(repo) {
1975
- const model = repo.model;
1976
- (async () => {
1977
- try {
1978
- const cached = await config.adapter.get(versionKey(config.prefix, model));
1979
- if (cached !== null) {
1980
- collectionVersion = cached;
1981
- log(`Initialized version for ${model}:`, collectionVersion);
1982
- }
1983
- } catch (e) {
1984
- log(`Failed to initialize version for ${model}:`, e);
1985
- }
1986
- })();
1987
- async function bumpVersion() {
1988
- collectionVersion++;
1989
- try {
1990
- await config.adapter.set(versionKey(config.prefix, model), collectionVersion, config.ttl * 10);
1991
- stats.invalidations++;
1992
- log(`Bumped version for ${model} to:`, collectionVersion);
1993
- } catch (e) {
1994
- log(`Failed to bump version for ${model}:`, e);
1995
- }
1996
- }
1997
- async function invalidateById(id) {
1998
- const key = byIdKey(config.prefix, model, id);
1999
- try {
2000
- await config.adapter.del(key);
2001
- stats.invalidations++;
2002
- log(`Invalidated byId cache:`, key);
2003
- } catch (e) {
2004
- log(`Failed to invalidate byId cache:`, e);
2005
- }
961
+ if (skip > SAFE_MAX_OFFSET) {
962
+ console.warn(
963
+ `[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`
964
+ );
2006
965
  }
2007
- repo.on("before:getById", async (context) => {
2008
- if (context.skipCache) {
2009
- log(`Skipping cache for getById: ${context.id}`);
2010
- return;
2011
- }
2012
- const id = String(context.id);
2013
- const key = byIdKey(config.prefix, model, id);
2014
- try {
2015
- const cached = await config.adapter.get(key);
2016
- if (cached !== null) {
2017
- stats.hits++;
2018
- log(`Cache HIT for getById:`, key);
2019
- context._cacheHit = true;
2020
- context._cachedResult = cached;
2021
- } else {
2022
- stats.misses++;
2023
- log(`Cache MISS for getById:`, key);
2024
- }
2025
- } catch (e) {
2026
- log(`Cache error for getById:`, e);
2027
- stats.misses++;
2028
- }
2029
- });
2030
- repo.on("before:getByQuery", async (context) => {
2031
- if (context.skipCache) {
2032
- log(`Skipping cache for getByQuery`);
2033
- return;
2034
- }
2035
- const query = context.query || {};
2036
- const key = byQueryKey(config.prefix, model, query, {
2037
- select: context.select,
2038
- populate: context.populate
2039
- });
2040
- try {
2041
- const cached = await config.adapter.get(key);
2042
- if (cached !== null) {
2043
- stats.hits++;
2044
- log(`Cache HIT for getByQuery:`, key);
2045
- context._cacheHit = true;
2046
- context._cachedResult = cached;
2047
- } else {
2048
- stats.misses++;
2049
- log(`Cache MISS for getByQuery:`, key);
2050
- }
2051
- } catch (e) {
2052
- log(`Cache error for getByQuery:`, e);
2053
- stats.misses++;
2054
- }
2055
- });
2056
- repo.on("before:getAll", async (context) => {
2057
- if (context.skipCache) {
2058
- log(`Skipping cache for getAll`);
2059
- return;
2060
- }
2061
- const limit = context.limit;
2062
- if (limit && limit > config.skipIfLargeLimit) {
2063
- log(`Skipping cache for large query (limit: ${limit})`);
2064
- return;
2065
- }
2066
- const params = {
2067
- filters: context.filters,
2068
- sort: context.sort,
2069
- page: context.page,
2070
- limit,
2071
- after: context.after,
2072
- select: context.select,
2073
- populate: context.populate
2074
- };
2075
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
2076
- try {
2077
- const cached = await config.adapter.get(key);
2078
- if (cached !== null) {
2079
- stats.hits++;
2080
- log(`Cache HIT for getAll:`, key);
2081
- context._cacheHit = true;
2082
- context._cachedResult = cached;
2083
- } else {
2084
- stats.misses++;
2085
- log(`Cache MISS for getAll:`, key);
2086
- }
2087
- } catch (e) {
2088
- log(`Cache error for getAll:`, e);
2089
- stats.misses++;
2090
- }
2091
- });
2092
- repo.on("after:getById", async (payload) => {
2093
- const { context, result } = payload;
2094
- if (context._cacheHit) return;
2095
- if (context.skipCache) return;
2096
- if (result === null) return;
2097
- const id = String(context.id);
2098
- const key = byIdKey(config.prefix, model, id);
2099
- const ttl = context.cacheTtl ?? config.byIdTtl;
2100
- try {
2101
- await config.adapter.set(key, result, ttl);
2102
- stats.sets++;
2103
- log(`Cached getById result:`, key);
2104
- } catch (e) {
2105
- log(`Failed to cache getById:`, e);
2106
- }
2107
- });
2108
- repo.on("after:getByQuery", async (payload) => {
2109
- const { context, result } = payload;
2110
- if (context._cacheHit) return;
2111
- if (context.skipCache) return;
2112
- if (result === null) return;
2113
- const query = context.query || {};
2114
- const key = byQueryKey(config.prefix, model, query, {
2115
- select: context.select,
2116
- populate: context.populate
2117
- });
2118
- const ttl = context.cacheTtl ?? config.queryTtl;
2119
- try {
2120
- await config.adapter.set(key, result, ttl);
2121
- stats.sets++;
2122
- log(`Cached getByQuery result:`, key);
2123
- } catch (e) {
2124
- log(`Failed to cache getByQuery:`, e);
2125
- }
2126
- });
2127
- repo.on("after:getAll", async (payload) => {
2128
- const { context, result } = payload;
2129
- if (context._cacheHit) return;
2130
- if (context.skipCache) return;
2131
- const limit = context.limit;
2132
- if (limit && limit > config.skipIfLargeLimit) return;
2133
- const params = {
2134
- filters: context.filters,
2135
- sort: context.sort,
2136
- page: context.page,
2137
- limit,
2138
- after: context.after,
2139
- select: context.select,
2140
- populate: context.populate
2141
- };
2142
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
2143
- const ttl = context.cacheTtl ?? config.queryTtl;
2144
- try {
2145
- await config.adapter.set(key, result, ttl);
2146
- stats.sets++;
2147
- log(`Cached getAll result:`, key);
2148
- } catch (e) {
2149
- log(`Failed to cache getAll:`, e);
2150
- }
2151
- });
2152
- repo.on("after:create", async () => {
2153
- await bumpVersion();
2154
- });
2155
- repo.on("after:createMany", async () => {
2156
- await bumpVersion();
2157
- });
2158
- repo.on("after:update", async (payload) => {
2159
- const { context } = payload;
2160
- const id = String(context.id);
2161
- await Promise.all([
2162
- invalidateById(id),
2163
- bumpVersion()
2164
- ]);
2165
- });
2166
- repo.on("after:updateMany", async () => {
2167
- await bumpVersion();
2168
- });
2169
- repo.on("after:delete", async (payload) => {
2170
- const { context } = payload;
2171
- const id = String(context.id);
2172
- await Promise.all([
2173
- invalidateById(id),
2174
- bumpVersion()
2175
- ]);
2176
- });
2177
- repo.on("after:deleteMany", async () => {
2178
- await bumpVersion();
2179
- });
2180
- repo.invalidateCache = async (id) => {
2181
- await invalidateById(id);
2182
- log(`Manual invalidation for ID:`, id);
2183
- };
2184
- repo.invalidateListCache = async () => {
2185
- await bumpVersion();
2186
- log(`Manual list cache invalidation for ${model}`);
2187
- };
2188
- repo.invalidateAllCache = async () => {
2189
- if (config.adapter.clear) {
2190
- try {
2191
- await config.adapter.clear(modelPattern(config.prefix, model));
2192
- stats.invalidations++;
2193
- log(`Full cache invalidation for ${model}`);
2194
- } catch (e) {
2195
- log(`Failed full cache invalidation for ${model}:`, e);
2196
- }
2197
- } else {
2198
- await bumpVersion();
2199
- log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
2200
- }
2201
- };
2202
- repo.getCacheStats = () => ({ ...stats });
2203
- repo.resetCacheStats = () => {
2204
- stats.hits = 0;
2205
- stats.misses = 0;
2206
- stats.sets = 0;
2207
- stats.invalidations = 0;
2208
- };
2209
- }
2210
- };
2211
- }
2212
- function cascadePlugin(options) {
2213
- const { relations, parallel = true, logger } = options;
2214
- if (!relations || relations.length === 0) {
2215
- throw new Error("cascadePlugin requires at least one relation");
2216
- }
2217
- return {
2218
- name: "cascade",
2219
- apply(repo) {
2220
- repo.on("after:delete", async (payload) => {
2221
- const { context } = payload;
2222
- const deletedId = context.id;
2223
- if (!deletedId) {
2224
- logger?.warn?.("Cascade delete skipped: no document ID in context", {
2225
- model: context.model
2226
- });
2227
- return;
2228
- }
2229
- const isSoftDelete = context.softDeleted === true;
2230
- const cascadeDelete = async (relation) => {
2231
- const RelatedModel = mongoose4.models[relation.model];
2232
- if (!RelatedModel) {
2233
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2234
- parentModel: context.model,
2235
- parentId: String(deletedId)
2236
- });
2237
- return;
2238
- }
2239
- const query = { [relation.foreignKey]: deletedId };
2240
- try {
2241
- const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2242
- if (shouldSoftDelete) {
2243
- const updateResult = await RelatedModel.updateMany(
2244
- query,
2245
- {
2246
- deletedAt: /* @__PURE__ */ new Date(),
2247
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2248
- },
2249
- { session: context.session }
2250
- );
2251
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2252
- parentModel: context.model,
2253
- parentId: String(deletedId),
2254
- relatedModel: relation.model,
2255
- foreignKey: relation.foreignKey,
2256
- count: updateResult.modifiedCount
2257
- });
966
+ const dataStages = [
967
+ { $skip: skip },
968
+ { $limit: limit }
969
+ ];
970
+ if (options.select) {
971
+ let projection;
972
+ if (typeof options.select === "string") {
973
+ projection = {};
974
+ const fields = options.select.split(",").map((f) => f.trim());
975
+ for (const field of fields) {
976
+ if (field.startsWith("-")) {
977
+ projection[field.substring(1)] = 0;
2258
978
  } else {
2259
- const deleteResult = await RelatedModel.deleteMany(query, {
2260
- session: context.session
2261
- });
2262
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2263
- parentModel: context.model,
2264
- parentId: String(deletedId),
2265
- relatedModel: relation.model,
2266
- foreignKey: relation.foreignKey,
2267
- count: deleteResult.deletedCount
2268
- });
979
+ projection[field] = 1;
2269
980
  }
2270
- } catch (error) {
2271
- logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2272
- parentModel: context.model,
2273
- parentId: String(deletedId),
2274
- relatedModel: relation.model,
2275
- foreignKey: relation.foreignKey,
2276
- error: error.message
2277
- });
2278
- throw error;
2279
- }
2280
- };
2281
- if (parallel) {
2282
- await Promise.all(relations.map(cascadeDelete));
2283
- } else {
2284
- for (const relation of relations) {
2285
- await cascadeDelete(relation);
2286
981
  }
2287
- }
2288
- });
2289
- repo.on("after:deleteMany", async (payload) => {
2290
- const { context, result } = payload;
2291
- const query = context.query;
2292
- if (!query || Object.keys(query).length === 0) {
2293
- logger?.warn?.("Cascade deleteMany skipped: empty query", {
2294
- model: context.model
2295
- });
2296
- return;
2297
- }
2298
- logger?.warn?.("Cascade deleteMany: use before:deleteMany hook for complete cascade support", {
2299
- model: context.model
2300
- });
2301
- });
2302
- repo.on("before:deleteMany", async (context) => {
2303
- const query = context.query;
2304
- if (!query || Object.keys(query).length === 0) {
2305
- return;
2306
- }
2307
- const docs = await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null);
2308
- const ids = docs.map((doc) => doc._id);
2309
- context._cascadeIds = ids;
2310
- });
2311
- const originalAfterDeleteMany = repo._hooks.get("after:deleteMany") || [];
2312
- repo._hooks.set("after:deleteMany", [
2313
- ...originalAfterDeleteMany,
2314
- async (payload) => {
2315
- const { context } = payload;
2316
- const ids = context._cascadeIds;
2317
- if (!ids || ids.length === 0) {
2318
- return;
2319
- }
2320
- const isSoftDelete = context.softDeleted === true;
2321
- const cascadeDeleteMany = async (relation) => {
2322
- const RelatedModel = mongoose4.models[relation.model];
2323
- if (!RelatedModel) {
2324
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, {
2325
- parentModel: context.model
2326
- });
2327
- return;
2328
- }
2329
- const query = { [relation.foreignKey]: { $in: ids } };
2330
- const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2331
- try {
2332
- if (shouldSoftDelete) {
2333
- const updateResult = await RelatedModel.updateMany(
2334
- query,
2335
- {
2336
- deletedAt: /* @__PURE__ */ new Date(),
2337
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2338
- },
2339
- { session: context.session }
2340
- );
2341
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2342
- parentModel: context.model,
2343
- parentCount: ids.length,
2344
- relatedModel: relation.model,
2345
- foreignKey: relation.foreignKey,
2346
- count: updateResult.modifiedCount
2347
- });
2348
- } else {
2349
- const deleteResult = await RelatedModel.deleteMany(query, {
2350
- session: context.session
2351
- });
2352
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2353
- parentModel: context.model,
2354
- parentCount: ids.length,
2355
- relatedModel: relation.model,
2356
- foreignKey: relation.foreignKey,
2357
- count: deleteResult.deletedCount
2358
- });
2359
- }
2360
- } catch (error) {
2361
- logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2362
- parentModel: context.model,
2363
- relatedModel: relation.model,
2364
- foreignKey: relation.foreignKey,
2365
- error: error.message
2366
- });
2367
- throw error;
2368
- }
2369
- };
2370
- if (parallel) {
2371
- await Promise.all(relations.map(cascadeDeleteMany));
2372
- } else {
2373
- for (const relation of relations) {
2374
- await cascadeDeleteMany(relation);
982
+ } else if (Array.isArray(options.select)) {
983
+ projection = {};
984
+ for (const field of options.select) {
985
+ if (field.startsWith("-")) {
986
+ projection[field.substring(1)] = 0;
987
+ } else {
988
+ projection[field] = 1;
2375
989
  }
2376
990
  }
991
+ } else {
992
+ projection = options.select;
2377
993
  }
2378
- ]);
2379
- }
2380
- };
2381
- }
2382
-
2383
- // src/utils/memory-cache.ts
2384
- function createMemoryCache(maxEntries = 1e3) {
2385
- const cache = /* @__PURE__ */ new Map();
2386
- function cleanup() {
2387
- const now = Date.now();
2388
- for (const [key, entry] of cache) {
2389
- if (entry.expiresAt < now) {
2390
- cache.delete(key);
2391
- }
2392
- }
2393
- }
2394
- function evictOldest() {
2395
- if (cache.size >= maxEntries) {
2396
- const firstKey = cache.keys().next().value;
2397
- if (firstKey) cache.delete(firstKey);
2398
- }
2399
- }
2400
- return {
2401
- async get(key) {
2402
- cleanup();
2403
- const entry = cache.get(key);
2404
- if (!entry) return null;
2405
- if (entry.expiresAt < Date.now()) {
2406
- cache.delete(key);
2407
- return null;
2408
- }
2409
- return entry.value;
2410
- },
2411
- async set(key, value, ttl) {
2412
- cleanup();
2413
- evictOldest();
2414
- cache.set(key, {
2415
- value,
2416
- expiresAt: Date.now() + ttl * 1e3
2417
- });
2418
- },
2419
- async del(key) {
2420
- cache.delete(key);
2421
- },
2422
- async clear(pattern) {
2423
- if (!pattern) {
2424
- cache.clear();
2425
- return;
2426
- }
2427
- const regex = new RegExp(
2428
- "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
2429
- );
2430
- for (const key of cache.keys()) {
2431
- if (regex.test(key)) {
2432
- cache.delete(key);
2433
- }
994
+ dataStages.push({ $project: projection });
2434
995
  }
996
+ builder.facet({
997
+ metadata: [{ $count: "total" }],
998
+ data: dataStages
999
+ });
1000
+ const pipeline = builder.build();
1001
+ const results = await this.Model.aggregate(pipeline).session(options.session || null);
1002
+ const result = results[0] || { metadata: [], data: [] };
1003
+ const total = result.metadata[0]?.total || 0;
1004
+ const data = result.data || [];
1005
+ await this._emitHook("after:lookupPopulate", { context, result: data });
1006
+ return {
1007
+ data,
1008
+ total,
1009
+ page,
1010
+ limit
1011
+ };
1012
+ } catch (error) {
1013
+ await this._emitErrorHook("error:lookupPopulate", { context, error });
1014
+ throw this._handleError(error);
2435
1015
  }
2436
- };
2437
- }
2438
- function isMongooseSchema(value) {
2439
- return value instanceof mongoose4.Schema;
2440
- }
2441
- function isPlainObject(value) {
2442
- return Object.prototype.toString.call(value) === "[object Object]";
2443
- }
2444
- function isObjectIdType(t) {
2445
- return t === mongoose4.Schema.Types.ObjectId || t === mongoose4.Types.ObjectId;
2446
- }
2447
- function buildCrudSchemasFromMongooseSchema(mongooseSchema, options = {}) {
2448
- const tree = mongooseSchema?.obj || {};
2449
- const jsonCreate = buildJsonSchemaForCreate(tree, options);
2450
- const jsonUpdate = buildJsonSchemaForUpdate(jsonCreate, options);
2451
- const jsonParams = {
2452
- type: "object",
2453
- properties: { id: { type: "string", pattern: "^[0-9a-fA-F]{24}$" } },
2454
- required: ["id"]
2455
- };
2456
- const jsonQuery = buildJsonSchemaForQuery(tree, options);
2457
- return { createBody: jsonCreate, updateBody: jsonUpdate, params: jsonParams, listQuery: jsonQuery };
2458
- }
2459
- function buildCrudSchemasFromModel(mongooseModel, options = {}) {
2460
- if (!mongooseModel || !mongooseModel.schema) {
2461
- throw new Error("Invalid mongoose model");
2462
1016
  }
2463
- return buildCrudSchemasFromMongooseSchema(mongooseModel.schema, options);
2464
- }
2465
- function getImmutableFields(options = {}) {
2466
- const immutable = [];
2467
- const fieldRules = options?.fieldRules || {};
2468
- Object.entries(fieldRules).forEach(([field, rules]) => {
2469
- if (rules.immutable || rules.immutableAfterCreate) {
2470
- immutable.push(field);
2471
- }
2472
- });
2473
- (options?.update?.omitFields || []).forEach((f) => {
2474
- if (!immutable.includes(f)) immutable.push(f);
2475
- });
2476
- return immutable;
2477
- }
2478
- function getSystemManagedFields(options = {}) {
2479
- const systemManaged = [];
2480
- const fieldRules = options?.fieldRules || {};
2481
- Object.entries(fieldRules).forEach(([field, rules]) => {
2482
- if (rules.systemManaged) {
2483
- systemManaged.push(field);
2484
- }
2485
- });
2486
- return systemManaged;
2487
- }
2488
- function isFieldUpdateAllowed(fieldName, options = {}) {
2489
- const immutableFields = getImmutableFields(options);
2490
- const systemManagedFields = getSystemManagedFields(options);
2491
- return !immutableFields.includes(fieldName) && !systemManagedFields.includes(fieldName);
2492
- }
2493
- function validateUpdateBody(body = {}, options = {}) {
2494
- const violations = [];
2495
- const immutableFields = getImmutableFields(options);
2496
- const systemManagedFields = getSystemManagedFields(options);
2497
- Object.keys(body).forEach((field) => {
2498
- if (immutableFields.includes(field)) {
2499
- violations.push({ field, reason: "Field is immutable" });
2500
- } else if (systemManagedFields.includes(field)) {
2501
- violations.push({ field, reason: "Field is system-managed" });
2502
- }
2503
- });
2504
- return {
2505
- valid: violations.length === 0,
2506
- violations
2507
- };
2508
- }
2509
- function jsonTypeFor(def, options, seen) {
2510
- if (Array.isArray(def)) {
2511
- if (def[0] === mongoose4.Schema.Types.Mixed) {
2512
- return { type: "array", items: { type: "object", additionalProperties: true } };
2513
- }
2514
- return { type: "array", items: jsonTypeFor(def[0] ?? String, options, seen) };
1017
+ /**
1018
+ * Create an aggregation builder for this model
1019
+ * Useful for building complex custom aggregations
1020
+ *
1021
+ * @example
1022
+ * ```typescript
1023
+ * const pipeline = repo.buildAggregation()
1024
+ * .match({ status: 'active' })
1025
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1026
+ * .group({ _id: '$department', count: { $sum: 1 } })
1027
+ * .sort({ count: -1 })
1028
+ * .build();
1029
+ *
1030
+ * const results = await repo.Model.aggregate(pipeline);
1031
+ * ```
1032
+ */
1033
+ buildAggregation() {
1034
+ return new AggregationBuilder();
2515
1035
  }
2516
- if (isPlainObject(def) && "type" in def) {
2517
- const typedDef = def;
2518
- if (typedDef.enum && Array.isArray(typedDef.enum) && typedDef.enum.length) {
2519
- return { type: "string", enum: typedDef.enum.map(String) };
2520
- }
2521
- if (Array.isArray(typedDef.type)) {
2522
- const inner = typedDef.type[0] !== void 0 ? typedDef.type[0] : String;
2523
- if (inner === mongoose4.Schema.Types.Mixed) {
2524
- return { type: "array", items: { type: "object", additionalProperties: true } };
1036
+ /**
1037
+ * Create a lookup builder
1038
+ * Useful for building $lookup stages independently
1039
+ *
1040
+ * @example
1041
+ * ```typescript
1042
+ * const lookupStages = repo.buildLookup('departments')
1043
+ * .localField('deptSlug')
1044
+ * .foreignField('slug')
1045
+ * .as('department')
1046
+ * .single()
1047
+ * .build();
1048
+ *
1049
+ * const pipeline = [
1050
+ * { $match: { status: 'active' } },
1051
+ * ...lookupStages
1052
+ * ];
1053
+ * ```
1054
+ */
1055
+ buildLookup(from) {
1056
+ return new LookupBuilder(from);
1057
+ }
1058
+ /**
1059
+ * Execute callback within a transaction
1060
+ */
1061
+ async withTransaction(callback, options = {}) {
1062
+ const session = await mongoose.startSession();
1063
+ let started = false;
1064
+ try {
1065
+ session.startTransaction();
1066
+ started = true;
1067
+ const result = await callback(session);
1068
+ await session.commitTransaction();
1069
+ return result;
1070
+ } catch (error) {
1071
+ const err = error;
1072
+ if (options.allowFallback && this._isTransactionUnsupported(err)) {
1073
+ if (typeof options.onFallback === "function") {
1074
+ options.onFallback(err);
1075
+ }
1076
+ if (started && session.inTransaction()) {
1077
+ try {
1078
+ await session.abortTransaction();
1079
+ } catch {
1080
+ }
1081
+ }
1082
+ return await callback(null);
2525
1083
  }
2526
- return { type: "array", items: jsonTypeFor(inner, options, seen) };
2527
- }
2528
- if (typedDef.type === String) return { type: "string" };
2529
- if (typedDef.type === Number) return { type: "number" };
2530
- if (typedDef.type === Boolean) return { type: "boolean" };
2531
- if (typedDef.type === Date) {
2532
- const mode = options?.dateAs || "datetime";
2533
- return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
2534
- }
2535
- if (typedDef.type === Map || typedDef.type === mongoose4.Schema.Types.Map) {
2536
- const ofSchema = jsonTypeFor(typedDef.of || String, options, seen);
2537
- return { type: "object", additionalProperties: ofSchema };
2538
- }
2539
- if (typedDef.type === mongoose4.Schema.Types.Mixed) {
2540
- return { type: "object", additionalProperties: true };
2541
- }
2542
- if (isObjectIdType(typedDef.type)) {
2543
- return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
2544
- }
2545
- if (isMongooseSchema(typedDef.type)) {
2546
- const obj = typedDef.type.obj;
2547
- if (obj && typeof obj === "object") {
2548
- if (seen.has(obj)) return { type: "object", additionalProperties: true };
2549
- seen.add(obj);
2550
- return convertTreeToJsonSchema(obj, options, seen);
1084
+ if (started && session.inTransaction()) {
1085
+ await session.abortTransaction();
2551
1086
  }
1087
+ throw err;
1088
+ } finally {
1089
+ session.endSession();
2552
1090
  }
2553
1091
  }
2554
- if (def === String) return { type: "string" };
2555
- if (def === Number) return { type: "number" };
2556
- if (def === Boolean) return { type: "boolean" };
2557
- if (def === Date) {
2558
- const mode = options?.dateAs || "datetime";
2559
- return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
2560
- }
2561
- if (isObjectIdType(def)) return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
2562
- if (isPlainObject(def)) {
2563
- if (seen.has(def)) return { type: "object", additionalProperties: true };
2564
- seen.add(def);
2565
- return convertTreeToJsonSchema(def, options, seen);
1092
+ _isTransactionUnsupported(error) {
1093
+ const message = (error.message || "").toLowerCase();
1094
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
2566
1095
  }
2567
- return {};
2568
- }
2569
- function convertTreeToJsonSchema(tree, options, seen = /* @__PURE__ */ new WeakSet()) {
2570
- if (!tree || typeof tree !== "object") {
2571
- return { type: "object", properties: {} };
2572
- }
2573
- if (seen.has(tree)) {
2574
- return { type: "object", additionalProperties: true };
2575
- }
2576
- seen.add(tree);
2577
- const properties = {};
2578
- const required = [];
2579
- for (const [key, val] of Object.entries(tree || {})) {
2580
- if (key === "__v" || key === "_id" || key === "id") continue;
2581
- const cfg = isPlainObject(val) && "type" in val ? val : { };
2582
- properties[key] = jsonTypeFor(val, options, seen);
2583
- if (cfg.required === true) required.push(key);
2584
- }
2585
- const schema = { type: "object", properties };
2586
- if (required.length) schema.required = required;
2587
- return schema;
2588
- }
2589
- function buildJsonSchemaForCreate(tree, options) {
2590
- const base = convertTreeToJsonSchema(tree, options, /* @__PURE__ */ new WeakSet());
2591
- const fieldsToOmit = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "__v"]);
2592
- (options?.create?.omitFields || []).forEach((f) => fieldsToOmit.add(f));
2593
- const fieldRules = options?.fieldRules || {};
2594
- Object.entries(fieldRules).forEach(([field, rules]) => {
2595
- if (rules.systemManaged) {
2596
- fieldsToOmit.add(field);
2597
- }
2598
- });
2599
- fieldsToOmit.forEach((field) => {
2600
- if (base.properties?.[field]) {
2601
- delete base.properties[field];
2602
- }
2603
- if (base.required) {
2604
- base.required = base.required.filter((k) => k !== field);
2605
- }
2606
- });
2607
- const reqOv = options?.create?.requiredOverrides || {};
2608
- const optOv = options?.create?.optionalOverrides || {};
2609
- base.required = base.required || [];
2610
- for (const [k, v] of Object.entries(reqOv)) {
2611
- if (v && !base.required.includes(k)) base.required.push(k);
2612
- }
2613
- for (const [k, v] of Object.entries(optOv)) {
2614
- if (v && base.required) base.required = base.required.filter((x) => x !== k);
2615
- }
2616
- Object.entries(fieldRules).forEach(([field, rules]) => {
2617
- if (rules.optional && base.required) {
2618
- base.required = base.required.filter((x) => x !== field);
1096
+ /**
1097
+ * Execute custom query with event emission
1098
+ */
1099
+ async _executeQuery(buildQuery) {
1100
+ const operation = buildQuery.name || "custom";
1101
+ const context = await this._buildContext(operation, {});
1102
+ try {
1103
+ const result = await buildQuery(this.Model);
1104
+ await this._emitHook(`after:${operation}`, { context, result });
1105
+ return result;
1106
+ } catch (error) {
1107
+ await this._emitErrorHook(`error:${operation}`, { context, error });
1108
+ throw this._handleError(error);
2619
1109
  }
2620
- });
2621
- const schemaOverrides = options?.create?.schemaOverrides || {};
2622
- for (const [k, override] of Object.entries(schemaOverrides)) {
2623
- if (base.properties?.[k]) {
2624
- base.properties[k] = override;
1110
+ }
1111
+ /**
1112
+ * Build operation context and run before hooks
1113
+ */
1114
+ async _buildContext(operation, options) {
1115
+ const context = { operation, model: this.model, ...options };
1116
+ const event = `before:${operation}`;
1117
+ const hooks = this._hooks.get(event) || [];
1118
+ for (const hook of hooks) {
1119
+ await hook(context);
2625
1120
  }
1121
+ return context;
2626
1122
  }
2627
- if (options?.strictAdditionalProperties === true) {
2628
- base.additionalProperties = false;
1123
+ /**
1124
+ * Parse sort string or object
1125
+ */
1126
+ _parseSort(sort) {
1127
+ if (!sort) return { createdAt: -1 };
1128
+ if (typeof sort === "object") return sort;
1129
+ const sortOrder = sort.startsWith("-") ? -1 : 1;
1130
+ const sortField = sort.startsWith("-") ? sort.substring(1) : sort;
1131
+ return { [sortField]: sortOrder };
2629
1132
  }
2630
- return base;
2631
- }
2632
- function buildJsonSchemaForUpdate(createJson, options) {
2633
- const clone = JSON.parse(JSON.stringify(createJson));
2634
- delete clone.required;
2635
- const fieldsToOmit = /* @__PURE__ */ new Set();
2636
- (options?.update?.omitFields || []).forEach((f) => fieldsToOmit.add(f));
2637
- const fieldRules = options?.fieldRules || {};
2638
- Object.entries(fieldRules).forEach(([field, rules]) => {
2639
- if (rules.immutable || rules.immutableAfterCreate) {
2640
- fieldsToOmit.add(field);
2641
- }
2642
- });
2643
- fieldsToOmit.forEach((field) => {
2644
- if (clone.properties?.[field]) {
2645
- delete clone.properties[field];
2646
- }
2647
- });
2648
- if (options?.strictAdditionalProperties === true) {
2649
- clone.additionalProperties = false;
1133
+ /**
1134
+ * Parse populate specification
1135
+ */
1136
+ _parsePopulate(populate) {
1137
+ if (!populate) return [];
1138
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
1139
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
1140
+ return [populate];
2650
1141
  }
2651
- return clone;
2652
- }
2653
- function buildJsonSchemaForQuery(_tree, options) {
2654
- const basePagination = {
2655
- type: "object",
2656
- properties: {
2657
- page: { type: "string" },
2658
- limit: { type: "string" },
2659
- sort: { type: "string" },
2660
- populate: { type: "string" },
2661
- search: { type: "string" },
2662
- select: { type: "string" },
2663
- lean: { type: "string" },
2664
- includeDeleted: { type: "string" }
2665
- },
2666
- additionalProperties: true
2667
- };
2668
- const filterable = options?.query?.filterableFields || {};
2669
- for (const [k, v] of Object.entries(filterable)) {
2670
- if (basePagination.properties) {
2671
- basePagination.properties[k] = v && typeof v === "object" && "type" in v ? v : { type: "string" };
1142
+ /**
1143
+ * Handle errors with proper HTTP status codes
1144
+ */
1145
+ _handleError(error) {
1146
+ if (error instanceof mongoose.Error.ValidationError) {
1147
+ const messages = Object.values(error.errors).map((err) => err.message);
1148
+ return createError(400, `Validation Error: ${messages.join(", ")}`);
1149
+ }
1150
+ if (error instanceof mongoose.Error.CastError) {
1151
+ return createError(400, `Invalid ${error.path}: ${error.value}`);
2672
1152
  }
1153
+ if (error.status && error.message) return error;
1154
+ return createError(500, error.message || "Internal Server Error");
2673
1155
  }
2674
- return basePagination;
2675
- }
1156
+ };
2676
1157
  var QueryParser = class {
2677
1158
  options;
2678
1159
  operators = {
@@ -2691,21 +1172,26 @@ var QueryParser = class {
2691
1172
  size: "$size",
2692
1173
  type: "$type"
2693
1174
  };
2694
- /**
2695
- * Dangerous MongoDB operators that should never be accepted from user input
2696
- * Security: Prevent NoSQL injection attacks
2697
- */
2698
1175
  dangerousOperators;
2699
1176
  /**
2700
- * Regex pattern characters that can cause catastrophic backtracking (ReDoS)
1177
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
1178
+ * Detects:
1179
+ * - Quantifiers: {n,m}
1180
+ * - Possessive quantifiers: *+, ++, ?+
1181
+ * - Nested quantifiers: (a+)+, (a*)*
1182
+ * - Backreferences: \1, \2, etc.
1183
+ * - Complex character classes: [...]...[...]
2701
1184
  */
2702
- dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\([^)]*\))\1|\(\?[^)]*\)|[\[\]].*[\[\]])/;
1185
+ dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
2703
1186
  constructor(options = {}) {
2704
1187
  this.options = {
2705
1188
  maxRegexLength: options.maxRegexLength ?? 500,
2706
1189
  maxSearchLength: options.maxSearchLength ?? 200,
2707
1190
  maxFilterDepth: options.maxFilterDepth ?? 10,
2708
- additionalDangerousOperators: options.additionalDangerousOperators ?? []
1191
+ maxLimit: options.maxLimit ?? 1e3,
1192
+ additionalDangerousOperators: options.additionalDangerousOperators ?? [],
1193
+ enableLookups: options.enableLookups ?? true,
1194
+ enableAggregations: options.enableAggregations ?? false
2709
1195
  };
2710
1196
  this.dangerousOperators = [
2711
1197
  "$where",
@@ -2716,9 +1202,16 @@ var QueryParser = class {
2716
1202
  ];
2717
1203
  }
2718
1204
  /**
2719
- * Parse query parameters into MongoDB query format
1205
+ * Parse URL query parameters into MongoDB query format
1206
+ *
1207
+ * @example
1208
+ * ```typescript
1209
+ * // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
1210
+ * const query = parser.parse(req.query);
1211
+ * // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
1212
+ * ```
2720
1213
  */
2721
- parseQuery(query) {
1214
+ parse(query) {
2722
1215
  const {
2723
1216
  page,
2724
1217
  limit = 20,
@@ -2727,15 +1220,35 @@ var QueryParser = class {
2727
1220
  search,
2728
1221
  after,
2729
1222
  cursor,
1223
+ select,
1224
+ lookup,
1225
+ aggregate: aggregate2,
2730
1226
  ...filters
2731
1227
  } = query || {};
1228
+ let parsedLimit = parseInt(String(limit), 10);
1229
+ if (isNaN(parsedLimit) || parsedLimit < 1) {
1230
+ parsedLimit = 20;
1231
+ }
1232
+ if (parsedLimit > this.options.maxLimit) {
1233
+ console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1234
+ parsedLimit = this.options.maxLimit;
1235
+ }
2732
1236
  const parsed = {
2733
1237
  filters: this._parseFilters(filters),
2734
- limit: parseInt(String(limit), 10),
1238
+ limit: parsedLimit,
2735
1239
  sort: this._parseSort(sort),
2736
1240
  populate,
2737
1241
  search: this._sanitizeSearch(search)
2738
1242
  };
1243
+ if (select) {
1244
+ parsed.select = this._parseSelect(select);
1245
+ }
1246
+ if (this.options.enableLookups && lookup) {
1247
+ parsed.lookups = this._parseLookups(lookup);
1248
+ }
1249
+ if (this.options.enableAggregations && aggregate2) {
1250
+ parsed.aggregation = this._parseAggregation(aggregate2);
1251
+ }
2739
1252
  if (after || cursor) {
2740
1253
  parsed.after = after || cursor;
2741
1254
  } else if (page !== void 0) {
@@ -2750,29 +1263,161 @@ var QueryParser = class {
2750
1263
  parsed.filters = this._enhanceWithBetween(parsed.filters);
2751
1264
  return parsed;
2752
1265
  }
1266
+ // ============================================================
1267
+ // LOOKUP PARSING (NEW)
1268
+ // ============================================================
2753
1269
  /**
2754
- * Parse sort parameter
2755
- * Converts string like '-createdAt' to { createdAt: -1 }
2756
- * Handles multiple sorts: '-createdAt,name' → { createdAt: -1, name: 1 }
1270
+ * Parse lookup configurations from URL parameters
1271
+ *
1272
+ * Supported formats:
1273
+ * 1. Simple: ?lookup[department]=slug
1274
+ * → Join with 'departments' collection on slug field
1275
+ *
1276
+ * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
1277
+ * → Full control over join configuration
1278
+ *
1279
+ * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
1280
+ * → Multiple lookups
1281
+ *
1282
+ * @example
1283
+ * ```typescript
1284
+ * // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
1285
+ * const lookups = parser._parseLookups({
1286
+ * department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
1287
+ * });
1288
+ * // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
1289
+ * ```
2757
1290
  */
2758
- _parseSort(sort) {
2759
- if (!sort) return void 0;
2760
- if (typeof sort === "object") return sort;
2761
- const sortObj = {};
2762
- const fields = sort.split(",").map((s) => s.trim());
2763
- for (const field of fields) {
2764
- if (field.startsWith("-")) {
2765
- sortObj[field.substring(1)] = -1;
2766
- } else {
2767
- sortObj[field] = 1;
1291
+ _parseLookups(lookup) {
1292
+ if (!lookup || typeof lookup !== "object") return [];
1293
+ const lookups = [];
1294
+ const lookupObj = lookup;
1295
+ for (const [collectionName, config] of Object.entries(lookupObj)) {
1296
+ try {
1297
+ const lookupConfig = this._parseSingleLookup(collectionName, config);
1298
+ if (lookupConfig) {
1299
+ lookups.push(lookupConfig);
1300
+ }
1301
+ } catch (error) {
1302
+ console.warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
2768
1303
  }
2769
1304
  }
2770
- return sortObj;
1305
+ return lookups;
1306
+ }
1307
+ /**
1308
+ * Parse a single lookup configuration
1309
+ */
1310
+ _parseSingleLookup(collectionName, config) {
1311
+ if (!config) return null;
1312
+ if (typeof config === "string") {
1313
+ return {
1314
+ from: this._pluralize(collectionName),
1315
+ localField: `${collectionName}${this._capitalize(config)}`,
1316
+ foreignField: config,
1317
+ as: collectionName,
1318
+ single: true
1319
+ };
1320
+ }
1321
+ if (typeof config === "object" && config !== null) {
1322
+ const opts = config;
1323
+ const from = opts.from || this._pluralize(collectionName);
1324
+ const localField = opts.localField;
1325
+ const foreignField = opts.foreignField;
1326
+ if (!localField || !foreignField) {
1327
+ console.warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
1328
+ return null;
1329
+ }
1330
+ return {
1331
+ from,
1332
+ localField,
1333
+ foreignField,
1334
+ as: opts.as || collectionName,
1335
+ single: opts.single === true || opts.single === "true",
1336
+ ...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: opts.pipeline } : {}
1337
+ };
1338
+ }
1339
+ return null;
1340
+ }
1341
+ // ============================================================
1342
+ // AGGREGATION PARSING (ADVANCED)
1343
+ // ============================================================
1344
+ /**
1345
+ * Parse aggregation pipeline from URL (advanced feature)
1346
+ *
1347
+ * @example
1348
+ * ```typescript
1349
+ * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
1350
+ * const pipeline = parser._parseAggregation({
1351
+ * group: { _id: '$status', count: '$sum:1' }
1352
+ * });
1353
+ * ```
1354
+ */
1355
+ _parseAggregation(aggregate2) {
1356
+ if (!aggregate2 || typeof aggregate2 !== "object") return void 0;
1357
+ const pipeline = [];
1358
+ const aggObj = aggregate2;
1359
+ for (const [stage, config] of Object.entries(aggObj)) {
1360
+ try {
1361
+ if (stage === "group" && typeof config === "object") {
1362
+ pipeline.push({ $group: config });
1363
+ } else if (stage === "match" && typeof config === "object") {
1364
+ const sanitizedMatch = this._sanitizeMatchConfig(config);
1365
+ if (Object.keys(sanitizedMatch).length > 0) {
1366
+ pipeline.push({ $match: sanitizedMatch });
1367
+ }
1368
+ } else if (stage === "sort" && typeof config === "object") {
1369
+ pipeline.push({ $sort: config });
1370
+ } else if (stage === "project" && typeof config === "object") {
1371
+ pipeline.push({ $project: config });
1372
+ }
1373
+ } catch (error) {
1374
+ console.warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
1375
+ }
1376
+ }
1377
+ return pipeline.length > 0 ? pipeline : void 0;
1378
+ }
1379
+ // ============================================================
1380
+ // SELECT/PROJECT PARSING
1381
+ // ============================================================
1382
+ /**
1383
+ * Parse select/project fields
1384
+ *
1385
+ * @example
1386
+ * ```typescript
1387
+ * // URL: ?select=name,email,-password
1388
+ * // Returns: { name: 1, email: 1, password: 0 }
1389
+ * ```
1390
+ */
1391
+ _parseSelect(select) {
1392
+ if (!select) return void 0;
1393
+ if (typeof select === "string") {
1394
+ const projection = {};
1395
+ const fields = select.split(",").map((f) => f.trim());
1396
+ for (const field of fields) {
1397
+ if (field.startsWith("-")) {
1398
+ projection[field.substring(1)] = 0;
1399
+ } else {
1400
+ projection[field] = 1;
1401
+ }
1402
+ }
1403
+ return projection;
1404
+ }
1405
+ if (typeof select === "object" && select !== null) {
1406
+ return select;
1407
+ }
1408
+ return void 0;
2771
1409
  }
1410
+ // ============================================================
1411
+ // FILTER PARSING (Enhanced from original)
1412
+ // ============================================================
2772
1413
  /**
2773
- * Parse standard filter parameter (filter[field]=value)
1414
+ * Parse filter parameters
2774
1415
  */
2775
- _parseFilters(filters) {
1416
+ _parseFilters(filters, depth = 0) {
1417
+ if (depth > this.options.maxFilterDepth) {
1418
+ console.warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
1419
+ return {};
1420
+ }
2776
1421
  const parsedFilters = {};
2777
1422
  const regexFields = {};
2778
1423
  for (const [key, value] of Object.entries(filters)) {
@@ -2780,7 +1425,7 @@ var QueryParser = class {
2780
1425
  console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
2781
1426
  continue;
2782
1427
  }
2783
- if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted"].includes(key)) {
1428
+ if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate"].includes(key)) {
2784
1429
  continue;
2785
1430
  }
2786
1431
  const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
@@ -2794,7 +1439,7 @@ var QueryParser = class {
2794
1439
  continue;
2795
1440
  }
2796
1441
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
2797
- this._handleBracketSyntax(key, value, parsedFilters);
1442
+ this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
2798
1443
  } else {
2799
1444
  parsedFilters[key] = this._convertValue(value);
2800
1445
  }
@@ -2806,6 +1451,9 @@ var QueryParser = class {
2806
1451
  */
2807
1452
  _handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
2808
1453
  const [, field, operator] = operatorMatch;
1454
+ if (value === "" || value === null || value === void 0) {
1455
+ return;
1456
+ }
2809
1457
  if (operator.toLowerCase() === "options" && regexFields[field]) {
2810
1458
  const fieldValue = filters[field];
2811
1459
  if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
@@ -2823,18 +1471,18 @@ var QueryParser = class {
2823
1471
  }
2824
1472
  const mongoOperator = this._toMongoOperator(operator);
2825
1473
  if (this.dangerousOperators.includes(mongoOperator)) {
2826
- console.warn(`[mongokit] Blocked dangerous operator in field[${operator}]: ${mongoOperator}`);
1474
+ console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
2827
1475
  return;
2828
1476
  }
2829
1477
  if (mongoOperator === "$eq") {
2830
1478
  filters[field] = value;
2831
1479
  } else if (mongoOperator === "$regex") {
2832
- filters[field] = { $regex: value };
2833
- regexFields[field] = true;
2834
- } else {
2835
- if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
2836
- filters[field] = {};
1480
+ const safeRegex = this._createSafeRegex(value);
1481
+ if (safeRegex) {
1482
+ filters[field] = { $regex: safeRegex };
1483
+ regexFields[field] = true;
2837
1484
  }
1485
+ } else {
2838
1486
  let processedValue;
2839
1487
  const op = operator.toLowerCase();
2840
1488
  if (["gt", "gte", "lt", "lte", "size"].includes(op)) {
@@ -2845,17 +1493,25 @@ var QueryParser = class {
2845
1493
  } else {
2846
1494
  processedValue = this._convertValue(value);
2847
1495
  }
1496
+ if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
1497
+ filters[field] = {};
1498
+ }
2848
1499
  filters[field][mongoOperator] = processedValue;
2849
1500
  }
2850
1501
  }
2851
1502
  /**
2852
1503
  * Handle bracket syntax with object value
2853
1504
  */
2854
- _handleBracketSyntax(field, operators, parsedFilters) {
1505
+ _handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
1506
+ if (depth > this.options.maxFilterDepth) {
1507
+ console.warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
1508
+ return;
1509
+ }
2855
1510
  if (!parsedFilters[field]) {
2856
1511
  parsedFilters[field] = {};
2857
1512
  }
2858
1513
  for (const [operator, value] of Object.entries(operators)) {
1514
+ if (value === "" || value === null || value === void 0) continue;
2859
1515
  if (operator === "between") {
2860
1516
  parsedFilters[field].between = value;
2861
1517
  continue;
@@ -2868,7 +1524,7 @@ var QueryParser = class {
2868
1524
  if (isNaN(processedValue)) continue;
2869
1525
  } else if (operator === "in" || operator === "nin") {
2870
1526
  processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
2871
- } else if (operator === "like" || operator === "contains") {
1527
+ } else if (operator === "like" || operator === "contains" || operator === "regex") {
2872
1528
  const safeRegex = this._createSafeRegex(value);
2873
1529
  if (!safeRegex) continue;
2874
1530
  processedValue = safeRegex;
@@ -2878,31 +1534,40 @@ var QueryParser = class {
2878
1534
  parsedFilters[field][mongoOperator] = processedValue;
2879
1535
  }
2880
1536
  }
1537
+ if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) {
1538
+ delete parsedFilters[field];
1539
+ }
1540
+ }
1541
+ // ============================================================
1542
+ // UTILITY METHODS
1543
+ // ============================================================
1544
+ _parseSort(sort) {
1545
+ if (!sort) return void 0;
1546
+ if (typeof sort === "object") return sort;
1547
+ const sortObj = {};
1548
+ const fields = sort.split(",").map((s) => s.trim());
1549
+ for (const field of fields) {
1550
+ if (field.startsWith("-")) {
1551
+ sortObj[field.substring(1)] = -1;
1552
+ } else {
1553
+ sortObj[field] = 1;
1554
+ }
1555
+ }
1556
+ return sortObj;
2881
1557
  }
2882
- /**
2883
- * Convert operator to MongoDB format
2884
- */
2885
1558
  _toMongoOperator(operator) {
2886
1559
  const op = operator.toLowerCase();
2887
1560
  return op.startsWith("$") ? op : "$" + op;
2888
1561
  }
2889
- /**
2890
- * Create a safe regex pattern with protection against ReDoS attacks
2891
- * @param pattern - The pattern string from user input
2892
- * @param flags - Regex flags (default: 'i' for case-insensitive)
2893
- * @returns A safe RegExp or null if pattern is invalid/dangerous
2894
- */
2895
1562
  _createSafeRegex(pattern, flags = "i") {
2896
- if (pattern === null || pattern === void 0) {
2897
- return null;
2898
- }
1563
+ if (pattern === null || pattern === void 0) return null;
2899
1564
  const patternStr = String(pattern);
2900
1565
  if (patternStr.length > this.options.maxRegexLength) {
2901
- console.warn(`[mongokit] Regex pattern too long (${patternStr.length} > ${this.options.maxRegexLength}), truncating`);
1566
+ console.warn(`[mongokit] Regex pattern too long, truncating`);
2902
1567
  return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
2903
1568
  }
2904
1569
  if (this.dangerousRegexPatterns.test(patternStr)) {
2905
- console.warn("[mongokit] Potentially dangerous regex pattern detected, escaping");
1570
+ console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
2906
1571
  return new RegExp(this._escapeRegex(patternStr), flags);
2907
1572
  }
2908
1573
  try {
@@ -2911,34 +1576,45 @@ var QueryParser = class {
2911
1576
  return new RegExp(this._escapeRegex(patternStr), flags);
2912
1577
  }
2913
1578
  }
2914
- /**
2915
- * Escape special regex characters for literal matching
2916
- */
2917
1579
  _escapeRegex(str) {
2918
1580
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2919
1581
  }
2920
1582
  /**
2921
- * Sanitize text search query for MongoDB $text search
2922
- * @param search - Raw search input from user
2923
- * @returns Sanitized search string or undefined
1583
+ * Sanitize $match configuration to prevent dangerous operators
1584
+ * Recursively filters out operators like $where, $function, $accumulator
2924
1585
  */
2925
- _sanitizeSearch(search) {
2926
- if (search === null || search === void 0 || search === "") {
2927
- return void 0;
1586
+ _sanitizeMatchConfig(config) {
1587
+ const sanitized = {};
1588
+ for (const [key, value] of Object.entries(config)) {
1589
+ if (this.dangerousOperators.includes(key)) {
1590
+ console.warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
1591
+ continue;
1592
+ }
1593
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1594
+ sanitized[key] = this._sanitizeMatchConfig(value);
1595
+ } else if (Array.isArray(value)) {
1596
+ sanitized[key] = value.map((item) => {
1597
+ if (item && typeof item === "object" && !Array.isArray(item)) {
1598
+ return this._sanitizeMatchConfig(item);
1599
+ }
1600
+ return item;
1601
+ });
1602
+ } else {
1603
+ sanitized[key] = value;
1604
+ }
2928
1605
  }
1606
+ return sanitized;
1607
+ }
1608
+ _sanitizeSearch(search) {
1609
+ if (search === null || search === void 0 || search === "") return void 0;
2929
1610
  let searchStr = String(search).trim();
2930
- if (!searchStr) {
2931
- return void 0;
2932
- }
1611
+ if (!searchStr) return void 0;
2933
1612
  if (searchStr.length > this.options.maxSearchLength) {
2934
- console.warn(`[mongokit] Search query too long (${searchStr.length} > ${this.options.maxSearchLength}), truncating`);
1613
+ console.warn(`[mongokit] Search query too long, truncating`);
2935
1614
  searchStr = searchStr.substring(0, this.options.maxSearchLength);
2936
1615
  }
2937
1616
  return searchStr;
2938
1617
  }
2939
- /**
2940
- * Convert values based on operator type
2941
- */
2942
1618
  _convertValue(value) {
2943
1619
  if (value === null || value === void 0) return value;
2944
1620
  if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
@@ -2946,14 +1622,11 @@ var QueryParser = class {
2946
1622
  const stringValue = String(value);
2947
1623
  if (stringValue === "true") return true;
2948
1624
  if (stringValue === "false") return false;
2949
- if (mongoose4.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
1625
+ if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
2950
1626
  return stringValue;
2951
1627
  }
2952
1628
  return stringValue;
2953
1629
  }
2954
- /**
2955
- * Parse $or conditions
2956
- */
2957
1630
  _parseOr(query) {
2958
1631
  const orArray = [];
2959
1632
  const raw = query?.or || query?.OR || query?.$or;
@@ -2961,14 +1634,11 @@ var QueryParser = class {
2961
1634
  const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
2962
1635
  for (const item of items) {
2963
1636
  if (typeof item === "object" && item) {
2964
- orArray.push(this._parseFilters(item));
1637
+ orArray.push(this._parseFilters(item, 1));
2965
1638
  }
2966
1639
  }
2967
1640
  return orArray.length ? orArray : void 0;
2968
1641
  }
2969
- /**
2970
- * Enhance filters with between operator
2971
- */
2972
1642
  _enhanceWithBetween(filters) {
2973
1643
  const output = { ...filters };
2974
1644
  for (const [key, value] of Object.entries(filters || {})) {
@@ -2985,19 +1655,16 @@ var QueryParser = class {
2985
1655
  }
2986
1656
  return output;
2987
1657
  }
1658
+ // String helpers
1659
+ _pluralize(str) {
1660
+ if (str.endsWith("y")) return str.slice(0, -1) + "ies";
1661
+ if (str.endsWith("s")) return str;
1662
+ return str + "s";
1663
+ }
1664
+ _capitalize(str) {
1665
+ return str.charAt(0).toUpperCase() + str.slice(1);
1666
+ }
2988
1667
  };
2989
- var defaultQueryParser = new QueryParser();
2990
- var queryParser_default = defaultQueryParser;
2991
-
2992
- // src/actions/index.ts
2993
- var actions_exports = {};
2994
- __export(actions_exports, {
2995
- aggregate: () => aggregate_exports,
2996
- create: () => create_exports,
2997
- deleteActions: () => delete_exports,
2998
- read: () => read_exports,
2999
- update: () => update_exports
3000
- });
3001
1668
 
3002
1669
  // src/index.ts
3003
1670
  function createRepository(Model, plugins = [], paginationConfig = {}, options = {}) {
@@ -3011,7 +1678,7 @@ var index_default = Repository;
3011
1678
  * smart pagination, events, and plugins.
3012
1679
  *
3013
1680
  * @module @classytic/mongokit
3014
- * @author Sadman Chowdhury (Github: @siam923)
1681
+ * @author Classytic (https://github.com/classytic)
3015
1682
  * @license MIT
3016
1683
  *
3017
1684
  * @example
@@ -3043,4 +1710,4 @@ var index_default = Repository;
3043
1710
  * ```
3044
1711
  */
3045
1712
 
3046
- export { PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, createError, createFieldPreset, createMemoryCache, createRepository, index_default as default, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, queryParser_default as queryParser, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
1713
+ export { AggregationBuilder, LookupBuilder, QueryParser, Repository, createRepository, index_default as default };