@classytic/mongokit 3.2.1 → 3.2.3

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.
Files changed (48) hide show
  1. package/README.md +592 -194
  2. package/dist/actions/index.d.mts +9 -0
  3. package/dist/actions/index.mjs +15 -0
  4. package/dist/aggregate-BAi4Do-X.mjs +767 -0
  5. package/dist/aggregate-CCHI7F51.d.mts +269 -0
  6. package/dist/ai/index.d.mts +125 -0
  7. package/dist/ai/index.mjs +203 -0
  8. package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
  9. package/dist/chunk-DQk6qfdC.mjs +18 -0
  10. package/dist/create-BuO6xt0v.mjs +55 -0
  11. package/dist/custom-id.plugin-BmK0SjR9.d.mts +1039 -0
  12. package/dist/custom-id.plugin-m0VW6yYm.mjs +2169 -0
  13. package/dist/index.d.mts +1049 -0
  14. package/dist/index.mjs +2052 -0
  15. package/dist/limits-DsNeCx4D.mjs +299 -0
  16. package/dist/logger-D8ily-PP.mjs +51 -0
  17. package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
  18. package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
  19. package/dist/pagination/PaginationEngine.d.mts +93 -0
  20. package/dist/pagination/PaginationEngine.mjs +196 -0
  21. package/dist/plugins/index.d.mts +3 -0
  22. package/dist/plugins/index.mjs +3 -0
  23. package/dist/types-D-gploPr.d.mts +1241 -0
  24. package/dist/utils/{index.d.ts → index.d.mts} +14 -21
  25. package/dist/utils/index.mjs +5 -0
  26. package/package.json +21 -21
  27. package/dist/actions/index.d.ts +0 -3
  28. package/dist/actions/index.js +0 -5
  29. package/dist/ai/index.d.ts +0 -175
  30. package/dist/ai/index.js +0 -206
  31. package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
  32. package/dist/chunks/chunk-44KXLGPO.js +0 -388
  33. package/dist/chunks/chunk-5G42WJHC.js +0 -737
  34. package/dist/chunks/chunk-B64F5ZWE.js +0 -1226
  35. package/dist/chunks/chunk-GZBKEPVE.js +0 -46
  36. package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
  37. package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
  38. package/dist/chunks/chunk-URLJFIR7.js +0 -22
  39. package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
  40. package/dist/index-BDn5fSTE.d.ts +0 -516
  41. package/dist/index.d.ts +0 -1422
  42. package/dist/index.js +0 -1893
  43. package/dist/pagination/PaginationEngine.d.ts +0 -117
  44. package/dist/pagination/PaginationEngine.js +0 -3
  45. package/dist/plugins/index.d.ts +0 -922
  46. package/dist/plugins/index.js +0 -6
  47. package/dist/types-Jni1KgkP.d.ts +0 -780
  48. package/dist/utils/index.js +0 -5
@@ -0,0 +1,2169 @@
1
+ import { i as createError, n as debug, r as warn } from "./logger-D8ily-PP.mjs";
2
+ import { i as upsert } from "./create-BuO6xt0v.mjs";
3
+ import { a as modelPattern, i as listQueryKey, l as getFieldsForUser, n as byQueryKey, o as versionKey, t as byIdKey } from "./cache-keys-C8Z9B5sw.mjs";
4
+ import mongoose from "mongoose";
5
+
6
+ //#region src/plugins/field-filter.plugin.ts
7
+ /**
8
+ * Field Filter Plugin
9
+ * Automatically filters response fields based on user roles
10
+ */
11
+ /**
12
+ * Field filter plugin that restricts fields based on user context
13
+ *
14
+ * @example
15
+ * const fieldPreset = {
16
+ * public: ['id', 'name'],
17
+ * authenticated: ['email'],
18
+ * admin: ['createdAt', 'internalNotes']
19
+ * };
20
+ *
21
+ * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
22
+ */
23
+ function fieldFilterPlugin(fieldPreset) {
24
+ return {
25
+ name: "fieldFilter",
26
+ apply(repo) {
27
+ const applyFieldFiltering = (context) => {
28
+ if (!fieldPreset) return;
29
+ const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
30
+ if (context.select) context.select = `${presetSelect} ${context.select}`;
31
+ else context.select = presetSelect;
32
+ };
33
+ repo.on("before:getAll", applyFieldFiltering);
34
+ repo.on("before:getById", applyFieldFiltering);
35
+ repo.on("before:getByQuery", applyFieldFiltering);
36
+ }
37
+ };
38
+ }
39
+
40
+ //#endregion
41
+ //#region src/plugins/timestamp.plugin.ts
42
+ /**
43
+ * Timestamp plugin that auto-injects timestamps
44
+ *
45
+ * @example
46
+ * const repo = new Repository(Model, [timestampPlugin()]);
47
+ */
48
+ function timestampPlugin() {
49
+ return {
50
+ name: "timestamp",
51
+ apply(repo) {
52
+ repo.on("before:create", (context) => {
53
+ if (!context.data) return;
54
+ const now = /* @__PURE__ */ new Date();
55
+ if (!context.data.createdAt) context.data.createdAt = now;
56
+ if (!context.data.updatedAt) context.data.updatedAt = now;
57
+ });
58
+ repo.on("before:update", (context) => {
59
+ if (!context.data) return;
60
+ context.data.updatedAt = /* @__PURE__ */ new Date();
61
+ });
62
+ }
63
+ };
64
+ }
65
+
66
+ //#endregion
67
+ //#region src/plugins/audit-log.plugin.ts
68
+ /**
69
+ * Audit log plugin that logs all repository operations
70
+ *
71
+ * @example
72
+ * const repo = new Repository(Model, [auditLogPlugin(console)]);
73
+ */
74
+ function auditLogPlugin(logger) {
75
+ return {
76
+ name: "auditLog",
77
+ apply(repo) {
78
+ repo.on("after:create", ({ context, result }) => {
79
+ logger?.info?.("Document created", {
80
+ model: context.model || repo.model,
81
+ id: result?._id,
82
+ userId: context.user?._id || context.user?.id,
83
+ organizationId: context.organizationId
84
+ });
85
+ });
86
+ repo.on("after:update", ({ context, result }) => {
87
+ logger?.info?.("Document updated", {
88
+ model: context.model || repo.model,
89
+ id: context.id || result?._id,
90
+ userId: context.user?._id || context.user?.id,
91
+ organizationId: context.organizationId
92
+ });
93
+ });
94
+ repo.on("after:delete", ({ context }) => {
95
+ logger?.info?.("Document deleted", {
96
+ model: context.model || repo.model,
97
+ id: context.id,
98
+ userId: context.user?._id || context.user?.id,
99
+ organizationId: context.organizationId
100
+ });
101
+ });
102
+ repo.on("error:create", ({ context, error }) => {
103
+ logger?.error?.("Create failed", {
104
+ model: context.model || repo.model,
105
+ error: error.message,
106
+ userId: context.user?._id || context.user?.id
107
+ });
108
+ });
109
+ repo.on("error:update", ({ context, error }) => {
110
+ logger?.error?.("Update failed", {
111
+ model: context.model || repo.model,
112
+ id: context.id,
113
+ error: error.message,
114
+ userId: context.user?._id || context.user?.id
115
+ });
116
+ });
117
+ repo.on("error:delete", ({ context, error }) => {
118
+ logger?.error?.("Delete failed", {
119
+ model: context.model || repo.model,
120
+ id: context.id,
121
+ error: error.message,
122
+ userId: context.user?._id || context.user?.id
123
+ });
124
+ });
125
+ }
126
+ };
127
+ }
128
+
129
+ //#endregion
130
+ //#region src/plugins/soft-delete.plugin.ts
131
+ /**
132
+ * Build filter condition based on filter mode
133
+ */
134
+ function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
135
+ if (includeDeleted) return {};
136
+ if (filterMode === "exists") return { [deletedField]: { $exists: false } };
137
+ return { [deletedField]: null };
138
+ }
139
+ /**
140
+ * Build filter condition for finding deleted documents
141
+ */
142
+ function buildGetDeletedFilter(deletedField, filterMode) {
143
+ if (filterMode === "exists") return { [deletedField]: {
144
+ $exists: true,
145
+ $ne: null
146
+ } };
147
+ return { [deletedField]: { $ne: null } };
148
+ }
149
+ /**
150
+ * Soft delete plugin
151
+ *
152
+ * @example Basic usage
153
+ * ```typescript
154
+ * const repo = new Repository(Model, [
155
+ * softDeletePlugin({ deletedField: 'deletedAt' })
156
+ * ]);
157
+ *
158
+ * // Delete (soft)
159
+ * await repo.delete(id);
160
+ *
161
+ * // Restore
162
+ * await repo.restore(id);
163
+ *
164
+ * // Get deleted documents
165
+ * await repo.getDeleted({ page: 1, limit: 20 });
166
+ * ```
167
+ *
168
+ * @example With null filter mode (for schemas with default: null)
169
+ * ```typescript
170
+ * // Schema: { deletedAt: { type: Date, default: null } }
171
+ * const repo = new Repository(Model, [
172
+ * softDeletePlugin({
173
+ * deletedField: 'deletedAt',
174
+ * filterMode: 'null', // default - works with default: null
175
+ * })
176
+ * ]);
177
+ * ```
178
+ *
179
+ * @example With TTL for auto-cleanup
180
+ * ```typescript
181
+ * const repo = new Repository(Model, [
182
+ * softDeletePlugin({
183
+ * deletedField: 'deletedAt',
184
+ * ttlDays: 30, // Auto-delete after 30 days
185
+ * })
186
+ * ]);
187
+ * ```
188
+ */
189
+ function softDeletePlugin(options = {}) {
190
+ const deletedField = options.deletedField || "deletedAt";
191
+ const deletedByField = options.deletedByField || "deletedBy";
192
+ const filterMode = options.filterMode || "null";
193
+ const addRestoreMethod = options.addRestoreMethod !== false;
194
+ const addGetDeletedMethod = options.addGetDeletedMethod !== false;
195
+ const ttlDays = options.ttlDays;
196
+ return {
197
+ name: "softDelete",
198
+ apply(repo) {
199
+ try {
200
+ const schemaPaths = repo.Model.schema.paths;
201
+ for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
202
+ if (pathName === "_id" || pathName === deletedField) continue;
203
+ if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
204
+ }
205
+ } catch {}
206
+ if (ttlDays !== void 0 && ttlDays > 0) {
207
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
208
+ repo.Model.collection.createIndex({ [deletedField]: 1 }, {
209
+ expireAfterSeconds: ttlSeconds,
210
+ partialFilterExpression: { [deletedField]: { $type: "date" } }
211
+ }).catch((err) => {
212
+ if (!err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
213
+ });
214
+ }
215
+ repo.on("before:delete", async (context) => {
216
+ if (options.soft !== false) {
217
+ const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
218
+ if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
219
+ await repo.Model.findByIdAndUpdate(context.id, updateData, { session: context.session });
220
+ context.softDeleted = true;
221
+ }
222
+ });
223
+ repo.on("before:getAll", (context) => {
224
+ if (options.soft !== false) {
225
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
226
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
227
+ ...context.filters || {},
228
+ ...deleteFilter
229
+ };
230
+ }
231
+ });
232
+ repo.on("before:getById", (context) => {
233
+ if (options.soft !== false) {
234
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
235
+ if (Object.keys(deleteFilter).length > 0) context.query = {
236
+ ...context.query || {},
237
+ ...deleteFilter
238
+ };
239
+ }
240
+ });
241
+ repo.on("before:getByQuery", (context) => {
242
+ if (options.soft !== false) {
243
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
244
+ if (Object.keys(deleteFilter).length > 0) context.query = {
245
+ ...context.query || {},
246
+ ...deleteFilter
247
+ };
248
+ }
249
+ });
250
+ if (addRestoreMethod) {
251
+ const restoreMethod = async function(id, restoreOptions = {}) {
252
+ const updateData = {
253
+ [deletedField]: null,
254
+ [deletedByField]: null
255
+ };
256
+ const result = await this.Model.findByIdAndUpdate(id, { $set: updateData }, {
257
+ returnDocument: "after",
258
+ session: restoreOptions.session
259
+ });
260
+ if (!result) {
261
+ const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
262
+ error.status = 404;
263
+ throw error;
264
+ }
265
+ await this.emitAsync("after:restore", {
266
+ id,
267
+ result
268
+ });
269
+ return result;
270
+ };
271
+ if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
272
+ else repo.restore = restoreMethod.bind(repo);
273
+ }
274
+ if (addGetDeletedMethod) {
275
+ const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
276
+ const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
277
+ const combinedFilters = {
278
+ ...params.filters || {},
279
+ ...deletedFilter
280
+ };
281
+ const page = params.page || 1;
282
+ const limit = params.limit || 20;
283
+ const skip = (page - 1) * limit;
284
+ let sortSpec = { [deletedField]: -1 };
285
+ if (params.sort) if (typeof params.sort === "string") {
286
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
287
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
288
+ } else sortSpec = params.sort;
289
+ let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
290
+ if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
291
+ if (getDeletedOptions.select) {
292
+ const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
293
+ query = query.select(selectValue);
294
+ }
295
+ if (getDeletedOptions.populate) {
296
+ const populateSpec = getDeletedOptions.populate;
297
+ if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
298
+ else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
299
+ else query = query.populate(populateSpec);
300
+ }
301
+ if (getDeletedOptions.lean !== false) query = query.lean();
302
+ const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
303
+ const pages = Math.ceil(total / limit);
304
+ return {
305
+ method: "offset",
306
+ docs,
307
+ page,
308
+ limit,
309
+ total,
310
+ pages,
311
+ hasNext: page < pages,
312
+ hasPrev: page > 1
313
+ };
314
+ };
315
+ if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
316
+ else repo.getDeleted = getDeletedMethod.bind(repo);
317
+ }
318
+ }
319
+ };
320
+ }
321
+
322
+ //#endregion
323
+ //#region src/plugins/method-registry.plugin.ts
324
+ /**
325
+ * Method registry plugin that enables dynamic method registration
326
+ */
327
+ function methodRegistryPlugin() {
328
+ return {
329
+ name: "method-registry",
330
+ apply(repo) {
331
+ const registeredMethods = [];
332
+ /**
333
+ * Register a new method on the repository instance
334
+ */
335
+ repo.registerMethod = function(name, fn) {
336
+ if (repo[name]) throw new Error(`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`);
337
+ if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
338
+ if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
339
+ repo[name] = fn.bind(repo);
340
+ registeredMethods.push(name);
341
+ repo.emit("method:registered", {
342
+ name,
343
+ fn
344
+ });
345
+ };
346
+ /**
347
+ * Check if a method is registered
348
+ */
349
+ repo.hasMethod = function(name) {
350
+ return typeof repo[name] === "function";
351
+ };
352
+ /**
353
+ * Get list of all dynamically registered methods
354
+ */
355
+ repo.getRegisteredMethods = function() {
356
+ return [...registeredMethods];
357
+ };
358
+ }
359
+ };
360
+ }
361
+
362
+ //#endregion
363
+ //#region src/plugins/validation-chain.plugin.ts
364
+ /**
365
+ * Validation Chain Plugin
366
+ *
367
+ * Composable validation for repository operations with customizable rules.
368
+ */
369
+ /**
370
+ * Validation chain plugin
371
+ *
372
+ * @example
373
+ * const repo = new Repository(Model, [
374
+ * validationChainPlugin([
375
+ * requireField('email'),
376
+ * uniqueField('email', 'Email already exists'),
377
+ * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
378
+ * ])
379
+ * ]);
380
+ */
381
+ function validationChainPlugin(validators = [], options = {}) {
382
+ const { stopOnFirstError = true } = options;
383
+ validators.forEach((v, idx) => {
384
+ if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
385
+ if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
386
+ });
387
+ const validatorsByOperation = {
388
+ create: [],
389
+ update: [],
390
+ delete: [],
391
+ createMany: []
392
+ };
393
+ const allOperationsValidators = [];
394
+ validators.forEach((v) => {
395
+ if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
396
+ else v.operations.forEach((op) => {
397
+ if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
398
+ });
399
+ });
400
+ return {
401
+ name: "validation-chain",
402
+ apply(repo) {
403
+ const getValidatorsForOperation = (operation) => {
404
+ const specific = validatorsByOperation[operation] || [];
405
+ return [...allOperationsValidators, ...specific];
406
+ };
407
+ const runValidators = async (operation, context) => {
408
+ const operationValidators = getValidatorsForOperation(operation);
409
+ const errors = [];
410
+ for (const validator of operationValidators) try {
411
+ await validator.validate(context, repo);
412
+ } catch (error) {
413
+ if (stopOnFirstError) throw error;
414
+ errors.push({
415
+ validator: validator.name,
416
+ error: error.message || String(error)
417
+ });
418
+ }
419
+ if (errors.length > 0) {
420
+ const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
421
+ err.validationErrors = errors;
422
+ throw err;
423
+ }
424
+ };
425
+ repo.on("before:create", async (context) => runValidators("create", context));
426
+ repo.on("before:createMany", async (context) => runValidators("createMany", context));
427
+ repo.on("before:update", async (context) => runValidators("update", context));
428
+ repo.on("before:delete", async (context) => runValidators("delete", context));
429
+ }
430
+ };
431
+ }
432
+ /**
433
+ * Block operation if condition is true
434
+ *
435
+ * @example
436
+ * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
437
+ */
438
+ function blockIf(name, operations, condition, errorMessage) {
439
+ return {
440
+ name,
441
+ operations,
442
+ validate: (context) => {
443
+ if (condition(context)) throw createError(403, errorMessage);
444
+ }
445
+ };
446
+ }
447
+ /**
448
+ * Require a field to be present
449
+ */
450
+ function requireField(field, operations = ["create"]) {
451
+ return {
452
+ name: `require-${field}`,
453
+ operations,
454
+ validate: (context) => {
455
+ if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
456
+ }
457
+ };
458
+ }
459
+ /**
460
+ * Auto-inject a value if not present
461
+ */
462
+ function autoInject(field, getter, operations = ["create"]) {
463
+ return {
464
+ name: `auto-inject-${field}`,
465
+ operations,
466
+ validate: (context) => {
467
+ if (context.data && !(field in context.data)) {
468
+ const value = getter(context);
469
+ if (value !== null && value !== void 0) context.data[field] = value;
470
+ }
471
+ }
472
+ };
473
+ }
474
+ /**
475
+ * Make a field immutable (cannot be updated)
476
+ */
477
+ function immutableField(field) {
478
+ return {
479
+ name: `immutable-${field}`,
480
+ operations: ["update"],
481
+ validate: (context) => {
482
+ if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
483
+ }
484
+ };
485
+ }
486
+ /**
487
+ * Ensure field value is unique
488
+ */
489
+ function uniqueField(field, errorMessage) {
490
+ return {
491
+ name: `unique-${field}`,
492
+ operations: ["create", "update"],
493
+ validate: async (context, repo) => {
494
+ if (!context.data || !context.data[field] || !repo) return;
495
+ const query = { [field]: context.data[field] };
496
+ const getByQuery = repo.getByQuery;
497
+ if (typeof getByQuery !== "function") return;
498
+ const existing = await getByQuery.call(repo, query, {
499
+ select: "_id",
500
+ lean: true,
501
+ throwOnNotFound: false
502
+ });
503
+ if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
504
+ }
505
+ };
506
+ }
507
+
508
+ //#endregion
509
+ //#region src/plugins/mongo-operations.plugin.ts
510
+ /**
511
+ * MongoDB Operations Plugin
512
+ *
513
+ * Adds MongoDB-specific operations to repositories.
514
+ * Requires method-registry.plugin.js to be loaded first.
515
+ */
516
+ /**
517
+ * MongoDB operations plugin
518
+ *
519
+ * Adds MongoDB-specific atomic operations to repositories:
520
+ * - upsert: Create or update document
521
+ * - increment/decrement: Atomic numeric operations
522
+ * - pushToArray/pullFromArray/addToSet: Array operations
523
+ * - setField/unsetField/renameField: Field operations
524
+ * - multiplyField: Multiply numeric field
525
+ * - setMin/setMax: Conditional min/max updates
526
+ *
527
+ * @example Basic usage (no TypeScript autocomplete)
528
+ * ```typescript
529
+ * const repo = new Repository(ProductModel, [
530
+ * methodRegistryPlugin(),
531
+ * mongoOperationsPlugin(),
532
+ * ]);
533
+ *
534
+ * // Works at runtime but TypeScript doesn't know about these methods
535
+ * await (repo as any).increment(productId, 'views', 1);
536
+ * await (repo as any).pushToArray(productId, 'tags', 'featured');
537
+ * ```
538
+ *
539
+ * @example With TypeScript type safety (recommended)
540
+ * ```typescript
541
+ * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
542
+ * import type { MongoOperationsMethods } from '@classytic/mongokit';
543
+ *
544
+ * class ProductRepo extends Repository<IProduct> {
545
+ * // Add your custom methods here
546
+ * }
547
+ *
548
+ * // Create with type assertion to get autocomplete for plugin methods
549
+ * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
550
+ *
551
+ * const repo = new ProductRepo(ProductModel, [
552
+ * methodRegistryPlugin(),
553
+ * mongoOperationsPlugin(),
554
+ * ]) as ProductRepoWithPlugins;
555
+ *
556
+ * // Now TypeScript provides autocomplete and type checking!
557
+ * await repo.increment(productId, 'views', 1);
558
+ * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
559
+ * await repo.pushToArray(productId, 'tags', 'featured');
560
+ * ```
561
+ */
562
+ function mongoOperationsPlugin() {
563
+ return {
564
+ name: "mongo-operations",
565
+ apply(repo) {
566
+ if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
567
+ /**
568
+ * Update existing document or insert new one
569
+ */
570
+ repo.registerMethod("upsert", async function(query, data, options = {}) {
571
+ return upsert(this.Model, query, data, options);
572
+ });
573
+ const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
574
+ if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
575
+ return this.update(id, { [operator]: { [field]: value } }, options);
576
+ };
577
+ /**
578
+ * Atomically increment numeric field
579
+ */
580
+ repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
581
+ return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
582
+ });
583
+ /**
584
+ * Atomically decrement numeric field
585
+ */
586
+ repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
587
+ return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
588
+ });
589
+ const applyOperator = function(id, field, value, operator, options) {
590
+ return this.update(id, { [operator]: { [field]: value } }, options);
591
+ };
592
+ /**
593
+ * Push value to array field
594
+ */
595
+ repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
596
+ return applyOperator.call(this, id, field, value, "$push", options);
597
+ });
598
+ /**
599
+ * Remove value from array field
600
+ */
601
+ repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
602
+ return applyOperator.call(this, id, field, value, "$pull", options);
603
+ });
604
+ /**
605
+ * Add value to array only if not already present (unique)
606
+ */
607
+ repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
608
+ return applyOperator.call(this, id, field, value, "$addToSet", options);
609
+ });
610
+ /**
611
+ * Set field value (alias for update with $set)
612
+ */
613
+ repo.registerMethod("setField", async function(id, field, value, options = {}) {
614
+ return applyOperator.call(this, id, field, value, "$set", options);
615
+ });
616
+ /**
617
+ * Unset (remove) field from document
618
+ */
619
+ repo.registerMethod("unsetField", async function(id, fields, options = {}) {
620
+ const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
621
+ acc[field] = "";
622
+ return acc;
623
+ }, {});
624
+ return this.update(id, { $unset: unsetObj }, options);
625
+ });
626
+ /**
627
+ * Rename field in document
628
+ */
629
+ repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
630
+ return this.update(id, { $rename: { [oldName]: newName } }, options);
631
+ });
632
+ /**
633
+ * Multiply numeric field by value
634
+ */
635
+ repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
636
+ return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
637
+ });
638
+ /**
639
+ * Set field to minimum value (only if current value is greater)
640
+ */
641
+ repo.registerMethod("setMin", async function(id, field, value, options = {}) {
642
+ return applyOperator.call(this, id, field, value, "$min", options);
643
+ });
644
+ /**
645
+ * Set field to maximum value (only if current value is less)
646
+ */
647
+ repo.registerMethod("setMax", async function(id, field, value, options = {}) {
648
+ return applyOperator.call(this, id, field, value, "$max", options);
649
+ });
650
+ }
651
+ };
652
+ }
653
+
654
+ //#endregion
655
+ //#region src/plugins/batch-operations.plugin.ts
656
+ /**
657
+ * Batch operations plugin
658
+ *
659
+ * @example
660
+ * const repo = new Repository(Model, [
661
+ * methodRegistryPlugin(),
662
+ * batchOperationsPlugin(),
663
+ * ]);
664
+ *
665
+ * await repo.updateMany({ status: 'pending' }, { status: 'active' });
666
+ * await repo.deleteMany({ status: 'deleted' });
667
+ */
668
+ function batchOperationsPlugin() {
669
+ return {
670
+ name: "batch-operations",
671
+ apply(repo) {
672
+ if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
673
+ /**
674
+ * Update multiple documents
675
+ */
676
+ repo.registerMethod("updateMany", async function(query, data, options = {}) {
677
+ const context = await this._buildContext.call(this, "updateMany", {
678
+ query,
679
+ data,
680
+ options
681
+ });
682
+ try {
683
+ await this.emitAsync("before:updateMany", context);
684
+ if (Array.isArray(data) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
685
+ const result = await this.Model.updateMany(query, data, {
686
+ runValidators: true,
687
+ session: options.session,
688
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
689
+ }).exec();
690
+ await this.emitAsync("after:updateMany", {
691
+ context,
692
+ result
693
+ });
694
+ return result;
695
+ } catch (error) {
696
+ this.emit("error:updateMany", {
697
+ context,
698
+ error
699
+ });
700
+ throw this._handleError.call(this, error);
701
+ }
702
+ });
703
+ /**
704
+ * Delete multiple documents
705
+ */
706
+ repo.registerMethod("deleteMany", async function(query, options = {}) {
707
+ const context = await this._buildContext.call(this, "deleteMany", {
708
+ query,
709
+ options
710
+ });
711
+ try {
712
+ await this.emitAsync("before:deleteMany", context);
713
+ const result = await this.Model.deleteMany(query, { session: options.session }).exec();
714
+ await this.emitAsync("after:deleteMany", {
715
+ context,
716
+ result
717
+ });
718
+ return result;
719
+ } catch (error) {
720
+ this.emit("error:deleteMany", {
721
+ context,
722
+ error
723
+ });
724
+ throw this._handleError.call(this, error);
725
+ }
726
+ });
727
+ }
728
+ };
729
+ }
730
+
731
+ //#endregion
732
+ //#region src/plugins/aggregate-helpers.plugin.ts
733
+ /**
734
+ * Aggregate helpers plugin
735
+ *
736
+ * @example
737
+ * const repo = new Repository(Model, [
738
+ * methodRegistryPlugin(),
739
+ * aggregateHelpersPlugin(),
740
+ * ]);
741
+ *
742
+ * const groups = await repo.groupBy('category');
743
+ * const total = await repo.sum('amount', { status: 'completed' });
744
+ */
745
+ function aggregateHelpersPlugin() {
746
+ return {
747
+ name: "aggregate-helpers",
748
+ apply(repo) {
749
+ if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
750
+ /**
751
+ * Group by field
752
+ */
753
+ repo.registerMethod("groupBy", async function(field, options = {}) {
754
+ const pipeline = [{ $group: {
755
+ _id: `$${field}`,
756
+ count: { $sum: 1 }
757
+ } }, { $sort: { count: -1 } }];
758
+ if (options.limit) pipeline.push({ $limit: options.limit });
759
+ return this.aggregate.call(this, pipeline, options);
760
+ });
761
+ const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
762
+ const pipeline = [{ $match: query }, { $group: {
763
+ _id: null,
764
+ [resultKey]: { [operator]: `$${field}` }
765
+ } }];
766
+ return (await this.aggregate.call(this, pipeline, options))[0]?.[resultKey] || 0;
767
+ };
768
+ /**
769
+ * Sum field values
770
+ */
771
+ repo.registerMethod("sum", async function(field, query = {}, options = {}) {
772
+ return aggregateOperation.call(this, field, "$sum", "total", query, options);
773
+ });
774
+ /**
775
+ * Average field values
776
+ */
777
+ repo.registerMethod("average", async function(field, query = {}, options = {}) {
778
+ return aggregateOperation.call(this, field, "$avg", "avg", query, options);
779
+ });
780
+ /**
781
+ * Get minimum value
782
+ */
783
+ repo.registerMethod("min", async function(field, query = {}, options = {}) {
784
+ return aggregateOperation.call(this, field, "$min", "min", query, options);
785
+ });
786
+ /**
787
+ * Get maximum value
788
+ */
789
+ repo.registerMethod("max", async function(field, query = {}, options = {}) {
790
+ return aggregateOperation.call(this, field, "$max", "max", query, options);
791
+ });
792
+ }
793
+ };
794
+ }
795
+
796
+ //#endregion
797
+ //#region src/plugins/subdocument.plugin.ts
798
+ /**
799
+ * Subdocument plugin for managing nested arrays
800
+ *
801
+ * @example
802
+ * const repo = new Repository(Model, [
803
+ * methodRegistryPlugin(),
804
+ * subdocumentPlugin(),
805
+ * ]);
806
+ *
807
+ * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
808
+ * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
809
+ */
810
+ function subdocumentPlugin() {
811
+ return {
812
+ name: "subdocument",
813
+ apply(repo) {
814
+ if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
815
+ /**
816
+ * Add subdocument to array
817
+ */
818
+ repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
819
+ return this.update.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
820
+ });
821
+ /**
822
+ * Get subdocument from array
823
+ */
824
+ repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
825
+ return this._executeQuery.call(this, async (Model) => {
826
+ const parent = await Model.findById(parentId).session(options.session).exec();
827
+ if (!parent) throw createError(404, "Parent not found");
828
+ const arrayField = parent[arrayPath];
829
+ if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
830
+ const sub = arrayField.id(subId);
831
+ if (!sub) throw createError(404, "Subdocument not found");
832
+ return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
833
+ });
834
+ });
835
+ /**
836
+ * Update subdocument in array
837
+ */
838
+ repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
839
+ return this._executeQuery.call(this, async (Model) => {
840
+ const query = {
841
+ _id: parentId,
842
+ [`${arrayPath}._id`]: subId
843
+ };
844
+ const update = { $set: { [`${arrayPath}.$`]: {
845
+ ...updateData,
846
+ _id: subId
847
+ } } };
848
+ const result = await Model.findOneAndUpdate(query, update, {
849
+ returnDocument: "after",
850
+ runValidators: true,
851
+ session: options.session
852
+ }).exec();
853
+ if (!result) throw createError(404, "Parent or subdocument not found");
854
+ return result;
855
+ });
856
+ });
857
+ /**
858
+ * Delete subdocument from array
859
+ */
860
+ repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
861
+ return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
862
+ });
863
+ }
864
+ };
865
+ }
866
+
867
+ //#endregion
868
+ //#region src/plugins/cache.plugin.ts
869
+ /**
870
+ * Cache plugin factory
871
+ *
872
+ * @param options - Cache configuration
873
+ * @returns Plugin instance
874
+ */
875
+ function cachePlugin(options) {
876
+ const config = {
877
+ adapter: options.adapter,
878
+ ttl: options.ttl ?? 60,
879
+ byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
880
+ queryTtl: options.queryTtl ?? options.ttl ?? 60,
881
+ prefix: options.prefix ?? "mk",
882
+ debug: options.debug ?? false,
883
+ skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
884
+ };
885
+ const stats = {
886
+ hits: 0,
887
+ misses: 0,
888
+ sets: 0,
889
+ invalidations: 0
890
+ };
891
+ const log = (msg, data) => {
892
+ if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
893
+ };
894
+ return {
895
+ name: "cache",
896
+ apply(repo) {
897
+ const model = repo.model;
898
+ async function getVersion() {
899
+ try {
900
+ return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
901
+ } catch {
902
+ return 0;
903
+ }
904
+ }
905
+ /**
906
+ * Bump collection version in the adapter (invalidates all list caches).
907
+ * Uses Date.now() so version always moves forward — safe after eviction or deploy.
908
+ */
909
+ async function bumpVersion() {
910
+ const newVersion = Date.now();
911
+ try {
912
+ await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
913
+ stats.invalidations++;
914
+ log(`Bumped version for ${model} to:`, newVersion);
915
+ } catch (e) {
916
+ log(`Failed to bump version for ${model}:`, e);
917
+ }
918
+ }
919
+ /**
920
+ * Invalidate a specific document by ID
921
+ */
922
+ async function invalidateById(id) {
923
+ const key = byIdKey(config.prefix, model, id);
924
+ try {
925
+ await config.adapter.del(key);
926
+ stats.invalidations++;
927
+ log(`Invalidated byId cache:`, key);
928
+ } catch (e) {
929
+ log(`Failed to invalidate byId cache:`, e);
930
+ }
931
+ }
932
+ /**
933
+ * before:getById - Check cache for document
934
+ */
935
+ repo.on("before:getById", async (context) => {
936
+ if (context.skipCache) {
937
+ log(`Skipping cache for getById: ${context.id}`);
938
+ return;
939
+ }
940
+ const id = String(context.id);
941
+ const key = byIdKey(config.prefix, model, id);
942
+ try {
943
+ const cached = await config.adapter.get(key);
944
+ if (cached !== null) {
945
+ stats.hits++;
946
+ log(`Cache HIT for getById:`, key);
947
+ context._cacheHit = true;
948
+ context._cachedResult = cached;
949
+ } else {
950
+ stats.misses++;
951
+ log(`Cache MISS for getById:`, key);
952
+ }
953
+ } catch (e) {
954
+ log(`Cache error for getById:`, e);
955
+ stats.misses++;
956
+ }
957
+ });
958
+ /**
959
+ * before:getByQuery - Check cache for single-doc query
960
+ */
961
+ repo.on("before:getByQuery", async (context) => {
962
+ if (context.skipCache) {
963
+ log(`Skipping cache for getByQuery`);
964
+ return;
965
+ }
966
+ const query = context.query || {};
967
+ const key = byQueryKey(config.prefix, model, query, {
968
+ select: context.select,
969
+ populate: context.populate
970
+ });
971
+ try {
972
+ const cached = await config.adapter.get(key);
973
+ if (cached !== null) {
974
+ stats.hits++;
975
+ log(`Cache HIT for getByQuery:`, key);
976
+ context._cacheHit = true;
977
+ context._cachedResult = cached;
978
+ } else {
979
+ stats.misses++;
980
+ log(`Cache MISS for getByQuery:`, key);
981
+ }
982
+ } catch (e) {
983
+ log(`Cache error for getByQuery:`, e);
984
+ stats.misses++;
985
+ }
986
+ });
987
+ /**
988
+ * before:getAll - Check cache for list query
989
+ */
990
+ repo.on("before:getAll", async (context) => {
991
+ if (context.skipCache) {
992
+ log(`Skipping cache for getAll`);
993
+ return;
994
+ }
995
+ const limit = context.limit;
996
+ if (limit && limit > config.skipIfLargeLimit) {
997
+ log(`Skipping cache for large query (limit: ${limit})`);
998
+ return;
999
+ }
1000
+ const collectionVersion = await getVersion();
1001
+ const params = {
1002
+ filters: context.filters,
1003
+ sort: context.sort,
1004
+ page: context.page,
1005
+ limit,
1006
+ after: context.after,
1007
+ select: context.select,
1008
+ populate: context.populate,
1009
+ search: context.search
1010
+ };
1011
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
1012
+ try {
1013
+ const cached = await config.adapter.get(key);
1014
+ if (cached !== null) {
1015
+ stats.hits++;
1016
+ log(`Cache HIT for getAll:`, key);
1017
+ context._cacheHit = true;
1018
+ context._cachedResult = cached;
1019
+ } else {
1020
+ stats.misses++;
1021
+ log(`Cache MISS for getAll:`, key);
1022
+ }
1023
+ } catch (e) {
1024
+ log(`Cache error for getAll:`, e);
1025
+ stats.misses++;
1026
+ }
1027
+ });
1028
+ /**
1029
+ * after:getById - Cache the result
1030
+ */
1031
+ repo.on("after:getById", async (payload) => {
1032
+ const { context, result } = payload;
1033
+ if (context._cacheHit) return;
1034
+ if (context.skipCache) return;
1035
+ if (result === null) return;
1036
+ const id = String(context.id);
1037
+ const key = byIdKey(config.prefix, model, id);
1038
+ const ttl = context.cacheTtl ?? config.byIdTtl;
1039
+ try {
1040
+ await config.adapter.set(key, result, ttl);
1041
+ stats.sets++;
1042
+ log(`Cached getById result:`, key);
1043
+ } catch (e) {
1044
+ log(`Failed to cache getById:`, e);
1045
+ }
1046
+ });
1047
+ /**
1048
+ * after:getByQuery - Cache the result
1049
+ */
1050
+ repo.on("after:getByQuery", async (payload) => {
1051
+ const { context, result } = payload;
1052
+ if (context._cacheHit) return;
1053
+ if (context.skipCache) return;
1054
+ if (result === null) return;
1055
+ const query = context.query || {};
1056
+ const key = byQueryKey(config.prefix, model, query, {
1057
+ select: context.select,
1058
+ populate: context.populate
1059
+ });
1060
+ const ttl = context.cacheTtl ?? config.queryTtl;
1061
+ try {
1062
+ await config.adapter.set(key, result, ttl);
1063
+ stats.sets++;
1064
+ log(`Cached getByQuery result:`, key);
1065
+ } catch (e) {
1066
+ log(`Failed to cache getByQuery:`, e);
1067
+ }
1068
+ });
1069
+ /**
1070
+ * after:getAll - Cache the result
1071
+ */
1072
+ repo.on("after:getAll", async (payload) => {
1073
+ const { context, result } = payload;
1074
+ if (context._cacheHit) return;
1075
+ if (context.skipCache) return;
1076
+ const limit = context.limit;
1077
+ if (limit && limit > config.skipIfLargeLimit) return;
1078
+ const collectionVersion = await getVersion();
1079
+ const params = {
1080
+ filters: context.filters,
1081
+ sort: context.sort,
1082
+ page: context.page,
1083
+ limit,
1084
+ after: context.after,
1085
+ select: context.select,
1086
+ populate: context.populate,
1087
+ search: context.search
1088
+ };
1089
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
1090
+ const ttl = context.cacheTtl ?? config.queryTtl;
1091
+ try {
1092
+ await config.adapter.set(key, result, ttl);
1093
+ stats.sets++;
1094
+ log(`Cached getAll result:`, key);
1095
+ } catch (e) {
1096
+ log(`Failed to cache getAll:`, e);
1097
+ }
1098
+ });
1099
+ /**
1100
+ * after:create - Bump version to invalidate list caches
1101
+ */
1102
+ repo.on("after:create", async () => {
1103
+ await bumpVersion();
1104
+ });
1105
+ /**
1106
+ * after:createMany - Bump version to invalidate list caches
1107
+ */
1108
+ repo.on("after:createMany", async () => {
1109
+ await bumpVersion();
1110
+ });
1111
+ /**
1112
+ * after:update - Invalidate by ID and bump version
1113
+ */
1114
+ repo.on("after:update", async (payload) => {
1115
+ const { context } = payload;
1116
+ const id = String(context.id);
1117
+ await Promise.all([invalidateById(id), bumpVersion()]);
1118
+ });
1119
+ /**
1120
+ * after:updateMany - Bump version (can't track individual IDs efficiently)
1121
+ */
1122
+ repo.on("after:updateMany", async () => {
1123
+ await bumpVersion();
1124
+ });
1125
+ /**
1126
+ * after:delete - Invalidate by ID and bump version
1127
+ */
1128
+ repo.on("after:delete", async (payload) => {
1129
+ const { context } = payload;
1130
+ const id = String(context.id);
1131
+ await Promise.all([invalidateById(id), bumpVersion()]);
1132
+ });
1133
+ /**
1134
+ * after:deleteMany - Bump version
1135
+ */
1136
+ repo.on("after:deleteMany", async () => {
1137
+ await bumpVersion();
1138
+ });
1139
+ /**
1140
+ * Invalidate cache for a specific document
1141
+ * Use when document was updated outside this service
1142
+ *
1143
+ * @example
1144
+ * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
1145
+ */
1146
+ repo.invalidateCache = async (id) => {
1147
+ await invalidateById(id);
1148
+ log(`Manual invalidation for ID:`, id);
1149
+ };
1150
+ /**
1151
+ * Invalidate all list caches for this model
1152
+ * Use when bulk changes happened outside this service
1153
+ *
1154
+ * @example
1155
+ * await userRepo.invalidateListCache();
1156
+ */
1157
+ repo.invalidateListCache = async () => {
1158
+ await bumpVersion();
1159
+ log(`Manual list cache invalidation for ${model}`);
1160
+ };
1161
+ /**
1162
+ * Invalidate ALL cache entries for this model
1163
+ * Nuclear option - use sparingly
1164
+ *
1165
+ * @example
1166
+ * await userRepo.invalidateAllCache();
1167
+ */
1168
+ repo.invalidateAllCache = async () => {
1169
+ if (config.adapter.clear) try {
1170
+ await config.adapter.clear(modelPattern(config.prefix, model));
1171
+ stats.invalidations++;
1172
+ log(`Full cache invalidation for ${model}`);
1173
+ } catch (e) {
1174
+ log(`Failed full cache invalidation for ${model}:`, e);
1175
+ }
1176
+ else {
1177
+ await bumpVersion();
1178
+ log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
1179
+ }
1180
+ };
1181
+ /**
1182
+ * Get cache statistics for monitoring
1183
+ *
1184
+ * @example
1185
+ * const stats = userRepo.getCacheStats();
1186
+ * console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
1187
+ */
1188
+ repo.getCacheStats = () => ({ ...stats });
1189
+ /**
1190
+ * Reset cache statistics
1191
+ */
1192
+ repo.resetCacheStats = () => {
1193
+ stats.hits = 0;
1194
+ stats.misses = 0;
1195
+ stats.sets = 0;
1196
+ stats.invalidations = 0;
1197
+ };
1198
+ }
1199
+ };
1200
+ }
1201
+
1202
+ //#endregion
1203
+ //#region src/plugins/cascade.plugin.ts
1204
+ /**
1205
+ * Cascade Delete Plugin
1206
+ * Automatically deletes related documents when a parent document is deleted
1207
+ *
1208
+ * @example
1209
+ * ```typescript
1210
+ * import mongoose from 'mongoose';
1211
+ * import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
1212
+ *
1213
+ * const productRepo = new Repository(Product, [
1214
+ * methodRegistryPlugin(),
1215
+ * cascadePlugin({
1216
+ * relations: [
1217
+ * { model: 'StockEntry', foreignKey: 'product' },
1218
+ * { model: 'StockMovement', foreignKey: 'product' },
1219
+ * ]
1220
+ * })
1221
+ * ]);
1222
+ *
1223
+ * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
1224
+ * await productRepo.delete(productId);
1225
+ * ```
1226
+ */
1227
+ /**
1228
+ * Cascade delete plugin
1229
+ *
1230
+ * Deletes related documents after the parent document is deleted.
1231
+ * Works with both hard delete and soft delete scenarios.
1232
+ *
1233
+ * @param options - Cascade configuration
1234
+ * @returns Plugin
1235
+ */
1236
+ function cascadePlugin(options) {
1237
+ const { relations, parallel = true, logger } = options;
1238
+ if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
1239
+ return {
1240
+ name: "cascade",
1241
+ apply(repo) {
1242
+ repo.on("after:delete", async (payload) => {
1243
+ const { context } = payload;
1244
+ const deletedId = context.id;
1245
+ if (!deletedId) {
1246
+ logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
1247
+ return;
1248
+ }
1249
+ const isSoftDelete = context.softDeleted === true;
1250
+ const cascadeDelete = async (relation) => {
1251
+ const RelatedModel = mongoose.models[relation.model];
1252
+ if (!RelatedModel) {
1253
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
1254
+ parentModel: context.model,
1255
+ parentId: String(deletedId)
1256
+ });
1257
+ return;
1258
+ }
1259
+ const query = { [relation.foreignKey]: deletedId };
1260
+ try {
1261
+ if (relation.softDelete ?? isSoftDelete) {
1262
+ const updateResult = await RelatedModel.updateMany(query, {
1263
+ deletedAt: /* @__PURE__ */ new Date(),
1264
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
1265
+ }, { session: context.session });
1266
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
1267
+ parentModel: context.model,
1268
+ parentId: String(deletedId),
1269
+ relatedModel: relation.model,
1270
+ foreignKey: relation.foreignKey,
1271
+ count: updateResult.modifiedCount
1272
+ });
1273
+ } else {
1274
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
1275
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
1276
+ parentModel: context.model,
1277
+ parentId: String(deletedId),
1278
+ relatedModel: relation.model,
1279
+ foreignKey: relation.foreignKey,
1280
+ count: deleteResult.deletedCount
1281
+ });
1282
+ }
1283
+ } catch (error) {
1284
+ logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
1285
+ parentModel: context.model,
1286
+ parentId: String(deletedId),
1287
+ relatedModel: relation.model,
1288
+ foreignKey: relation.foreignKey,
1289
+ error: error.message
1290
+ });
1291
+ throw error;
1292
+ }
1293
+ };
1294
+ if (parallel) {
1295
+ const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
1296
+ if (failures.length) {
1297
+ const err = failures[0].reason;
1298
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
1299
+ throw err;
1300
+ }
1301
+ } else for (const relation of relations) await cascadeDelete(relation);
1302
+ });
1303
+ repo.on("after:deleteMany", async (payload) => {
1304
+ const { context, result } = payload;
1305
+ const query = context.query;
1306
+ if (!query || Object.keys(query).length === 0) {
1307
+ logger?.warn?.("Cascade deleteMany skipped: empty query", { model: context.model });
1308
+ return;
1309
+ }
1310
+ logger?.warn?.("Cascade deleteMany: use before:deleteMany hook for complete cascade support", { model: context.model });
1311
+ });
1312
+ repo.on("before:deleteMany", async (context) => {
1313
+ const query = context.query;
1314
+ if (!query || Object.keys(query).length === 0) return;
1315
+ context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
1316
+ });
1317
+ const originalAfterDeleteMany = repo._hooks.get("after:deleteMany") || [];
1318
+ repo._hooks.set("after:deleteMany", [...originalAfterDeleteMany, async (payload) => {
1319
+ const { context } = payload;
1320
+ const ids = context._cascadeIds;
1321
+ if (!ids || ids.length === 0) return;
1322
+ const isSoftDelete = context.softDeleted === true;
1323
+ const cascadeDeleteMany = async (relation) => {
1324
+ const RelatedModel = mongoose.models[relation.model];
1325
+ if (!RelatedModel) {
1326
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
1327
+ return;
1328
+ }
1329
+ const query = { [relation.foreignKey]: { $in: ids } };
1330
+ const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
1331
+ try {
1332
+ if (shouldSoftDelete) {
1333
+ const updateResult = await RelatedModel.updateMany(query, {
1334
+ deletedAt: /* @__PURE__ */ new Date(),
1335
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
1336
+ }, { session: context.session });
1337
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
1338
+ parentModel: context.model,
1339
+ parentCount: ids.length,
1340
+ relatedModel: relation.model,
1341
+ foreignKey: relation.foreignKey,
1342
+ count: updateResult.modifiedCount
1343
+ });
1344
+ } else {
1345
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
1346
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
1347
+ parentModel: context.model,
1348
+ parentCount: ids.length,
1349
+ relatedModel: relation.model,
1350
+ foreignKey: relation.foreignKey,
1351
+ count: deleteResult.deletedCount
1352
+ });
1353
+ }
1354
+ } catch (error) {
1355
+ logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
1356
+ parentModel: context.model,
1357
+ relatedModel: relation.model,
1358
+ foreignKey: relation.foreignKey,
1359
+ error: error.message
1360
+ });
1361
+ throw error;
1362
+ }
1363
+ };
1364
+ if (parallel) {
1365
+ const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
1366
+ if (failures.length) {
1367
+ const err = failures[0].reason;
1368
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
1369
+ throw err;
1370
+ }
1371
+ } else for (const relation of relations) await cascadeDeleteMany(relation);
1372
+ }]);
1373
+ }
1374
+ };
1375
+ }
1376
+
1377
+ //#endregion
1378
+ //#region src/plugins/multi-tenant.plugin.ts
1379
+ function multiTenantPlugin(options = {}) {
1380
+ const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext } = options;
1381
+ const readOps = [
1382
+ "getById",
1383
+ "getByQuery",
1384
+ "getAll",
1385
+ "aggregatePaginate",
1386
+ "lookupPopulate"
1387
+ ];
1388
+ const writeOps = [
1389
+ "create",
1390
+ "createMany",
1391
+ "update",
1392
+ "delete"
1393
+ ];
1394
+ const allOps = [...readOps, ...writeOps];
1395
+ return {
1396
+ name: "multi-tenant",
1397
+ apply(repo) {
1398
+ for (const op of allOps) {
1399
+ if (skipOperations.includes(op)) continue;
1400
+ repo.on(`before:${op}`, (context) => {
1401
+ if (skipWhen?.(context, op)) return;
1402
+ let tenantId = context[contextKey];
1403
+ if (!tenantId && resolveContext) {
1404
+ tenantId = resolveContext();
1405
+ if (tenantId) context[contextKey] = tenantId;
1406
+ }
1407
+ if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
1408
+ if (!tenantId) return;
1409
+ if (readOps.includes(op)) if (op === "getAll" || op === "aggregatePaginate" || op === "lookupPopulate") context.filters = {
1410
+ ...context.filters,
1411
+ [tenantField]: tenantId
1412
+ };
1413
+ else context.query = {
1414
+ ...context.query,
1415
+ [tenantField]: tenantId
1416
+ };
1417
+ if (op === "create" && context.data) context.data[tenantField] = tenantId;
1418
+ if (op === "createMany" && context.dataArray) for (const doc of context.dataArray) doc[tenantField] = tenantId;
1419
+ if (op === "update" || op === "delete") context.query = {
1420
+ ...context.query,
1421
+ [tenantField]: tenantId
1422
+ };
1423
+ });
1424
+ }
1425
+ }
1426
+ };
1427
+ }
1428
+
1429
+ //#endregion
1430
+ //#region src/plugins/observability.plugin.ts
1431
+ const DEFAULT_OPS = [
1432
+ "create",
1433
+ "createMany",
1434
+ "update",
1435
+ "delete",
1436
+ "getById",
1437
+ "getByQuery",
1438
+ "getAll",
1439
+ "aggregatePaginate",
1440
+ "lookupPopulate"
1441
+ ];
1442
+ const timers = /* @__PURE__ */ new WeakMap();
1443
+ function observabilityPlugin(options) {
1444
+ const { onMetric, slowThresholdMs } = options;
1445
+ const ops = options.operations ?? DEFAULT_OPS;
1446
+ return {
1447
+ name: "observability",
1448
+ apply(repo) {
1449
+ for (const op of ops) {
1450
+ repo.on(`before:${op}`, (context) => {
1451
+ timers.set(context, performance.now());
1452
+ });
1453
+ repo.on(`after:${op}`, ({ context }) => {
1454
+ const start = timers.get(context);
1455
+ if (start == null) return;
1456
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
1457
+ timers.delete(context);
1458
+ if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
1459
+ onMetric({
1460
+ operation: op,
1461
+ model: context.model || repo.model,
1462
+ durationMs,
1463
+ success: true,
1464
+ startedAt: new Date(Date.now() - durationMs),
1465
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
1466
+ organizationId: context.organizationId?.toString()
1467
+ });
1468
+ });
1469
+ repo.on(`error:${op}`, ({ context, error }) => {
1470
+ const start = timers.get(context);
1471
+ if (start == null) return;
1472
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
1473
+ timers.delete(context);
1474
+ onMetric({
1475
+ operation: op,
1476
+ model: context.model || repo.model,
1477
+ durationMs,
1478
+ success: false,
1479
+ error: error.message,
1480
+ startedAt: new Date(Date.now() - durationMs),
1481
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
1482
+ organizationId: context.organizationId?.toString()
1483
+ });
1484
+ });
1485
+ }
1486
+ }
1487
+ };
1488
+ }
1489
+
1490
+ //#endregion
1491
+ //#region src/plugins/audit-trail.plugin.ts
1492
+ /**
1493
+ * Audit Trail Plugin
1494
+ *
1495
+ * Persists operation audit entries to a MongoDB collection.
1496
+ * Fire-and-forget: writes happen async and never block or fail the main operation.
1497
+ *
1498
+ * Features:
1499
+ * - Tracks create, update, delete operations
1500
+ * - Field-level change tracking (before/after diff on updates)
1501
+ * - TTL auto-cleanup via MongoDB TTL index
1502
+ * - Custom metadata per entry (IP, user-agent, etc.)
1503
+ * - Shared `audit_trails` collection across all models
1504
+ *
1505
+ * @example
1506
+ * ```typescript
1507
+ * const repo = new Repository(Job, [
1508
+ * auditTrailPlugin({
1509
+ * operations: ['create', 'update', 'delete'],
1510
+ * trackChanges: true,
1511
+ * ttlDays: 90,
1512
+ * metadata: (context) => ({
1513
+ * ip: context.req?.ip,
1514
+ * }),
1515
+ * }),
1516
+ * ]);
1517
+ * ```
1518
+ */
1519
+ const modelCache = /* @__PURE__ */ new Map();
1520
+ function getAuditModel(collectionName, ttlDays) {
1521
+ const existing = modelCache.get(collectionName);
1522
+ if (existing) return existing;
1523
+ const schema = new mongoose.Schema({
1524
+ model: {
1525
+ type: String,
1526
+ required: true,
1527
+ index: true
1528
+ },
1529
+ operation: {
1530
+ type: String,
1531
+ required: true,
1532
+ enum: [
1533
+ "create",
1534
+ "update",
1535
+ "delete"
1536
+ ]
1537
+ },
1538
+ documentId: {
1539
+ type: mongoose.Schema.Types.Mixed,
1540
+ required: true,
1541
+ index: true
1542
+ },
1543
+ userId: {
1544
+ type: mongoose.Schema.Types.Mixed,
1545
+ index: true
1546
+ },
1547
+ orgId: {
1548
+ type: mongoose.Schema.Types.Mixed,
1549
+ index: true
1550
+ },
1551
+ changes: { type: mongoose.Schema.Types.Mixed },
1552
+ document: { type: mongoose.Schema.Types.Mixed },
1553
+ metadata: { type: mongoose.Schema.Types.Mixed },
1554
+ timestamp: {
1555
+ type: Date,
1556
+ default: Date.now,
1557
+ index: true
1558
+ }
1559
+ }, {
1560
+ collection: collectionName,
1561
+ versionKey: false
1562
+ });
1563
+ schema.index({
1564
+ model: 1,
1565
+ documentId: 1,
1566
+ timestamp: -1
1567
+ });
1568
+ schema.index({
1569
+ orgId: 1,
1570
+ userId: 1,
1571
+ timestamp: -1
1572
+ });
1573
+ if (ttlDays !== void 0 && ttlDays > 0) {
1574
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
1575
+ schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
1576
+ }
1577
+ const modelName = `AuditTrail_${collectionName}`;
1578
+ const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
1579
+ modelCache.set(collectionName, model);
1580
+ return model;
1581
+ }
1582
+ /** Compute field-level diff between previous and updated document */
1583
+ function computeChanges(prev, next, excludeFields) {
1584
+ const changes = {};
1585
+ const exclude = new Set(excludeFields);
1586
+ for (const key of Object.keys(next)) {
1587
+ if (exclude.has(key)) continue;
1588
+ if (key === "_id" || key === "__v" || key === "updatedAt") continue;
1589
+ const prevVal = prev[key];
1590
+ const nextVal = next[key];
1591
+ if (!deepEqual(prevVal, nextVal)) changes[key] = {
1592
+ from: prevVal,
1593
+ to: nextVal
1594
+ };
1595
+ }
1596
+ return Object.keys(changes).length > 0 ? changes : void 0;
1597
+ }
1598
+ /** Simple deep equality check for audit diffing */
1599
+ function deepEqual(a, b) {
1600
+ if (a === b) return true;
1601
+ if (a == null && b == null) return true;
1602
+ if (a == null || b == null) return false;
1603
+ if (typeof a === "object" && typeof b === "object") {
1604
+ const aStr = a.toString?.();
1605
+ const bStr = b.toString?.();
1606
+ if (aStr && bStr && aStr === bStr) return true;
1607
+ }
1608
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
1609
+ try {
1610
+ return JSON.stringify(a) === JSON.stringify(b);
1611
+ } catch {
1612
+ return false;
1613
+ }
1614
+ }
1615
+ /** Extract user ID from context */
1616
+ function getUserId(context) {
1617
+ return context.user?._id || context.user?.id;
1618
+ }
1619
+ /** Fire-and-forget: write audit entry, never throw */
1620
+ function writeAudit(AuditModel, entry) {
1621
+ Promise.resolve().then(() => {
1622
+ AuditModel.create({
1623
+ ...entry,
1624
+ timestamp: /* @__PURE__ */ new Date()
1625
+ }).catch((err) => {
1626
+ warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
1627
+ });
1628
+ });
1629
+ }
1630
+ const snapshots = /* @__PURE__ */ new WeakMap();
1631
+ function auditTrailPlugin(options = {}) {
1632
+ const { operations = [
1633
+ "create",
1634
+ "update",
1635
+ "delete"
1636
+ ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
1637
+ const opsSet = new Set(operations);
1638
+ return {
1639
+ name: "auditTrail",
1640
+ apply(repo) {
1641
+ const AuditModel = getAuditModel(collectionName, ttlDays);
1642
+ if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
1643
+ const doc = toPlainObject(result);
1644
+ writeAudit(AuditModel, {
1645
+ model: context.model || repo.model,
1646
+ operation: "create",
1647
+ documentId: doc?._id,
1648
+ userId: getUserId(context),
1649
+ orgId: context.organizationId,
1650
+ document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
1651
+ metadata: metadata?.(context)
1652
+ });
1653
+ });
1654
+ if (opsSet.has("update")) {
1655
+ if (trackChanges) repo.on("before:update", async (context) => {
1656
+ if (!context.id) return;
1657
+ try {
1658
+ const prev = await repo.Model.findById(context.id).lean();
1659
+ if (prev) snapshots.set(context, prev);
1660
+ } catch (err) {
1661
+ warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
1662
+ }
1663
+ });
1664
+ repo.on("after:update", ({ context, result }) => {
1665
+ const doc = result;
1666
+ let changes;
1667
+ if (trackChanges) {
1668
+ const prev = snapshots.get(context);
1669
+ if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
1670
+ snapshots.delete(context);
1671
+ }
1672
+ writeAudit(AuditModel, {
1673
+ model: context.model || repo.model,
1674
+ operation: "update",
1675
+ documentId: context.id || doc?._id,
1676
+ userId: getUserId(context),
1677
+ orgId: context.organizationId,
1678
+ changes,
1679
+ metadata: metadata?.(context)
1680
+ });
1681
+ });
1682
+ }
1683
+ if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
1684
+ writeAudit(AuditModel, {
1685
+ model: context.model || repo.model,
1686
+ operation: "delete",
1687
+ documentId: context.id,
1688
+ userId: getUserId(context),
1689
+ orgId: context.organizationId,
1690
+ metadata: metadata?.(context)
1691
+ });
1692
+ });
1693
+ if (typeof repo.registerMethod === "function")
1694
+ /**
1695
+ * Get audit trail for a specific document
1696
+ */
1697
+ repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
1698
+ const { page = 1, limit = 20, operation } = queryOptions;
1699
+ const skip = (page - 1) * limit;
1700
+ const filter = {
1701
+ model: this.model,
1702
+ documentId
1703
+ };
1704
+ if (operation) filter.operation = operation;
1705
+ const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
1706
+ return {
1707
+ docs,
1708
+ page,
1709
+ limit,
1710
+ total,
1711
+ pages: Math.ceil(total / limit),
1712
+ hasNext: page < Math.ceil(total / limit),
1713
+ hasPrev: page > 1
1714
+ };
1715
+ });
1716
+ }
1717
+ };
1718
+ }
1719
+ /** Convert Mongoose document to plain object */
1720
+ function toPlainObject(doc) {
1721
+ if (!doc) return {};
1722
+ if (typeof doc.toObject === "function") return doc.toObject();
1723
+ return doc;
1724
+ }
1725
+ /** Remove excluded fields from a document snapshot */
1726
+ function sanitizeDoc(doc, excludeFields) {
1727
+ if (excludeFields.length === 0) return doc;
1728
+ const result = { ...doc };
1729
+ for (const field of excludeFields) delete result[field];
1730
+ return result;
1731
+ }
1732
+ /**
1733
+ * Standalone audit trail query utility.
1734
+ * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
1735
+ *
1736
+ * @example
1737
+ * ```typescript
1738
+ * import { AuditTrailQuery } from '@classytic/mongokit';
1739
+ *
1740
+ * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
1741
+ *
1742
+ * // All audits for an org
1743
+ * const orgAudits = await auditQuery.query({ orgId: '...' });
1744
+ *
1745
+ * // All updates by a user
1746
+ * const userUpdates = await auditQuery.query({
1747
+ * userId: '...',
1748
+ * operation: 'update',
1749
+ * });
1750
+ *
1751
+ * // All audits for a specific document
1752
+ * const docHistory = await auditQuery.query({
1753
+ * model: 'Job',
1754
+ * documentId: '...',
1755
+ * });
1756
+ *
1757
+ * // Date range
1758
+ * const recent = await auditQuery.query({
1759
+ * from: new Date('2025-01-01'),
1760
+ * to: new Date(),
1761
+ * page: 1,
1762
+ * limit: 50,
1763
+ * });
1764
+ *
1765
+ * // Direct model access for custom queries
1766
+ * const model = auditQuery.getModel();
1767
+ * const count = await model.countDocuments({ operation: 'delete' });
1768
+ * ```
1769
+ */
1770
+ var AuditTrailQuery = class {
1771
+ model;
1772
+ constructor(collectionName = "audit_trails", ttlDays) {
1773
+ this.model = getAuditModel(collectionName, ttlDays);
1774
+ }
1775
+ /**
1776
+ * Get the underlying Mongoose model for custom queries
1777
+ */
1778
+ getModel() {
1779
+ return this.model;
1780
+ }
1781
+ /**
1782
+ * Query audit entries with filters and pagination
1783
+ */
1784
+ async query(options = {}) {
1785
+ const { page = 1, limit = 20 } = options;
1786
+ const skip = (page - 1) * limit;
1787
+ const filter = {};
1788
+ if (options.model) filter.model = options.model;
1789
+ if (options.documentId) filter.documentId = options.documentId;
1790
+ if (options.userId) filter.userId = options.userId;
1791
+ if (options.orgId) filter.orgId = options.orgId;
1792
+ if (options.operation) filter.operation = options.operation;
1793
+ if (options.from || options.to) {
1794
+ const dateFilter = {};
1795
+ if (options.from) dateFilter.$gte = options.from;
1796
+ if (options.to) dateFilter.$lte = options.to;
1797
+ filter.timestamp = dateFilter;
1798
+ }
1799
+ const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
1800
+ const pages = Math.ceil(total / limit);
1801
+ return {
1802
+ docs,
1803
+ page,
1804
+ limit,
1805
+ total,
1806
+ pages,
1807
+ hasNext: page < pages,
1808
+ hasPrev: page > 1
1809
+ };
1810
+ }
1811
+ /**
1812
+ * Get audit trail for a specific document
1813
+ */
1814
+ async getDocumentTrail(model, documentId, options = {}) {
1815
+ return this.query({
1816
+ model,
1817
+ documentId,
1818
+ ...options
1819
+ });
1820
+ }
1821
+ /**
1822
+ * Get all audits for a user
1823
+ */
1824
+ async getUserTrail(userId, options = {}) {
1825
+ return this.query({
1826
+ userId,
1827
+ ...options
1828
+ });
1829
+ }
1830
+ /**
1831
+ * Get all audits for an organization
1832
+ */
1833
+ async getOrgTrail(orgId, options = {}) {
1834
+ return this.query({
1835
+ orgId,
1836
+ ...options
1837
+ });
1838
+ }
1839
+ };
1840
+
1841
+ //#endregion
1842
+ //#region src/plugins/elastic.plugin.ts
1843
+ function elasticSearchPlugin(options) {
1844
+ return {
1845
+ name: "elastic-search",
1846
+ apply(repo) {
1847
+ if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
1848
+ repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
1849
+ const { client, index, idField = "_id" } = options;
1850
+ const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
1851
+ const from = Math.max(searchOptions.from || 0, 0);
1852
+ const esResponse = await client.search({
1853
+ index,
1854
+ body: {
1855
+ query: searchQuery,
1856
+ size: limit,
1857
+ from
1858
+ }
1859
+ });
1860
+ const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
1861
+ if (hits.length === 0) return {
1862
+ docs: [],
1863
+ total: 0,
1864
+ limit,
1865
+ from
1866
+ };
1867
+ const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
1868
+ const total = typeof totalValue === "number" ? totalValue : 0;
1869
+ const docsOrder = /* @__PURE__ */ new Map();
1870
+ const scores = /* @__PURE__ */ new Map();
1871
+ const ids = [];
1872
+ hits.forEach((hit, idx) => {
1873
+ const docId = hit._source?.[idField] || hit[idField] || hit._id;
1874
+ if (docId) {
1875
+ const strId = String(docId);
1876
+ docsOrder.set(strId, idx);
1877
+ if (hit._score !== void 0) scores.set(strId, hit._score);
1878
+ ids.push(strId);
1879
+ }
1880
+ });
1881
+ if (ids.length === 0) return {
1882
+ docs: [],
1883
+ total,
1884
+ limit,
1885
+ from
1886
+ };
1887
+ const mongoQuery = this.Model.find({ _id: { $in: ids } });
1888
+ if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
1889
+ if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
1890
+ if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
1891
+ return {
1892
+ docs: (await mongoQuery.exec()).sort((a, b) => {
1893
+ const aId = String(a._id);
1894
+ const bId = String(b._id);
1895
+ return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
1896
+ }).map((doc) => {
1897
+ const strId = String(doc._id);
1898
+ if (searchOptions.mongoOptions?.lean !== false) return {
1899
+ ...doc,
1900
+ _score: scores.get(strId)
1901
+ };
1902
+ return doc;
1903
+ }),
1904
+ total,
1905
+ limit,
1906
+ from
1907
+ };
1908
+ });
1909
+ }
1910
+ };
1911
+ }
1912
+
1913
+ //#endregion
1914
+ //#region src/plugins/custom-id.plugin.ts
1915
+ /**
1916
+ * Custom ID Plugin
1917
+ *
1918
+ * Generates custom document IDs using pluggable generators.
1919
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
1920
+ * date-partitioned sequences, and fully custom generators.
1921
+ *
1922
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
1923
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
1924
+ *
1925
+ * @example Basic sequential counter
1926
+ * ```typescript
1927
+ * const invoiceRepo = new Repository(InvoiceModel, [
1928
+ * customIdPlugin({
1929
+ * field: 'invoiceNumber',
1930
+ * generator: sequentialId({
1931
+ * prefix: 'INV',
1932
+ * model: InvoiceModel,
1933
+ * }),
1934
+ * }),
1935
+ * ]);
1936
+ *
1937
+ * const inv = await invoiceRepo.create({ amount: 100 });
1938
+ * // inv.invoiceNumber → "INV-0001"
1939
+ * ```
1940
+ *
1941
+ * @example Date-partitioned counter (resets monthly)
1942
+ * ```typescript
1943
+ * const billRepo = new Repository(BillModel, [
1944
+ * customIdPlugin({
1945
+ * field: 'billNumber',
1946
+ * generator: dateSequentialId({
1947
+ * prefix: 'BILL',
1948
+ * model: BillModel,
1949
+ * partition: 'monthly',
1950
+ * separator: '-',
1951
+ * padding: 4,
1952
+ * }),
1953
+ * }),
1954
+ * ]);
1955
+ *
1956
+ * const bill = await billRepo.create({ total: 250 });
1957
+ * // bill.billNumber → "BILL-2026-02-0001"
1958
+ * ```
1959
+ *
1960
+ * @example Custom generator function
1961
+ * ```typescript
1962
+ * const orderRepo = new Repository(OrderModel, [
1963
+ * customIdPlugin({
1964
+ * field: 'orderRef',
1965
+ * generator: async (context) => {
1966
+ * const region = context.data?.region || 'US';
1967
+ * const seq = await getNextSequence('orders');
1968
+ * return `ORD-${region}-${seq}`;
1969
+ * },
1970
+ * }),
1971
+ * ]);
1972
+ * ```
1973
+ */
1974
+ /** Schema for the internal counters collection */
1975
+ const counterSchema = new mongoose.Schema({
1976
+ _id: {
1977
+ type: String,
1978
+ required: true
1979
+ },
1980
+ seq: {
1981
+ type: Number,
1982
+ default: 0
1983
+ }
1984
+ }, {
1985
+ collection: "_mongokit_counters",
1986
+ versionKey: false
1987
+ });
1988
+ /**
1989
+ * Get or create the Counter model.
1990
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
1991
+ */
1992
+ function getCounterModel() {
1993
+ if (mongoose.models._MongoKitCounter) return mongoose.models._MongoKitCounter;
1994
+ return mongoose.model("_MongoKitCounter", counterSchema);
1995
+ }
1996
+ /**
1997
+ * Atomically increment and return the next sequence value for a given key.
1998
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
1999
+ * heavy concurrency.
2000
+ *
2001
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
2002
+ * @param increment - Value to increment by (default: 1)
2003
+ * @returns The next sequence number (after increment)
2004
+ *
2005
+ * @example
2006
+ * const seq = await getNextSequence('invoices');
2007
+ * // First call → 1, second → 2, ...
2008
+ *
2009
+ * @example Batch increment for createMany
2010
+ * const startSeq = await getNextSequence('invoices', 5);
2011
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
2012
+ */
2013
+ async function getNextSequence(counterKey, increment = 1) {
2014
+ return (await getCounterModel().findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
2015
+ upsert: true,
2016
+ returnDocument: "after"
2017
+ })).seq;
2018
+ }
2019
+ /**
2020
+ * Generator: Simple sequential counter.
2021
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
2022
+ *
2023
+ * Uses atomic MongoDB counters — safe under concurrency.
2024
+ *
2025
+ * @example
2026
+ * ```typescript
2027
+ * customIdPlugin({
2028
+ * field: 'invoiceNumber',
2029
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2030
+ * })
2031
+ * ```
2032
+ */
2033
+ function sequentialId(options) {
2034
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
2035
+ const key = counterKey || model.modelName;
2036
+ return async (_context) => {
2037
+ const seq = await getNextSequence(key);
2038
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
2039
+ };
2040
+ }
2041
+ /**
2042
+ * Generator: Date-partitioned sequential counter.
2043
+ * Counter resets per period — great for invoice/bill numbering.
2044
+ *
2045
+ * Produces IDs like:
2046
+ * - yearly: `BILL-2026-0001`
2047
+ * - monthly: `BILL-2026-02-0001`
2048
+ * - daily: `BILL-2026-02-20-0001`
2049
+ *
2050
+ * @example
2051
+ * ```typescript
2052
+ * customIdPlugin({
2053
+ * field: 'billNumber',
2054
+ * generator: dateSequentialId({
2055
+ * prefix: 'BILL',
2056
+ * model: BillModel,
2057
+ * partition: 'monthly',
2058
+ * }),
2059
+ * })
2060
+ * ```
2061
+ */
2062
+ function dateSequentialId(options) {
2063
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
2064
+ return async (_context) => {
2065
+ const now = /* @__PURE__ */ new Date();
2066
+ const year = String(now.getFullYear());
2067
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2068
+ const day = String(now.getDate()).padStart(2, "0");
2069
+ let datePart;
2070
+ let counterKey;
2071
+ switch (partition) {
2072
+ case "yearly":
2073
+ datePart = year;
2074
+ counterKey = `${model.modelName}:${year}`;
2075
+ break;
2076
+ case "daily":
2077
+ datePart = `${year}${separator}${month}${separator}${day}`;
2078
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
2079
+ break;
2080
+ default:
2081
+ datePart = `${year}${separator}${month}`;
2082
+ counterKey = `${model.modelName}:${year}-${month}`;
2083
+ break;
2084
+ }
2085
+ const seq = await getNextSequence(counterKey);
2086
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
2087
+ };
2088
+ }
2089
+ /**
2090
+ * Generator: Prefix + random alphanumeric suffix.
2091
+ * Does NOT require a database round-trip — purely in-memory.
2092
+ *
2093
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
2094
+ *
2095
+ * Good for: user-facing IDs where ordering doesn't matter.
2096
+ * Not suitable for sequential numbering.
2097
+ *
2098
+ * @example
2099
+ * ```typescript
2100
+ * customIdPlugin({
2101
+ * field: 'publicId',
2102
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
2103
+ * })
2104
+ * ```
2105
+ */
2106
+ function prefixedId(options) {
2107
+ const { prefix, separator = "_", length = 12 } = options;
2108
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
2109
+ return (_context) => {
2110
+ let result = "";
2111
+ const bytes = new Uint8Array(length);
2112
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
2113
+ globalThis.crypto.getRandomValues(bytes);
2114
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
2115
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
2116
+ return `${prefix}${separator}${result}`;
2117
+ };
2118
+ }
2119
+ /**
2120
+ * Custom ID plugin — injects generated IDs into documents before creation.
2121
+ *
2122
+ * @param options - Configuration for ID generation
2123
+ * @returns Plugin instance
2124
+ *
2125
+ * @example
2126
+ * ```typescript
2127
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
2128
+ *
2129
+ * const invoiceRepo = new Repository(InvoiceModel, [
2130
+ * customIdPlugin({
2131
+ * field: 'invoiceNumber',
2132
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2133
+ * }),
2134
+ * ]);
2135
+ *
2136
+ * const inv = await invoiceRepo.create({ amount: 100 });
2137
+ * console.log(inv.invoiceNumber); // "INV-0001"
2138
+ * ```
2139
+ */
2140
+ function customIdPlugin(options) {
2141
+ const fieldName = options.field || "customId";
2142
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
2143
+ return {
2144
+ name: "custom-id",
2145
+ apply(repo) {
2146
+ repo.on("before:create", async (context) => {
2147
+ if (!context.data) return;
2148
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
2149
+ context.data[fieldName] = await options.generator(context);
2150
+ });
2151
+ repo.on("before:createMany", async (context) => {
2152
+ if (!context.dataArray) return;
2153
+ const docsNeedingIds = [];
2154
+ for (const doc of context.dataArray) {
2155
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
2156
+ docsNeedingIds.push(doc);
2157
+ }
2158
+ if (docsNeedingIds.length === 0) return;
2159
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
2160
+ ...context,
2161
+ data: doc
2162
+ });
2163
+ });
2164
+ }
2165
+ };
2166
+ }
2167
+
2168
+ //#endregion
2169
+ export { methodRegistryPlugin as C, fieldFilterPlugin as D, timestampPlugin as E, validationChainPlugin as S, auditLogPlugin as T, autoInject as _, sequentialId as a, requireField as b, auditTrailPlugin as c, cascadePlugin as d, cachePlugin as f, mongoOperationsPlugin as g, batchOperationsPlugin as h, prefixedId as i, observabilityPlugin as l, aggregateHelpersPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, subdocumentPlugin as p, getNextSequence as r, AuditTrailQuery as s, customIdPlugin as t, multiTenantPlugin as u, blockIf as v, softDeletePlugin as w, uniqueField as x, immutableField as y };