@classytic/mongokit 3.2.0 → 3.2.2

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 +470 -193
  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-B_zIs6gE.mjs +1818 -0
  12. package/dist/custom-id.plugin-BzZI4gnE.d.mts +893 -0
  13. package/dist/index.d.mts +1012 -0
  14. package/dist/index.mjs +1906 -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-DEVXDBRL.js +0 -1226
  34. package/dist/chunks/chunk-I7CWNAJB.js +0 -46
  35. package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
  36. package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
  37. package/dist/chunks/chunk-URLJFIR7.js +0 -22
  38. package/dist/chunks/chunk-VWKIKZYF.js +0 -737
  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,1818 @@
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/elastic.plugin.ts
1492
+ function elasticSearchPlugin(options) {
1493
+ return {
1494
+ name: "elastic-search",
1495
+ apply(repo) {
1496
+ if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
1497
+ repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
1498
+ const { client, index, idField = "_id" } = options;
1499
+ const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
1500
+ const from = Math.max(searchOptions.from || 0, 0);
1501
+ const esResponse = await client.search({
1502
+ index,
1503
+ body: {
1504
+ query: searchQuery,
1505
+ size: limit,
1506
+ from
1507
+ }
1508
+ });
1509
+ const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
1510
+ if (hits.length === 0) return {
1511
+ docs: [],
1512
+ total: 0,
1513
+ limit,
1514
+ from
1515
+ };
1516
+ const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
1517
+ const total = typeof totalValue === "number" ? totalValue : 0;
1518
+ const docsOrder = /* @__PURE__ */ new Map();
1519
+ const scores = /* @__PURE__ */ new Map();
1520
+ const ids = [];
1521
+ hits.forEach((hit, idx) => {
1522
+ const docId = hit._source?.[idField] || hit[idField] || hit._id;
1523
+ if (docId) {
1524
+ const strId = String(docId);
1525
+ docsOrder.set(strId, idx);
1526
+ if (hit._score !== void 0) scores.set(strId, hit._score);
1527
+ ids.push(strId);
1528
+ }
1529
+ });
1530
+ if (ids.length === 0) return {
1531
+ docs: [],
1532
+ total,
1533
+ limit,
1534
+ from
1535
+ };
1536
+ const mongoQuery = this.Model.find({ _id: { $in: ids } });
1537
+ if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
1538
+ if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
1539
+ if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
1540
+ return {
1541
+ docs: (await mongoQuery.exec()).sort((a, b) => {
1542
+ const aId = String(a._id);
1543
+ const bId = String(b._id);
1544
+ return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
1545
+ }).map((doc) => {
1546
+ const strId = String(doc._id);
1547
+ if (searchOptions.mongoOptions?.lean !== false) return {
1548
+ ...doc,
1549
+ _score: scores.get(strId)
1550
+ };
1551
+ return doc;
1552
+ }),
1553
+ total,
1554
+ limit,
1555
+ from
1556
+ };
1557
+ });
1558
+ }
1559
+ };
1560
+ }
1561
+
1562
+ //#endregion
1563
+ //#region src/plugins/custom-id.plugin.ts
1564
+ /**
1565
+ * Custom ID Plugin
1566
+ *
1567
+ * Generates custom document IDs using pluggable generators.
1568
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
1569
+ * date-partitioned sequences, and fully custom generators.
1570
+ *
1571
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
1572
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
1573
+ *
1574
+ * @example Basic sequential counter
1575
+ * ```typescript
1576
+ * const invoiceRepo = new Repository(InvoiceModel, [
1577
+ * customIdPlugin({
1578
+ * field: 'invoiceNumber',
1579
+ * generator: sequentialId({
1580
+ * prefix: 'INV',
1581
+ * model: InvoiceModel,
1582
+ * }),
1583
+ * }),
1584
+ * ]);
1585
+ *
1586
+ * const inv = await invoiceRepo.create({ amount: 100 });
1587
+ * // inv.invoiceNumber → "INV-0001"
1588
+ * ```
1589
+ *
1590
+ * @example Date-partitioned counter (resets monthly)
1591
+ * ```typescript
1592
+ * const billRepo = new Repository(BillModel, [
1593
+ * customIdPlugin({
1594
+ * field: 'billNumber',
1595
+ * generator: dateSequentialId({
1596
+ * prefix: 'BILL',
1597
+ * model: BillModel,
1598
+ * partition: 'monthly',
1599
+ * separator: '-',
1600
+ * padding: 4,
1601
+ * }),
1602
+ * }),
1603
+ * ]);
1604
+ *
1605
+ * const bill = await billRepo.create({ total: 250 });
1606
+ * // bill.billNumber → "BILL-2026-02-0001"
1607
+ * ```
1608
+ *
1609
+ * @example Custom generator function
1610
+ * ```typescript
1611
+ * const orderRepo = new Repository(OrderModel, [
1612
+ * customIdPlugin({
1613
+ * field: 'orderRef',
1614
+ * generator: async (context) => {
1615
+ * const region = context.data?.region || 'US';
1616
+ * const seq = await getNextSequence('orders');
1617
+ * return `ORD-${region}-${seq}`;
1618
+ * },
1619
+ * }),
1620
+ * ]);
1621
+ * ```
1622
+ */
1623
+ /** Schema for the internal counters collection */
1624
+ const counterSchema = new mongoose.Schema({
1625
+ _id: {
1626
+ type: String,
1627
+ required: true
1628
+ },
1629
+ seq: {
1630
+ type: Number,
1631
+ default: 0
1632
+ }
1633
+ }, {
1634
+ collection: "_mongokit_counters",
1635
+ versionKey: false
1636
+ });
1637
+ /**
1638
+ * Get or create the Counter model.
1639
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
1640
+ */
1641
+ function getCounterModel() {
1642
+ if (mongoose.models._MongoKitCounter) return mongoose.models._MongoKitCounter;
1643
+ return mongoose.model("_MongoKitCounter", counterSchema);
1644
+ }
1645
+ /**
1646
+ * Atomically increment and return the next sequence value for a given key.
1647
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
1648
+ * heavy concurrency.
1649
+ *
1650
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
1651
+ * @param increment - Value to increment by (default: 1)
1652
+ * @returns The next sequence number (after increment)
1653
+ *
1654
+ * @example
1655
+ * const seq = await getNextSequence('invoices');
1656
+ * // First call → 1, second → 2, ...
1657
+ *
1658
+ * @example Batch increment for createMany
1659
+ * const startSeq = await getNextSequence('invoices', 5);
1660
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
1661
+ */
1662
+ async function getNextSequence(counterKey, increment = 1) {
1663
+ return (await getCounterModel().findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
1664
+ upsert: true,
1665
+ returnDocument: "after"
1666
+ })).seq;
1667
+ }
1668
+ /**
1669
+ * Generator: Simple sequential counter.
1670
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
1671
+ *
1672
+ * Uses atomic MongoDB counters — safe under concurrency.
1673
+ *
1674
+ * @example
1675
+ * ```typescript
1676
+ * customIdPlugin({
1677
+ * field: 'invoiceNumber',
1678
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
1679
+ * })
1680
+ * ```
1681
+ */
1682
+ function sequentialId(options) {
1683
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
1684
+ const key = counterKey || model.modelName;
1685
+ return async (_context) => {
1686
+ const seq = await getNextSequence(key);
1687
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
1688
+ };
1689
+ }
1690
+ /**
1691
+ * Generator: Date-partitioned sequential counter.
1692
+ * Counter resets per period — great for invoice/bill numbering.
1693
+ *
1694
+ * Produces IDs like:
1695
+ * - yearly: `BILL-2026-0001`
1696
+ * - monthly: `BILL-2026-02-0001`
1697
+ * - daily: `BILL-2026-02-20-0001`
1698
+ *
1699
+ * @example
1700
+ * ```typescript
1701
+ * customIdPlugin({
1702
+ * field: 'billNumber',
1703
+ * generator: dateSequentialId({
1704
+ * prefix: 'BILL',
1705
+ * model: BillModel,
1706
+ * partition: 'monthly',
1707
+ * }),
1708
+ * })
1709
+ * ```
1710
+ */
1711
+ function dateSequentialId(options) {
1712
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
1713
+ return async (_context) => {
1714
+ const now = /* @__PURE__ */ new Date();
1715
+ const year = String(now.getFullYear());
1716
+ const month = String(now.getMonth() + 1).padStart(2, "0");
1717
+ const day = String(now.getDate()).padStart(2, "0");
1718
+ let datePart;
1719
+ let counterKey;
1720
+ switch (partition) {
1721
+ case "yearly":
1722
+ datePart = year;
1723
+ counterKey = `${model.modelName}:${year}`;
1724
+ break;
1725
+ case "daily":
1726
+ datePart = `${year}${separator}${month}${separator}${day}`;
1727
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
1728
+ break;
1729
+ default:
1730
+ datePart = `${year}${separator}${month}`;
1731
+ counterKey = `${model.modelName}:${year}-${month}`;
1732
+ break;
1733
+ }
1734
+ const seq = await getNextSequence(counterKey);
1735
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
1736
+ };
1737
+ }
1738
+ /**
1739
+ * Generator: Prefix + random alphanumeric suffix.
1740
+ * Does NOT require a database round-trip — purely in-memory.
1741
+ *
1742
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
1743
+ *
1744
+ * Good for: user-facing IDs where ordering doesn't matter.
1745
+ * Not suitable for sequential numbering.
1746
+ *
1747
+ * @example
1748
+ * ```typescript
1749
+ * customIdPlugin({
1750
+ * field: 'publicId',
1751
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
1752
+ * })
1753
+ * ```
1754
+ */
1755
+ function prefixedId(options) {
1756
+ const { prefix, separator = "_", length = 12 } = options;
1757
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
1758
+ return (_context) => {
1759
+ let result = "";
1760
+ const bytes = new Uint8Array(length);
1761
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
1762
+ globalThis.crypto.getRandomValues(bytes);
1763
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
1764
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
1765
+ return `${prefix}${separator}${result}`;
1766
+ };
1767
+ }
1768
+ /**
1769
+ * Custom ID plugin — injects generated IDs into documents before creation.
1770
+ *
1771
+ * @param options - Configuration for ID generation
1772
+ * @returns Plugin instance
1773
+ *
1774
+ * @example
1775
+ * ```typescript
1776
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
1777
+ *
1778
+ * const invoiceRepo = new Repository(InvoiceModel, [
1779
+ * customIdPlugin({
1780
+ * field: 'invoiceNumber',
1781
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
1782
+ * }),
1783
+ * ]);
1784
+ *
1785
+ * const inv = await invoiceRepo.create({ amount: 100 });
1786
+ * console.log(inv.invoiceNumber); // "INV-0001"
1787
+ * ```
1788
+ */
1789
+ function customIdPlugin(options) {
1790
+ const fieldName = options.field || "customId";
1791
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
1792
+ return {
1793
+ name: "custom-id",
1794
+ apply(repo) {
1795
+ repo.on("before:create", async (context) => {
1796
+ if (!context.data) return;
1797
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
1798
+ context.data[fieldName] = await options.generator(context);
1799
+ });
1800
+ repo.on("before:createMany", async (context) => {
1801
+ if (!context.dataArray) return;
1802
+ const docsNeedingIds = [];
1803
+ for (const doc of context.dataArray) {
1804
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
1805
+ docsNeedingIds.push(doc);
1806
+ }
1807
+ if (docsNeedingIds.length === 0) return;
1808
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
1809
+ ...context,
1810
+ data: doc
1811
+ });
1812
+ });
1813
+ }
1814
+ };
1815
+ }
1816
+
1817
+ //#endregion
1818
+ export { auditLogPlugin as C, softDeletePlugin as S, fieldFilterPlugin as T, immutableField as _, sequentialId as a, validationChainPlugin as b, multiTenantPlugin as c, subdocumentPlugin as d, aggregateHelpersPlugin as f, blockIf as g, autoInject as h, prefixedId as i, cascadePlugin as l, mongoOperationsPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, batchOperationsPlugin as p, getNextSequence as r, observabilityPlugin as s, customIdPlugin as t, cachePlugin as u, requireField as v, timestampPlugin as w, methodRegistryPlugin as x, uniqueField as y };