@classytic/mongokit 3.3.2 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,98 +1,712 @@
1
- import { i as createError, n as debug, r as warn } from "./logger-D8ily-PP.mjs";
2
- import { _ as upsert, c as count, d as getByQuery, f as getOrCreate, h as createMany, i as deleteById, l as exists, m as create, n as distinct, o as update, r as LookupBuilder, u as getById } from "./aggregate-BClp040M.mjs";
3
- import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
4
- import { a as modelPattern, i as listQueryKey, l as getFieldsForUser, n as byQueryKey, o as versionKey, t as byIdKey } from "./cache-keys-CzFwVnLy.mjs";
1
+ import { a as warn, i as debug, n as parseDuplicateKeyError, t as createError } from "./error-Bpbi_NKo.mjs";
2
+ import { _ as LookupBuilder, a as getById, d as create, f as createMany, g as distinct, i as exists, l as deleteById, m as upsert, o as getByQuery, r as count, s as getOrCreate, t as update } from "./update-DXwVh6M1.mjs";
3
+ import { t as PaginationEngine } from "./PaginationEngine-PLyDhrO7.mjs";
4
+ import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-CalOB7yM.mjs";
5
5
  import mongoose from "mongoose";
6
-
7
- //#region src/query/AggregationBuilder.ts
6
+ //#region src/plugins/aggregate-helpers.plugin.ts
8
7
  /**
9
- * Normalize SortSpec to MongoDB's strict format (1 | -1)
10
- * Converts 'asc' -> 1, 'desc' -> -1
8
+ * Aggregate helpers plugin
9
+ *
10
+ * @example
11
+ * const repo = new Repository(Model, [
12
+ * methodRegistryPlugin(),
13
+ * aggregateHelpersPlugin(),
14
+ * ]);
15
+ *
16
+ * const groups = await repo.groupBy('category');
17
+ * const total = await repo.sum('amount', { status: 'completed' });
11
18
  */
12
- function normalizeSortSpec(sortSpec) {
13
- const normalized = {};
14
- for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
15
- else if (order === "desc") normalized[field] = -1;
16
- else normalized[field] = order;
17
- return normalized;
19
+ function aggregateHelpersPlugin() {
20
+ return {
21
+ name: "aggregate-helpers",
22
+ apply(repo) {
23
+ if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
24
+ /**
25
+ * Group by field
26
+ */
27
+ repo.registerMethod("groupBy", async function(field, options = {}) {
28
+ const pipeline = [{ $group: {
29
+ _id: `$${field}`,
30
+ count: { $sum: 1 }
31
+ } }, { $sort: { count: -1 } }];
32
+ if (options.limit) pipeline.push({ $limit: options.limit });
33
+ return this.aggregate.call(this, pipeline, options);
34
+ });
35
+ const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
36
+ const pipeline = [{ $match: query }, { $group: {
37
+ _id: null,
38
+ [resultKey]: { [operator]: `$${field}` }
39
+ } }];
40
+ return (await this.aggregate.call(this, pipeline, options))[0]?.[resultKey] || 0;
41
+ };
42
+ /**
43
+ * Sum field values
44
+ */
45
+ repo.registerMethod("sum", async function(field, query = {}, options = {}) {
46
+ return aggregateOperation.call(this, field, "$sum", "total", query, options);
47
+ });
48
+ /**
49
+ * Average field values
50
+ */
51
+ repo.registerMethod("average", async function(field, query = {}, options = {}) {
52
+ return aggregateOperation.call(this, field, "$avg", "avg", query, options);
53
+ });
54
+ /**
55
+ * Get minimum value
56
+ */
57
+ repo.registerMethod("min", async function(field, query = {}, options = {}) {
58
+ return aggregateOperation.call(this, field, "$min", "min", query, options);
59
+ });
60
+ /**
61
+ * Get maximum value
62
+ */
63
+ repo.registerMethod("max", async function(field, query = {}, options = {}) {
64
+ return aggregateOperation.call(this, field, "$max", "max", query, options);
65
+ });
66
+ }
67
+ };
18
68
  }
69
+ //#endregion
70
+ //#region src/plugins/audit-log.plugin.ts
19
71
  /**
20
- * Fluent builder for MongoDB aggregation pipelines
21
- * Optimized for complex queries at scale
72
+ * Audit log plugin that logs all repository operations
73
+ *
74
+ * @example
75
+ * const repo = new Repository(Model, [auditLogPlugin(console)]);
22
76
  */
23
- var AggregationBuilder = class AggregationBuilder {
24
- pipeline = [];
25
- _diskUse = false;
26
- /**
27
- * Get the current pipeline
28
- */
29
- get() {
30
- return [...this.pipeline];
31
- }
32
- /**
33
- * Build and return the final pipeline
34
- */
35
- build() {
36
- return this.get();
77
+ function auditLogPlugin(logger) {
78
+ return {
79
+ name: "auditLog",
80
+ apply(repo) {
81
+ repo.on("after:create", ({ context, result }) => {
82
+ logger?.info?.("Document created", {
83
+ model: context.model || repo.model,
84
+ id: result?._id,
85
+ userId: context.user?._id || context.user?.id,
86
+ organizationId: context.organizationId
87
+ });
88
+ });
89
+ repo.on("after:update", ({ context, result }) => {
90
+ logger?.info?.("Document updated", {
91
+ model: context.model || repo.model,
92
+ id: context.id || result?._id,
93
+ userId: context.user?._id || context.user?.id,
94
+ organizationId: context.organizationId
95
+ });
96
+ });
97
+ repo.on("after:delete", ({ context }) => {
98
+ logger?.info?.("Document deleted", {
99
+ model: context.model || repo.model,
100
+ id: context.id,
101
+ userId: context.user?._id || context.user?.id,
102
+ organizationId: context.organizationId
103
+ });
104
+ });
105
+ repo.on("error:create", ({ context, error }) => {
106
+ logger?.error?.("Create failed", {
107
+ model: context.model || repo.model,
108
+ error: error.message,
109
+ userId: context.user?._id || context.user?.id
110
+ });
111
+ });
112
+ repo.on("error:update", ({ context, error }) => {
113
+ logger?.error?.("Update failed", {
114
+ model: context.model || repo.model,
115
+ id: context.id,
116
+ error: error.message,
117
+ userId: context.user?._id || context.user?.id
118
+ });
119
+ });
120
+ repo.on("error:delete", ({ context, error }) => {
121
+ logger?.error?.("Delete failed", {
122
+ model: context.model || repo.model,
123
+ id: context.id,
124
+ error: error.message,
125
+ userId: context.user?._id || context.user?.id
126
+ });
127
+ });
128
+ }
129
+ };
130
+ }
131
+ //#endregion
132
+ //#region src/plugins/audit-trail.plugin.ts
133
+ /**
134
+ * Audit Trail Plugin
135
+ *
136
+ * Persists operation audit entries to a MongoDB collection.
137
+ * Fire-and-forget: writes happen async and never block or fail the main operation.
138
+ *
139
+ * Features:
140
+ * - Tracks create, update, delete operations
141
+ * - Field-level change tracking (before/after diff on updates)
142
+ * - TTL auto-cleanup via MongoDB TTL index
143
+ * - Custom metadata per entry (IP, user-agent, etc.)
144
+ * - Shared `audit_trails` collection across all models
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const repo = new Repository(Job, [
149
+ * auditTrailPlugin({
150
+ * operations: ['create', 'update', 'delete'],
151
+ * trackChanges: true,
152
+ * ttlDays: 90,
153
+ * metadata: (context) => ({
154
+ * ip: context.req?.ip,
155
+ * }),
156
+ * }),
157
+ * ]);
158
+ * ```
159
+ */
160
+ const modelCache = /* @__PURE__ */ new Map();
161
+ function getAuditModel(collectionName, ttlDays) {
162
+ const existing = modelCache.get(collectionName);
163
+ if (existing) return existing;
164
+ const schema = new mongoose.Schema({
165
+ model: {
166
+ type: String,
167
+ required: true,
168
+ index: true
169
+ },
170
+ operation: {
171
+ type: String,
172
+ required: true,
173
+ enum: [
174
+ "create",
175
+ "update",
176
+ "delete"
177
+ ]
178
+ },
179
+ documentId: {
180
+ type: mongoose.Schema.Types.Mixed,
181
+ required: true,
182
+ index: true
183
+ },
184
+ userId: {
185
+ type: mongoose.Schema.Types.Mixed,
186
+ index: true
187
+ },
188
+ orgId: {
189
+ type: mongoose.Schema.Types.Mixed,
190
+ index: true
191
+ },
192
+ changes: { type: mongoose.Schema.Types.Mixed },
193
+ document: { type: mongoose.Schema.Types.Mixed },
194
+ metadata: { type: mongoose.Schema.Types.Mixed },
195
+ timestamp: {
196
+ type: Date,
197
+ default: Date.now,
198
+ index: true
199
+ }
200
+ }, {
201
+ collection: collectionName,
202
+ versionKey: false
203
+ });
204
+ schema.index({
205
+ model: 1,
206
+ documentId: 1,
207
+ timestamp: -1
208
+ });
209
+ schema.index({
210
+ orgId: 1,
211
+ userId: 1,
212
+ timestamp: -1
213
+ });
214
+ if (ttlDays !== void 0 && ttlDays > 0) {
215
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
216
+ schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
37
217
  }
38
- /**
39
- * Build pipeline with execution options (allowDiskUse, etc.)
40
- */
41
- plan() {
42
- return {
43
- pipeline: this.get(),
44
- allowDiskUse: this._diskUse
218
+ const modelName = `AuditTrail_${collectionName}`;
219
+ const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
220
+ modelCache.set(collectionName, model);
221
+ return model;
222
+ }
223
+ /** Compute field-level diff between previous and updated document */
224
+ function computeChanges(prev, next, excludeFields) {
225
+ const changes = {};
226
+ const exclude = new Set(excludeFields);
227
+ for (const key of Object.keys(next)) {
228
+ if (exclude.has(key)) continue;
229
+ if (key === "_id" || key === "__v" || key === "updatedAt") continue;
230
+ const prevVal = prev[key];
231
+ const nextVal = next[key];
232
+ if (!deepEqual(prevVal, nextVal)) changes[key] = {
233
+ from: prevVal,
234
+ to: nextVal
45
235
  };
46
236
  }
47
- /**
48
- * Build and execute the pipeline against a model
49
- *
50
- * @example
51
- * ```typescript
52
- * const results = await new AggregationBuilder()
53
- * .match({ status: 'active' })
54
- * .allowDiskUse()
55
- * .exec(MyModel);
56
- * ```
57
- */
58
- async exec(model, session) {
59
- const agg = model.aggregate(this.build());
60
- if (this._diskUse) agg.allowDiskUse(true);
61
- if (session) agg.session(session);
62
- return agg.exec();
237
+ return Object.keys(changes).length > 0 ? changes : void 0;
238
+ }
239
+ /** Simple deep equality check for audit diffing */
240
+ function deepEqual(a, b) {
241
+ if (a === b) return true;
242
+ if (a == null && b == null) return true;
243
+ if (a == null || b == null) return false;
244
+ if (typeof a === "object" && typeof b === "object") {
245
+ const aStr = a.toString?.();
246
+ const bStr = b.toString?.();
247
+ if (aStr && bStr && aStr === bStr) return true;
248
+ }
249
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
250
+ try {
251
+ return JSON.stringify(a) === JSON.stringify(b);
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ /** Extract user ID from context */
257
+ function getUserId(context) {
258
+ return context.user?._id || context.user?.id;
259
+ }
260
+ /** Fire-and-forget: write audit entry, never throw */
261
+ function writeAudit(AuditModel, entry) {
262
+ Promise.resolve().then(() => {
263
+ AuditModel.create({
264
+ ...entry,
265
+ timestamp: /* @__PURE__ */ new Date()
266
+ }).catch((err) => {
267
+ warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
268
+ });
269
+ });
270
+ }
271
+ const snapshots = /* @__PURE__ */ new WeakMap();
272
+ function auditTrailPlugin(options = {}) {
273
+ const { operations = [
274
+ "create",
275
+ "update",
276
+ "delete"
277
+ ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
278
+ const opsSet = new Set(operations);
279
+ return {
280
+ name: "auditTrail",
281
+ apply(repo) {
282
+ const AuditModel = getAuditModel(collectionName, ttlDays);
283
+ if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
284
+ const doc = toPlainObject(result);
285
+ writeAudit(AuditModel, {
286
+ model: context.model || repo.model,
287
+ operation: "create",
288
+ documentId: doc?._id,
289
+ userId: getUserId(context),
290
+ orgId: context.organizationId,
291
+ document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
292
+ metadata: metadata?.(context)
293
+ });
294
+ });
295
+ if (opsSet.has("update")) {
296
+ if (trackChanges) repo.on("before:update", async (context) => {
297
+ if (!context.id) return;
298
+ try {
299
+ const prev = await repo.Model.findById(context.id).lean();
300
+ if (prev) snapshots.set(context, prev);
301
+ } catch (err) {
302
+ warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
303
+ }
304
+ });
305
+ repo.on("after:update", ({ context, result }) => {
306
+ const doc = result;
307
+ let changes;
308
+ if (trackChanges) {
309
+ const prev = snapshots.get(context);
310
+ if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
311
+ snapshots.delete(context);
312
+ }
313
+ writeAudit(AuditModel, {
314
+ model: context.model || repo.model,
315
+ operation: "update",
316
+ documentId: context.id || doc?._id,
317
+ userId: getUserId(context),
318
+ orgId: context.organizationId,
319
+ changes,
320
+ metadata: metadata?.(context)
321
+ });
322
+ });
323
+ }
324
+ if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
325
+ writeAudit(AuditModel, {
326
+ model: context.model || repo.model,
327
+ operation: "delete",
328
+ documentId: context.id,
329
+ userId: getUserId(context),
330
+ orgId: context.organizationId,
331
+ metadata: metadata?.(context)
332
+ });
333
+ });
334
+ if (typeof repo.registerMethod === "function")
335
+ /**
336
+ * Get audit trail for a specific document
337
+ */
338
+ repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
339
+ const { page = 1, limit = 20, operation } = queryOptions;
340
+ const skip = (page - 1) * limit;
341
+ const filter = {
342
+ model: this.model,
343
+ documentId
344
+ };
345
+ if (operation) filter.operation = operation;
346
+ const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
347
+ return {
348
+ docs,
349
+ page,
350
+ limit,
351
+ total,
352
+ pages: Math.ceil(total / limit),
353
+ hasNext: page < Math.ceil(total / limit),
354
+ hasPrev: page > 1
355
+ };
356
+ });
357
+ }
358
+ };
359
+ }
360
+ /** Convert Mongoose document to plain object */
361
+ function toPlainObject(doc) {
362
+ if (!doc) return {};
363
+ if (typeof doc.toObject === "function") return doc.toObject();
364
+ return doc;
365
+ }
366
+ /** Remove excluded fields from a document snapshot */
367
+ function sanitizeDoc(doc, excludeFields) {
368
+ if (excludeFields.length === 0) return doc;
369
+ const result = { ...doc };
370
+ for (const field of excludeFields) delete result[field];
371
+ return result;
372
+ }
373
+ /**
374
+ * Standalone audit trail query utility.
375
+ * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * import { AuditTrailQuery } from '@classytic/mongokit';
380
+ *
381
+ * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
382
+ *
383
+ * // All audits for an org
384
+ * const orgAudits = await auditQuery.query({ orgId: '...' });
385
+ *
386
+ * // All updates by a user
387
+ * const userUpdates = await auditQuery.query({
388
+ * userId: '...',
389
+ * operation: 'update',
390
+ * });
391
+ *
392
+ * // All audits for a specific document
393
+ * const docHistory = await auditQuery.query({
394
+ * model: 'Job',
395
+ * documentId: '...',
396
+ * });
397
+ *
398
+ * // Date range
399
+ * const recent = await auditQuery.query({
400
+ * from: new Date('2025-01-01'),
401
+ * to: new Date(),
402
+ * page: 1,
403
+ * limit: 50,
404
+ * });
405
+ *
406
+ * // Direct model access for custom queries
407
+ * const model = auditQuery.getModel();
408
+ * const count = await model.countDocuments({ operation: 'delete' });
409
+ * ```
410
+ */
411
+ var AuditTrailQuery = class {
412
+ model;
413
+ constructor(collectionName = "audit_trails", ttlDays) {
414
+ this.model = getAuditModel(collectionName, ttlDays);
63
415
  }
64
416
  /**
65
- * Reset the pipeline
417
+ * Get the underlying Mongoose model for custom queries
66
418
  */
67
- reset() {
68
- this.pipeline = [];
69
- this._diskUse = false;
70
- return this;
419
+ getModel() {
420
+ return this.model;
71
421
  }
72
422
  /**
73
- * Add a raw pipeline stage
423
+ * Query audit entries with filters and pagination
74
424
  */
75
- addStage(stage) {
76
- this.pipeline.push(stage);
77
- return this;
425
+ async query(options = {}) {
426
+ const { page = 1, limit = 20 } = options;
427
+ const skip = (page - 1) * limit;
428
+ const filter = {};
429
+ if (options.model) filter.model = options.model;
430
+ if (options.documentId) filter.documentId = options.documentId;
431
+ if (options.userId) filter.userId = options.userId;
432
+ if (options.orgId) filter.orgId = options.orgId;
433
+ if (options.operation) filter.operation = options.operation;
434
+ if (options.from || options.to) {
435
+ const dateFilter = {};
436
+ if (options.from) dateFilter.$gte = options.from;
437
+ if (options.to) dateFilter.$lte = options.to;
438
+ filter.timestamp = dateFilter;
439
+ }
440
+ const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
441
+ const pages = Math.ceil(total / limit);
442
+ return {
443
+ docs,
444
+ page,
445
+ limit,
446
+ total,
447
+ pages,
448
+ hasNext: page < pages,
449
+ hasPrev: page > 1
450
+ };
78
451
  }
79
452
  /**
80
- * Add multiple raw pipeline stages
453
+ * Get audit trail for a specific document
81
454
  */
82
- addStages(stages) {
83
- this.pipeline.push(...stages);
84
- return this;
455
+ async getDocumentTrail(model, documentId, options = {}) {
456
+ return this.query({
457
+ model,
458
+ documentId,
459
+ ...options
460
+ });
85
461
  }
86
462
  /**
87
- * $match - Filter documents
88
- * IMPORTANT: Place $match as early as possible for performance
463
+ * Get all audits for a user
89
464
  */
90
- match(query) {
91
- this.pipeline.push({ $match: query });
92
- return this;
465
+ async getUserTrail(userId, options = {}) {
466
+ return this.query({
467
+ userId,
468
+ ...options
469
+ });
93
470
  }
94
471
  /**
95
- * $project - Include/exclude fields or compute new fields
472
+ * Get all audits for an organization
473
+ */
474
+ async getOrgTrail(orgId, options = {}) {
475
+ return this.query({
476
+ orgId,
477
+ ...options
478
+ });
479
+ }
480
+ };
481
+ //#endregion
482
+ //#region src/plugins/batch-operations.plugin.ts
483
+ /**
484
+ * Batch operations plugin
485
+ *
486
+ * @example
487
+ * const repo = new Repository(Model, [
488
+ * methodRegistryPlugin(),
489
+ * batchOperationsPlugin(),
490
+ * ]);
491
+ *
492
+ * await repo.updateMany({ status: 'pending' }, { status: 'active' });
493
+ * await repo.deleteMany({ status: 'deleted' });
494
+ */
495
+ function batchOperationsPlugin() {
496
+ return {
497
+ name: "batch-operations",
498
+ apply(repo) {
499
+ if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
500
+ /**
501
+ * Update multiple documents
502
+ */
503
+ repo.registerMethod("updateMany", async function(query, data, options = {}) {
504
+ const context = await this._buildContext.call(this, "updateMany", {
505
+ query,
506
+ data,
507
+ ...options
508
+ });
509
+ try {
510
+ const finalQuery = context.query || query;
511
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
512
+ 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.");
513
+ const finalData = context.data || data;
514
+ const result = await this.Model.updateMany(finalQuery, finalData, {
515
+ runValidators: true,
516
+ session: options.session,
517
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
518
+ }).exec();
519
+ await this.emitAsync("after:updateMany", {
520
+ context,
521
+ result
522
+ });
523
+ return result;
524
+ } catch (error) {
525
+ this.emit("error:updateMany", {
526
+ context,
527
+ error
528
+ });
529
+ throw this._handleError.call(this, error);
530
+ }
531
+ });
532
+ /**
533
+ * Execute heterogeneous bulk write operations in a single database call.
534
+ *
535
+ * Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
536
+ * operations mixed together for maximum efficiency.
537
+ *
538
+ * @example
539
+ * await repo.bulkWrite([
540
+ * { insertOne: { document: { name: 'New Item', price: 10 } } },
541
+ * { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
542
+ * { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
543
+ * { deleteOne: { filter: { _id: id2 } } },
544
+ * ]);
545
+ */
546
+ repo.registerMethod("bulkWrite", async function(operations, options = {}) {
547
+ const context = await this._buildContext.call(this, "bulkWrite", {
548
+ operations,
549
+ ...options
550
+ });
551
+ try {
552
+ const finalOps = context.operations || operations;
553
+ if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
554
+ const result = await this.Model.bulkWrite(finalOps, {
555
+ ordered: options.ordered ?? true,
556
+ session: options.session
557
+ });
558
+ const bulkResult = {
559
+ ok: result.ok,
560
+ insertedCount: result.insertedCount,
561
+ upsertedCount: result.upsertedCount,
562
+ matchedCount: result.matchedCount,
563
+ modifiedCount: result.modifiedCount,
564
+ deletedCount: result.deletedCount,
565
+ insertedIds: result.insertedIds,
566
+ upsertedIds: result.upsertedIds
567
+ };
568
+ await this.emitAsync("after:bulkWrite", {
569
+ context,
570
+ result: bulkResult
571
+ });
572
+ return bulkResult;
573
+ } catch (error) {
574
+ this.emit("error:bulkWrite", {
575
+ context,
576
+ error
577
+ });
578
+ throw this._handleError.call(this, error);
579
+ }
580
+ });
581
+ /**
582
+ * Delete multiple documents
583
+ */
584
+ repo.registerMethod("deleteMany", async function(query, options = {}) {
585
+ const context = await this._buildContext.call(this, "deleteMany", {
586
+ query,
587
+ ...options
588
+ });
589
+ try {
590
+ if (context.softDeleted) {
591
+ const result = {
592
+ acknowledged: true,
593
+ deletedCount: 0
594
+ };
595
+ await this.emitAsync("after:deleteMany", {
596
+ context,
597
+ result
598
+ });
599
+ return result;
600
+ }
601
+ const finalQuery = context.query || query;
602
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
603
+ const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
604
+ await this.emitAsync("after:deleteMany", {
605
+ context,
606
+ result
607
+ });
608
+ return result;
609
+ } catch (error) {
610
+ this.emit("error:deleteMany", {
611
+ context,
612
+ error
613
+ });
614
+ throw this._handleError.call(this, error);
615
+ }
616
+ });
617
+ }
618
+ };
619
+ }
620
+ //#endregion
621
+ //#region src/query/AggregationBuilder.ts
622
+ /**
623
+ * Normalize SortSpec to MongoDB's strict format (1 | -1)
624
+ * Converts 'asc' -> 1, 'desc' -> -1
625
+ */
626
+ function normalizeSortSpec(sortSpec) {
627
+ const normalized = {};
628
+ for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
629
+ else if (order === "desc") normalized[field] = -1;
630
+ else normalized[field] = order;
631
+ return normalized;
632
+ }
633
+ /**
634
+ * Fluent builder for MongoDB aggregation pipelines
635
+ * Optimized for complex queries at scale
636
+ */
637
+ var AggregationBuilder = class AggregationBuilder {
638
+ pipeline = [];
639
+ _diskUse = false;
640
+ /**
641
+ * Get the current pipeline
642
+ */
643
+ get() {
644
+ return [...this.pipeline];
645
+ }
646
+ /**
647
+ * Build and return the final pipeline
648
+ */
649
+ build() {
650
+ return this.get();
651
+ }
652
+ /**
653
+ * Build pipeline with execution options (allowDiskUse, etc.)
654
+ */
655
+ plan() {
656
+ return {
657
+ pipeline: this.get(),
658
+ allowDiskUse: this._diskUse
659
+ };
660
+ }
661
+ /**
662
+ * Build and execute the pipeline against a model
663
+ *
664
+ * @example
665
+ * ```typescript
666
+ * const results = await new AggregationBuilder()
667
+ * .match({ status: 'active' })
668
+ * .allowDiskUse()
669
+ * .exec(MyModel);
670
+ * ```
671
+ */
672
+ async exec(model, session) {
673
+ const agg = model.aggregate(this.build());
674
+ if (this._diskUse) agg.allowDiskUse(true);
675
+ if (session) agg.session(session);
676
+ return agg.exec();
677
+ }
678
+ /**
679
+ * Reset the pipeline
680
+ */
681
+ reset() {
682
+ this.pipeline = [];
683
+ this._diskUse = false;
684
+ return this;
685
+ }
686
+ /**
687
+ * Add a raw pipeline stage
688
+ */
689
+ addStage(stage) {
690
+ this.pipeline.push(stage);
691
+ return this;
692
+ }
693
+ /**
694
+ * Add multiple raw pipeline stages
695
+ */
696
+ addStages(stages) {
697
+ this.pipeline.push(...stages);
698
+ return this;
699
+ }
700
+ /**
701
+ * $match - Filter documents
702
+ * IMPORTANT: Place $match as early as possible for performance
703
+ */
704
+ match(query) {
705
+ this.pipeline.push({ $match: query });
706
+ return this;
707
+ }
708
+ /**
709
+ * $project - Include/exclude fields or compute new fields
96
710
  */
97
711
  project(projection) {
98
712
  this.pipeline.push({ $project: projection });
@@ -489,36 +1103,9 @@ var AggregationBuilder = class AggregationBuilder {
489
1103
  return new AggregationBuilder().match(query);
490
1104
  }
491
1105
  };
492
-
493
1106
  //#endregion
494
1107
  //#region src/Repository.ts
495
1108
  /**
496
- * Repository Pattern - Data Access Layer
497
- *
498
- * Event-driven, plugin-based abstraction for MongoDB operations
499
- * Inspired by Meta & Stripe's repository patterns
500
- *
501
- * @example
502
- * ```typescript
503
- * const userRepo = new Repository(UserModel, [
504
- * timestampPlugin(),
505
- * softDeletePlugin(),
506
- * ]);
507
- *
508
- * // Create
509
- * const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
510
- *
511
- * // Read with pagination
512
- * const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
513
- *
514
- * // Update
515
- * const updated = await userRepo.update(user._id, { name: 'John Doe' });
516
- *
517
- * // Delete
518
- * await userRepo.delete(user._id);
519
- * ```
520
- */
521
- /**
522
1109
  * Plugin phase priorities (lower = runs first)
523
1110
  * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
524
1111
  * to ensure filters are injected before cache keys are computed.
@@ -546,7 +1133,9 @@ var Repository = class {
546
1133
  this._hooks = /* @__PURE__ */ new Map();
547
1134
  this._pagination = new PaginationEngine(Model, paginationConfig);
548
1135
  this._hookMode = options.hooks ?? "async";
549
- plugins.forEach((plugin) => this.use(plugin));
1136
+ plugins.forEach((plugin) => {
1137
+ this.use(plugin);
1138
+ });
550
1139
  }
551
1140
  /**
552
1141
  * Register a plugin
@@ -807,7 +1396,7 @@ var Repository = class {
807
1396
  let useKeyset = false;
808
1397
  if (mode) useKeyset = mode === "keyset";
809
1398
  else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
810
- let query = { ...filters };
1399
+ const query = { ...filters };
811
1400
  if (search) {
812
1401
  if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
813
1402
  if (this._hasTextIndex) query.$text = { $search: search };
@@ -826,18 +1415,30 @@ var Repository = class {
826
1415
  maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
827
1416
  readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
828
1417
  };
829
- try {
830
- let result;
831
- if (useKeyset) result = await this._pagination.stream({
832
- ...paginationOptions,
1418
+ const lookups = params.lookups;
1419
+ if (lookups && lookups.length > 0) try {
1420
+ const lookupResult = await this.lookupPopulate({
1421
+ filters: query,
1422
+ lookups,
833
1423
  sort: paginationOptions.sort,
834
- after
835
- });
836
- else result = await this._pagination.paginate({
837
- ...paginationOptions,
838
1424
  page: page || 1,
839
- countStrategy: context.countStrategy ?? params.countStrategy
840
- });
1425
+ limit,
1426
+ select: paginationOptions.select,
1427
+ session: options.session,
1428
+ readPreference: paginationOptions.readPreference
1429
+ });
1430
+ const totalPages = Math.ceil((lookupResult.total ?? 0) / (lookupResult.limit ?? limit));
1431
+ const currentPage = lookupResult.page ?? 1;
1432
+ const result = {
1433
+ method: "offset",
1434
+ docs: lookupResult.data,
1435
+ page: currentPage,
1436
+ limit: lookupResult.limit ?? limit,
1437
+ total: lookupResult.total ?? 0,
1438
+ pages: totalPages,
1439
+ hasNext: currentPage < totalPages,
1440
+ hasPrev: currentPage > 1
1441
+ };
841
1442
  await this._emitHook("after:getAll", {
842
1443
  context,
843
1444
  result
@@ -850,9 +1451,33 @@ var Repository = class {
850
1451
  });
851
1452
  throw this._handleError(error);
852
1453
  }
853
- }
854
- /**
855
- * Get or create document
1454
+ try {
1455
+ let result;
1456
+ if (useKeyset) result = await this._pagination.stream({
1457
+ ...paginationOptions,
1458
+ sort: paginationOptions.sort,
1459
+ after
1460
+ });
1461
+ else result = await this._pagination.paginate({
1462
+ ...paginationOptions,
1463
+ page: page || 1,
1464
+ countStrategy: context.countStrategy ?? params.countStrategy
1465
+ });
1466
+ await this._emitHook("after:getAll", {
1467
+ context,
1468
+ result
1469
+ });
1470
+ return result;
1471
+ } catch (error) {
1472
+ await this._emitErrorHook("error:getAll", {
1473
+ context,
1474
+ error
1475
+ });
1476
+ throw this._handleError(error);
1477
+ }
1478
+ }
1479
+ /**
1480
+ * Get or create document
856
1481
  * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
857
1482
  */
858
1483
  async getOrCreate(query, createData, options = {}) {
@@ -1340,1614 +1965,1205 @@ var Repository = class {
1340
1965
  _handleError(error) {
1341
1966
  if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
1342
1967
  if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
1968
+ const duplicateErr = parseDuplicateKeyError(error);
1969
+ if (duplicateErr) return duplicateErr;
1343
1970
  if (error.status && error.message) return error;
1344
1971
  return createError(500, error.message || "Internal Server Error");
1345
1972
  }
1346
1973
  };
1347
-
1348
- //#endregion
1349
- //#region src/plugins/field-filter.plugin.ts
1350
- /**
1351
- * Field Filter Plugin
1352
- * Automatically filters response fields based on user roles
1353
- */
1354
- /**
1355
- * Field filter plugin that restricts fields based on user context
1356
- *
1357
- * @example
1358
- * const fieldPreset = {
1359
- * public: ['id', 'name'],
1360
- * authenticated: ['email'],
1361
- * admin: ['createdAt', 'internalNotes']
1362
- * };
1363
- *
1364
- * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
1365
- */
1366
- function fieldFilterPlugin(fieldPreset) {
1367
- return {
1368
- name: "fieldFilter",
1369
- apply(repo) {
1370
- const applyFieldFiltering = (context) => {
1371
- if (!fieldPreset) return;
1372
- const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
1373
- if (context.select) context.select = `${presetSelect} ${context.select}`;
1374
- else context.select = presetSelect;
1375
- };
1376
- repo.on("before:getAll", applyFieldFiltering);
1377
- repo.on("before:getById", applyFieldFiltering);
1378
- repo.on("before:getByQuery", applyFieldFiltering);
1379
- }
1380
- };
1381
- }
1382
-
1383
- //#endregion
1384
- //#region src/plugins/timestamp.plugin.ts
1385
- /**
1386
- * Timestamp plugin that auto-injects timestamps
1387
- *
1388
- * @example
1389
- * const repo = new Repository(Model, [timestampPlugin()]);
1390
- */
1391
- function timestampPlugin() {
1392
- return {
1393
- name: "timestamp",
1394
- apply(repo) {
1395
- repo.on("before:create", (context) => {
1396
- if (!context.data) return;
1397
- const now = /* @__PURE__ */ new Date();
1398
- if (!context.data.createdAt) context.data.createdAt = now;
1399
- if (!context.data.updatedAt) context.data.updatedAt = now;
1400
- });
1401
- repo.on("before:update", (context) => {
1402
- if (!context.data) return;
1403
- context.data.updatedAt = /* @__PURE__ */ new Date();
1404
- });
1405
- }
1406
- };
1407
- }
1408
-
1409
- //#endregion
1410
- //#region src/plugins/audit-log.plugin.ts
1411
- /**
1412
- * Audit log plugin that logs all repository operations
1413
- *
1414
- * @example
1415
- * const repo = new Repository(Model, [auditLogPlugin(console)]);
1416
- */
1417
- function auditLogPlugin(logger) {
1418
- return {
1419
- name: "auditLog",
1420
- apply(repo) {
1421
- repo.on("after:create", ({ context, result }) => {
1422
- logger?.info?.("Document created", {
1423
- model: context.model || repo.model,
1424
- id: result?._id,
1425
- userId: context.user?._id || context.user?.id,
1426
- organizationId: context.organizationId
1427
- });
1428
- });
1429
- repo.on("after:update", ({ context, result }) => {
1430
- logger?.info?.("Document updated", {
1431
- model: context.model || repo.model,
1432
- id: context.id || result?._id,
1433
- userId: context.user?._id || context.user?.id,
1434
- organizationId: context.organizationId
1435
- });
1436
- });
1437
- repo.on("after:delete", ({ context }) => {
1438
- logger?.info?.("Document deleted", {
1439
- model: context.model || repo.model,
1440
- id: context.id,
1441
- userId: context.user?._id || context.user?.id,
1442
- organizationId: context.organizationId
1443
- });
1444
- });
1445
- repo.on("error:create", ({ context, error }) => {
1446
- logger?.error?.("Create failed", {
1447
- model: context.model || repo.model,
1448
- error: error.message,
1449
- userId: context.user?._id || context.user?.id
1450
- });
1451
- });
1452
- repo.on("error:update", ({ context, error }) => {
1453
- logger?.error?.("Update failed", {
1454
- model: context.model || repo.model,
1455
- id: context.id,
1456
- error: error.message,
1457
- userId: context.user?._id || context.user?.id
1458
- });
1459
- });
1460
- repo.on("error:delete", ({ context, error }) => {
1461
- logger?.error?.("Delete failed", {
1462
- model: context.model || repo.model,
1463
- id: context.id,
1464
- error: error.message,
1465
- userId: context.user?._id || context.user?.id
1466
- });
1467
- });
1468
- }
1469
- };
1470
- }
1471
-
1472
1974
  //#endregion
1473
- //#region src/plugins/soft-delete.plugin.ts
1474
- /**
1475
- * Build filter condition based on filter mode
1476
- */
1477
- function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
1478
- if (includeDeleted) return {};
1479
- if (filterMode === "exists") return { [deletedField]: { $exists: false } };
1480
- return { [deletedField]: null };
1481
- }
1482
- /**
1483
- * Build filter condition for finding deleted documents
1484
- */
1485
- function buildGetDeletedFilter(deletedField, filterMode) {
1486
- if (filterMode === "exists") return { [deletedField]: {
1487
- $exists: true,
1488
- $ne: null
1489
- } };
1490
- return { [deletedField]: { $ne: null } };
1491
- }
1975
+ //#region src/plugins/cache.plugin.ts
1492
1976
  /**
1493
- * Soft delete plugin
1977
+ * Cache Plugin
1494
1978
  *
1495
- * @example Basic usage
1496
- * ```typescript
1497
- * const repo = new Repository(Model, [
1498
- * softDeletePlugin({ deletedField: 'deletedAt' })
1499
- * ]);
1979
+ * Optional caching layer for MongoKit with automatic invalidation.
1980
+ * Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
1500
1981
  *
1501
- * // Delete (soft)
1502
- * await repo.delete(id);
1982
+ * Features:
1983
+ * - Cache-aside (read-through) pattern with configurable TTLs
1984
+ * - Automatic invalidation on create/update/delete
1985
+ * - Collection version tags for efficient list cache invalidation
1986
+ * - Manual invalidation methods for microservice scenarios
1987
+ * - Skip cache per-operation with `skipCache: true`
1503
1988
  *
1504
- * // Restore
1505
- * await repo.restore(id);
1989
+ * @example
1990
+ * ```typescript
1991
+ * import { Repository, cachePlugin } from '@classytic/mongokit';
1992
+ * import Redis from 'ioredis';
1506
1993
  *
1507
- * // Get deleted documents
1508
- * await repo.getDeleted({ page: 1, limit: 20 });
1509
- * ```
1994
+ * const redis = new Redis();
1510
1995
  *
1511
- * @example With null filter mode (for schemas with default: null)
1512
- * ```typescript
1513
- * // Schema: { deletedAt: { type: Date, default: null } }
1514
- * const repo = new Repository(Model, [
1515
- * softDeletePlugin({
1516
- * deletedField: 'deletedAt',
1517
- * filterMode: 'null', // default - works with default: null
1996
+ * const userRepo = new Repository(UserModel, [
1997
+ * cachePlugin({
1998
+ * adapter: {
1999
+ * async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
2000
+ * async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
2001
+ * async del(key) { await redis.del(key); },
2002
+ * async clear(pattern) {
2003
+ * const keys = await redis.keys(pattern || '*');
2004
+ * if (keys.length) await redis.del(...keys);
2005
+ * }
2006
+ * },
2007
+ * ttl: 60, // 1 minute default
1518
2008
  * })
1519
2009
  * ]);
1520
- * ```
1521
2010
  *
1522
- * @example With TTL for auto-cleanup
1523
- * ```typescript
1524
- * const repo = new Repository(Model, [
1525
- * softDeletePlugin({
1526
- * deletedField: 'deletedAt',
1527
- * ttlDays: 30, // Auto-delete after 30 days
1528
- * })
1529
- * ]);
2011
+ * // Reads check cache first
2012
+ * const user = await userRepo.getById(id); // cached
2013
+ *
2014
+ * // Skip cache for fresh data
2015
+ * const fresh = await userRepo.getById(id, { skipCache: true });
2016
+ *
2017
+ * // Mutations auto-invalidate
2018
+ * await userRepo.update(id, { name: 'New Name' }); // invalidates cache
2019
+ *
2020
+ * // Manual invalidation for microservice sync
2021
+ * await userRepo.invalidateCache(id); // invalidate single doc
2022
+ * await userRepo.invalidateAllCache(); // invalidate all for this model
1530
2023
  * ```
1531
2024
  */
1532
- function softDeletePlugin(options = {}) {
1533
- const deletedField = options.deletedField || "deletedAt";
1534
- const deletedByField = options.deletedByField || "deletedBy";
1535
- const filterMode = options.filterMode || "null";
1536
- const addRestoreMethod = options.addRestoreMethod !== false;
1537
- const addGetDeletedMethod = options.addGetDeletedMethod !== false;
1538
- const ttlDays = options.ttlDays;
2025
+ /**
2026
+ * Cache plugin factory
2027
+ *
2028
+ * @param options - Cache configuration
2029
+ * @returns Plugin instance
2030
+ */
2031
+ function cachePlugin(options) {
2032
+ const config = {
2033
+ adapter: options.adapter,
2034
+ ttl: options.ttl ?? 60,
2035
+ byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
2036
+ queryTtl: options.queryTtl ?? options.ttl ?? 60,
2037
+ prefix: options.prefix ?? "mk",
2038
+ debug: options.debug ?? false,
2039
+ skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
2040
+ };
2041
+ const stats = {
2042
+ hits: 0,
2043
+ misses: 0,
2044
+ sets: 0,
2045
+ invalidations: 0,
2046
+ errors: 0
2047
+ };
2048
+ const log = (msg, data) => {
2049
+ if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
2050
+ };
1539
2051
  return {
1540
- name: "softDelete",
2052
+ name: "cache",
1541
2053
  apply(repo) {
1542
- try {
1543
- const schemaPaths = repo.Model.schema.paths;
1544
- for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
1545
- if (pathName === "_id" || pathName === deletedField) continue;
1546
- if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
2054
+ const model = repo.model;
2055
+ const byIdKeyRegistry = /* @__PURE__ */ new Map();
2056
+ function trackByIdKey(docId, cacheKey) {
2057
+ let keys = byIdKeyRegistry.get(docId);
2058
+ if (!keys) {
2059
+ keys = /* @__PURE__ */ new Set();
2060
+ byIdKeyRegistry.set(docId, keys);
1547
2061
  }
1548
- } catch (err) {
1549
- warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
2062
+ keys.add(cacheKey);
1550
2063
  }
1551
- if (ttlDays !== void 0 && ttlDays > 0) {
1552
- const ttlSeconds = ttlDays * 24 * 60 * 60;
1553
- repo.Model.collection.createIndex({ [deletedField]: 1 }, {
1554
- expireAfterSeconds: ttlSeconds,
1555
- partialFilterExpression: { [deletedField]: { $type: "date" } }
1556
- }).catch((err) => {
1557
- if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1558
- });
2064
+ async function getVersion() {
2065
+ try {
2066
+ return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
2067
+ } catch (e) {
2068
+ log(`Cache error in getVersion for ${model}:`, e);
2069
+ return 0;
2070
+ }
1559
2071
  }
1560
- repo.on("before:delete", async (context) => {
1561
- if (options.soft !== false) {
1562
- const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
1563
- if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
1564
- const deleteQuery = {
1565
- _id: context.id,
1566
- ...context.query || {}
1567
- };
1568
- if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
1569
- const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
1570
- error.status = 404;
1571
- throw error;
1572
- }
1573
- context.softDeleted = true;
2072
+ /**
2073
+ * Bump collection version in the adapter (invalidates all list caches).
2074
+ * Uses Date.now() so version always moves forward safe after eviction or deploy.
2075
+ */
2076
+ async function bumpVersion() {
2077
+ const newVersion = Date.now();
2078
+ try {
2079
+ await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
2080
+ stats.invalidations++;
2081
+ log(`Bumped version for ${model} to:`, newVersion);
2082
+ } catch (e) {
2083
+ log(`Failed to bump version for ${model}:`, e);
1574
2084
  }
1575
- }, { priority: HOOK_PRIORITY.POLICY });
1576
- repo.on("before:getAll", (context) => {
1577
- if (options.soft !== false) {
1578
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1579
- if (Object.keys(deleteFilter).length > 0) context.filters = {
1580
- ...context.filters || {},
1581
- ...deleteFilter
1582
- };
2085
+ }
2086
+ /**
2087
+ * Invalidate a specific document by ID (all shape variants).
2088
+ * Deletes every tracked shape-variant key individually via del(),
2089
+ * so adapters without pattern-based clear() still get full invalidation.
2090
+ */
2091
+ async function invalidateById(id) {
2092
+ try {
2093
+ const baseKey = byIdKey(config.prefix, model, id);
2094
+ await config.adapter.del(baseKey);
2095
+ const trackedKeys = byIdKeyRegistry.get(id);
2096
+ if (trackedKeys) {
2097
+ for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
2098
+ byIdKeyRegistry.delete(id);
2099
+ }
2100
+ stats.invalidations++;
2101
+ log(`Invalidated byId cache for:`, id);
2102
+ } catch (e) {
2103
+ log(`Failed to invalidate byId cache:`, e);
1583
2104
  }
1584
- }, { priority: HOOK_PRIORITY.POLICY });
1585
- repo.on("before:getById", (context) => {
1586
- if (options.soft !== false) {
1587
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1588
- if (Object.keys(deleteFilter).length > 0) context.query = {
1589
- ...context.query || {},
1590
- ...deleteFilter
1591
- };
2105
+ }
2106
+ /**
2107
+ * before:getById - Check cache for document
2108
+ * Runs at CACHE priority (200) after policy hooks inject filters
2109
+ */
2110
+ repo.on("before:getById", async (context) => {
2111
+ if (context.skipCache) {
2112
+ log(`Skipping cache for getById: ${context.id}`);
2113
+ return;
1592
2114
  }
1593
- }, { priority: HOOK_PRIORITY.POLICY });
1594
- repo.on("before:getByQuery", (context) => {
1595
- if (options.soft !== false) {
1596
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1597
- if (Object.keys(deleteFilter).length > 0) context.query = {
1598
- ...context.query || {},
1599
- ...deleteFilter
1600
- };
2115
+ const id = String(context.id);
2116
+ const key = byIdKey(config.prefix, model, id, {
2117
+ select: context.select,
2118
+ populate: context.populate,
2119
+ lean: context.lean
2120
+ });
2121
+ try {
2122
+ const cached = await config.adapter.get(key);
2123
+ if (cached !== null) {
2124
+ stats.hits++;
2125
+ log(`Cache HIT for getById:`, key);
2126
+ context._cacheHit = true;
2127
+ context._cachedResult = cached;
2128
+ } else {
2129
+ stats.misses++;
2130
+ log(`Cache MISS for getById:`, key);
2131
+ }
2132
+ } catch (e) {
2133
+ log(`Cache error for getById:`, e);
2134
+ stats.errors++;
1601
2135
  }
1602
- }, { priority: HOOK_PRIORITY.POLICY });
1603
- repo.on("before:count", (context) => {
1604
- if (options.soft !== false) {
1605
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1606
- if (Object.keys(deleteFilter).length > 0) context.query = {
1607
- ...context.query || {},
1608
- ...deleteFilter
1609
- };
2136
+ }, { priority: HOOK_PRIORITY.CACHE });
2137
+ /**
2138
+ * before:getByQuery - Check cache for single-doc query
2139
+ * Runs at CACHE priority (200) after policy hooks inject filters
2140
+ */
2141
+ repo.on("before:getByQuery", async (context) => {
2142
+ if (context.skipCache) {
2143
+ log(`Skipping cache for getByQuery`);
2144
+ return;
1610
2145
  }
1611
- }, { priority: HOOK_PRIORITY.POLICY });
1612
- repo.on("before:exists", (context) => {
1613
- if (options.soft !== false) {
1614
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1615
- if (Object.keys(deleteFilter).length > 0) context.query = {
1616
- ...context.query || {},
1617
- ...deleteFilter
1618
- };
2146
+ const collectionVersion = await getVersion();
2147
+ const query = context.query || {};
2148
+ const key = byQueryKey(config.prefix, model, collectionVersion, query, {
2149
+ select: context.select,
2150
+ populate: context.populate
2151
+ });
2152
+ try {
2153
+ const cached = await config.adapter.get(key);
2154
+ if (cached !== null) {
2155
+ stats.hits++;
2156
+ log(`Cache HIT for getByQuery:`, key);
2157
+ context._cacheHit = true;
2158
+ context._cachedResult = cached;
2159
+ } else {
2160
+ stats.misses++;
2161
+ log(`Cache MISS for getByQuery:`, key);
2162
+ }
2163
+ } catch (e) {
2164
+ log(`Cache error for getByQuery:`, e);
2165
+ stats.errors++;
1619
2166
  }
1620
- }, { priority: HOOK_PRIORITY.POLICY });
1621
- repo.on("before:getOrCreate", (context) => {
1622
- if (options.soft !== false) {
1623
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1624
- if (Object.keys(deleteFilter).length > 0) context.query = {
1625
- ...context.query || {},
1626
- ...deleteFilter
1627
- };
2167
+ }, { priority: HOOK_PRIORITY.CACHE });
2168
+ /**
2169
+ * before:getAll - Check cache for list query
2170
+ * Runs at CACHE priority (200) after policy hooks inject filters
2171
+ */
2172
+ repo.on("before:getAll", async (context) => {
2173
+ if (context.skipCache) {
2174
+ log(`Skipping cache for getAll`);
2175
+ return;
1628
2176
  }
1629
- }, { priority: HOOK_PRIORITY.POLICY });
1630
- repo.on("before:distinct", (context) => {
1631
- if (options.soft !== false) {
1632
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1633
- if (Object.keys(deleteFilter).length > 0) context.query = {
1634
- ...context.query || {},
1635
- ...deleteFilter
1636
- };
2177
+ const limit = context.limit;
2178
+ if (limit && limit > config.skipIfLargeLimit) {
2179
+ log(`Skipping cache for large query (limit: ${limit})`);
2180
+ return;
1637
2181
  }
1638
- }, { priority: HOOK_PRIORITY.POLICY });
1639
- repo.on("before:aggregate", (context) => {
1640
- if (options.soft !== false) {
1641
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1642
- if (Object.keys(deleteFilter).length > 0) context.query = {
1643
- ...context.query || {},
1644
- ...deleteFilter
1645
- };
2182
+ const collectionVersion = await getVersion();
2183
+ const params = {
2184
+ filters: context.filters,
2185
+ sort: context.sort,
2186
+ page: context.page,
2187
+ limit,
2188
+ after: context.after,
2189
+ select: context.select,
2190
+ populate: context.populate,
2191
+ search: context.search,
2192
+ mode: context.mode,
2193
+ lean: context.lean,
2194
+ readPreference: context.readPreference,
2195
+ hint: context.hint,
2196
+ maxTimeMS: context.maxTimeMS,
2197
+ countStrategy: context.countStrategy
2198
+ };
2199
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
2200
+ try {
2201
+ const cached = await config.adapter.get(key);
2202
+ if (cached !== null) {
2203
+ stats.hits++;
2204
+ log(`Cache HIT for getAll:`, key);
2205
+ context._cacheHit = true;
2206
+ context._cachedResult = cached;
2207
+ } else {
2208
+ stats.misses++;
2209
+ log(`Cache MISS for getAll:`, key);
2210
+ }
2211
+ } catch (e) {
2212
+ log(`Cache error for getAll:`, e);
2213
+ stats.errors++;
1646
2214
  }
1647
- }, { priority: HOOK_PRIORITY.POLICY });
1648
- repo.on("before:aggregatePaginate", (context) => {
1649
- if (options.soft !== false) {
1650
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1651
- if (Object.keys(deleteFilter).length > 0) context.filters = {
1652
- ...context.filters || {},
1653
- ...deleteFilter
1654
- };
2215
+ }, { priority: HOOK_PRIORITY.CACHE });
2216
+ /**
2217
+ * after:getById - Cache the result
2218
+ */
2219
+ repo.on("after:getById", async (payload) => {
2220
+ const { context, result } = payload;
2221
+ if (context._cacheHit) return;
2222
+ if (context.skipCache) return;
2223
+ if (result === null) return;
2224
+ const id = String(context.id);
2225
+ const key = byIdKey(config.prefix, model, id, {
2226
+ select: context.select,
2227
+ populate: context.populate,
2228
+ lean: context.lean
2229
+ });
2230
+ const ttl = context.cacheTtl ?? config.byIdTtl;
2231
+ try {
2232
+ await config.adapter.set(key, result, ttl);
2233
+ trackByIdKey(id, key);
2234
+ stats.sets++;
2235
+ log(`Cached getById result:`, key);
2236
+ } catch (e) {
2237
+ log(`Failed to cache getById:`, e);
1655
2238
  }
1656
- }, { priority: HOOK_PRIORITY.POLICY });
1657
- if (addRestoreMethod) {
1658
- const restoreMethod = async function(id, restoreOptions = {}) {
1659
- const context = await this._buildContext.call(this, "restore", {
1660
- id,
1661
- ...restoreOptions
1662
- });
1663
- const updateData = {
1664
- [deletedField]: null,
1665
- [deletedByField]: null
1666
- };
1667
- const restoreQuery = {
1668
- _id: id,
1669
- ...context.query || {}
1670
- };
1671
- const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
1672
- returnDocument: "after",
1673
- session: restoreOptions.session
1674
- });
1675
- if (!result) {
1676
- const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
1677
- error.status = 404;
1678
- throw error;
1679
- }
1680
- await this.emitAsync("after:restore", {
1681
- id,
1682
- result,
1683
- context
1684
- });
1685
- return result;
1686
- };
1687
- if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
1688
- else repo.restore = restoreMethod.bind(repo);
1689
- }
1690
- if (addGetDeletedMethod) {
1691
- const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
1692
- const context = await this._buildContext.call(this, "getDeleted", {
1693
- ...params,
1694
- ...getDeletedOptions
1695
- });
1696
- const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
1697
- const combinedFilters = {
1698
- ...params.filters || {},
1699
- ...deletedFilter,
1700
- ...context.filters || {},
1701
- ...context.query || {}
1702
- };
1703
- const page = params.page || 1;
1704
- const limit = params.limit || 20;
1705
- const skip = (page - 1) * limit;
1706
- let sortSpec = { [deletedField]: -1 };
1707
- if (params.sort) if (typeof params.sort === "string") {
1708
- const sortOrder = params.sort.startsWith("-") ? -1 : 1;
1709
- sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
1710
- } else sortSpec = params.sort;
1711
- let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
1712
- if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
1713
- if (getDeletedOptions.select) {
1714
- const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
1715
- query = query.select(selectValue);
1716
- }
1717
- if (getDeletedOptions.populate) {
1718
- const populateSpec = getDeletedOptions.populate;
1719
- if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
1720
- else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
1721
- else query = query.populate(populateSpec);
1722
- }
1723
- if (getDeletedOptions.lean !== false) query = query.lean();
1724
- const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
1725
- const pages = Math.ceil(total / limit);
1726
- return {
1727
- method: "offset",
1728
- docs,
1729
- page,
1730
- limit,
1731
- total,
1732
- pages,
1733
- hasNext: page < pages,
1734
- hasPrev: page > 1
1735
- };
1736
- };
1737
- if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
1738
- else repo.getDeleted = getDeletedMethod.bind(repo);
1739
- }
1740
- }
1741
- };
1742
- }
1743
-
1744
- //#endregion
1745
- //#region src/plugins/method-registry.plugin.ts
1746
- /**
1747
- * Method registry plugin that enables dynamic method registration
1748
- */
1749
- function methodRegistryPlugin() {
1750
- return {
1751
- name: "method-registry",
1752
- apply(repo) {
1753
- const registeredMethods = [];
2239
+ });
1754
2240
  /**
1755
- * Register a new method on the repository instance
2241
+ * after:getByQuery - Cache the result
1756
2242
  */
1757
- repo.registerMethod = function(name, fn) {
1758
- 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.`);
1759
- if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
1760
- if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
1761
- repo[name] = fn.bind(repo);
1762
- registeredMethods.push(name);
1763
- repo.emit("method:registered", {
1764
- name,
1765
- fn
2243
+ repo.on("after:getByQuery", async (payload) => {
2244
+ const { context, result } = payload;
2245
+ if (context._cacheHit) return;
2246
+ if (context.skipCache) return;
2247
+ if (result === null) return;
2248
+ const collectionVersion = await getVersion();
2249
+ const query = context.query || {};
2250
+ const key = byQueryKey(config.prefix, model, collectionVersion, query, {
2251
+ select: context.select,
2252
+ populate: context.populate
1766
2253
  });
2254
+ const ttl = context.cacheTtl ?? config.queryTtl;
2255
+ try {
2256
+ await config.adapter.set(key, result, ttl);
2257
+ stats.sets++;
2258
+ log(`Cached getByQuery result:`, key);
2259
+ } catch (e) {
2260
+ log(`Failed to cache getByQuery:`, e);
2261
+ }
2262
+ });
2263
+ /**
2264
+ * after:getAll - Cache the result
2265
+ */
2266
+ repo.on("after:getAll", async (payload) => {
2267
+ const { context, result } = payload;
2268
+ if (context._cacheHit) return;
2269
+ if (context.skipCache) return;
2270
+ const limit = context.limit;
2271
+ if (limit && limit > config.skipIfLargeLimit) return;
2272
+ const collectionVersion = await getVersion();
2273
+ const params = {
2274
+ filters: context.filters,
2275
+ sort: context.sort,
2276
+ page: context.page,
2277
+ limit,
2278
+ after: context.after,
2279
+ select: context.select,
2280
+ populate: context.populate,
2281
+ search: context.search,
2282
+ mode: context.mode,
2283
+ lean: context.lean,
2284
+ readPreference: context.readPreference,
2285
+ hint: context.hint,
2286
+ maxTimeMS: context.maxTimeMS,
2287
+ countStrategy: context.countStrategy
2288
+ };
2289
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
2290
+ const ttl = context.cacheTtl ?? config.queryTtl;
2291
+ try {
2292
+ await config.adapter.set(key, result, ttl);
2293
+ stats.sets++;
2294
+ log(`Cached getAll result:`, key);
2295
+ } catch (e) {
2296
+ log(`Failed to cache getAll:`, e);
2297
+ }
2298
+ });
2299
+ /**
2300
+ * after:create - Bump version to invalidate list caches
2301
+ */
2302
+ repo.on("after:create", async () => {
2303
+ await bumpVersion();
2304
+ });
2305
+ /**
2306
+ * after:createMany - Bump version to invalidate list caches
2307
+ */
2308
+ repo.on("after:createMany", async () => {
2309
+ await bumpVersion();
2310
+ });
2311
+ /**
2312
+ * after:update - Invalidate by ID and bump version
2313
+ */
2314
+ repo.on("after:update", async (payload) => {
2315
+ const { context } = payload;
2316
+ const id = String(context.id);
2317
+ await Promise.all([invalidateById(id), bumpVersion()]);
2318
+ });
2319
+ /**
2320
+ * after:updateMany - Bump version (can't track individual IDs efficiently)
2321
+ */
2322
+ repo.on("after:updateMany", async () => {
2323
+ await bumpVersion();
2324
+ });
2325
+ /**
2326
+ * after:delete - Invalidate by ID and bump version
2327
+ */
2328
+ repo.on("after:delete", async (payload) => {
2329
+ const { context } = payload;
2330
+ const id = String(context.id);
2331
+ await Promise.all([invalidateById(id), bumpVersion()]);
2332
+ });
2333
+ /**
2334
+ * after:deleteMany - Bump version
2335
+ */
2336
+ repo.on("after:deleteMany", async () => {
2337
+ await bumpVersion();
2338
+ });
2339
+ /**
2340
+ * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
2341
+ */
2342
+ repo.on("after:bulkWrite", async () => {
2343
+ await bumpVersion();
2344
+ });
2345
+ /**
2346
+ * Invalidate cache for a specific document
2347
+ * Use when document was updated outside this service
2348
+ *
2349
+ * @example
2350
+ * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
2351
+ */
2352
+ repo.invalidateCache = async (id) => {
2353
+ await invalidateById(id);
2354
+ log(`Manual invalidation for ID:`, id);
1767
2355
  };
1768
2356
  /**
1769
- * Check if a method is registered
2357
+ * Invalidate all list caches for this model
2358
+ * Use when bulk changes happened outside this service
2359
+ *
2360
+ * @example
2361
+ * await userRepo.invalidateListCache();
1770
2362
  */
1771
- repo.hasMethod = function(name) {
1772
- return typeof repo[name] === "function";
2363
+ repo.invalidateListCache = async () => {
2364
+ await bumpVersion();
2365
+ log(`Manual list cache invalidation for ${model}`);
1773
2366
  };
1774
2367
  /**
1775
- * Get list of all dynamically registered methods
2368
+ * Invalidate ALL cache entries for this model
2369
+ * Nuclear option - use sparingly
2370
+ *
2371
+ * @example
2372
+ * await userRepo.invalidateAllCache();
1776
2373
  */
1777
- repo.getRegisteredMethods = function() {
1778
- return [...registeredMethods];
2374
+ repo.invalidateAllCache = async () => {
2375
+ if (config.adapter.clear) try {
2376
+ await config.adapter.clear(modelPattern(config.prefix, model));
2377
+ stats.invalidations++;
2378
+ log(`Full cache invalidation for ${model}`);
2379
+ } catch (e) {
2380
+ log(`Failed full cache invalidation for ${model}:`, e);
2381
+ }
2382
+ else {
2383
+ await bumpVersion();
2384
+ log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
2385
+ }
1779
2386
  };
1780
- }
1781
- };
1782
- }
1783
-
1784
- //#endregion
1785
- //#region src/plugins/validation-chain.plugin.ts
1786
- /**
1787
- * Validation Chain Plugin
1788
- *
1789
- * Composable validation for repository operations with customizable rules.
1790
- */
1791
- /**
1792
- * Validation chain plugin
1793
- *
1794
- * @example
1795
- * const repo = new Repository(Model, [
1796
- * validationChainPlugin([
1797
- * requireField('email'),
1798
- * uniqueField('email', 'Email already exists'),
1799
- * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
1800
- * ])
1801
- * ]);
1802
- */
1803
- function validationChainPlugin(validators = [], options = {}) {
1804
- const { stopOnFirstError = true } = options;
1805
- validators.forEach((v, idx) => {
1806
- if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
1807
- if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
1808
- });
1809
- const validatorsByOperation = {
1810
- create: [],
1811
- update: [],
1812
- delete: [],
1813
- createMany: []
1814
- };
1815
- const allOperationsValidators = [];
1816
- validators.forEach((v) => {
1817
- if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
1818
- else v.operations.forEach((op) => {
1819
- if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
1820
- });
1821
- });
1822
- return {
1823
- name: "validation-chain",
1824
- apply(repo) {
1825
- const getValidatorsForOperation = (operation) => {
1826
- const specific = validatorsByOperation[operation] || [];
1827
- return [...allOperationsValidators, ...specific];
1828
- };
1829
- const runValidators = async (operation, context) => {
1830
- const operationValidators = getValidatorsForOperation(operation);
1831
- const errors = [];
1832
- for (const validator of operationValidators) try {
1833
- await validator.validate(context, repo);
1834
- } catch (error) {
1835
- if (stopOnFirstError) throw error;
1836
- errors.push({
1837
- validator: validator.name,
1838
- error: error.message || String(error)
1839
- });
1840
- }
1841
- if (errors.length > 0) {
1842
- const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
1843
- err.validationErrors = errors;
1844
- throw err;
1845
- }
2387
+ /**
2388
+ * Get cache statistics for monitoring
2389
+ *
2390
+ * @example
2391
+ * const stats = userRepo.getCacheStats();
2392
+ * console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
2393
+ */
2394
+ repo.getCacheStats = () => ({ ...stats });
2395
+ /**
2396
+ * Reset cache statistics
2397
+ */
2398
+ repo.resetCacheStats = () => {
2399
+ stats.hits = 0;
2400
+ stats.misses = 0;
2401
+ stats.sets = 0;
2402
+ stats.invalidations = 0;
2403
+ stats.errors = 0;
1846
2404
  };
1847
- repo.on("before:create", async (context) => runValidators("create", context));
1848
- repo.on("before:createMany", async (context) => runValidators("createMany", context));
1849
- repo.on("before:update", async (context) => runValidators("update", context));
1850
- repo.on("before:delete", async (context) => runValidators("delete", context));
1851
- }
1852
- };
1853
- }
1854
- /**
1855
- * Block operation if condition is true
1856
- *
1857
- * @example
1858
- * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
1859
- */
1860
- function blockIf(name, operations, condition, errorMessage) {
1861
- return {
1862
- name,
1863
- operations,
1864
- validate: (context) => {
1865
- if (condition(context)) throw createError(403, errorMessage);
1866
- }
1867
- };
1868
- }
1869
- /**
1870
- * Require a field to be present
1871
- */
1872
- function requireField(field, operations = ["create"]) {
1873
- return {
1874
- name: `require-${field}`,
1875
- operations,
1876
- validate: (context) => {
1877
- if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
1878
- }
1879
- };
1880
- }
1881
- /**
1882
- * Auto-inject a value if not present
1883
- */
1884
- function autoInject(field, getter, operations = ["create"]) {
1885
- return {
1886
- name: `auto-inject-${field}`,
1887
- operations,
1888
- validate: (context) => {
1889
- if (context.data && !(field in context.data)) {
1890
- const value = getter(context);
1891
- if (value !== null && value !== void 0) context.data[field] = value;
1892
- }
1893
- }
1894
- };
1895
- }
1896
- /**
1897
- * Make a field immutable (cannot be updated)
1898
- */
1899
- function immutableField(field) {
1900
- return {
1901
- name: `immutable-${field}`,
1902
- operations: ["update"],
1903
- validate: (context) => {
1904
- if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
1905
- }
1906
- };
1907
- }
1908
- /**
1909
- * Ensure field value is unique
1910
- */
1911
- function uniqueField(field, errorMessage) {
1912
- return {
1913
- name: `unique-${field}`,
1914
- operations: ["create", "update"],
1915
- validate: async (context, repo) => {
1916
- if (!context.data || !context.data[field]) return;
1917
- if (!repo) {
1918
- warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
1919
- return;
1920
- }
1921
- const query = { [field]: context.data[field] };
1922
- const getByQuery = repo.getByQuery;
1923
- if (typeof getByQuery !== "function") {
1924
- warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
1925
- return;
1926
- }
1927
- const existing = await getByQuery.call(repo, query, {
1928
- select: "_id",
1929
- lean: true,
1930
- throwOnNotFound: false
1931
- });
1932
- if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
1933
2405
  }
1934
2406
  };
1935
2407
  }
1936
-
1937
2408
  //#endregion
1938
- //#region src/plugins/mongo-operations.plugin.ts
1939
- /**
1940
- * MongoDB Operations Plugin
1941
- *
1942
- * Adds MongoDB-specific operations to repositories.
1943
- * Requires method-registry.plugin.js to be loaded first.
1944
- */
2409
+ //#region src/plugins/cascade.plugin.ts
1945
2410
  /**
1946
- * MongoDB operations plugin
1947
- *
1948
- * Adds MongoDB-specific atomic operations to repositories:
1949
- * - upsert: Create or update document
1950
- * - increment/decrement: Atomic numeric operations
1951
- * - pushToArray/pullFromArray/addToSet: Array operations
1952
- * - setField/unsetField/renameField: Field operations
1953
- * - multiplyField: Multiply numeric field
1954
- * - setMin/setMax: Conditional min/max updates
2411
+ * Cascade Delete Plugin
2412
+ * Automatically deletes related documents when a parent document is deleted
1955
2413
  *
1956
- * @example Basic usage (no TypeScript autocomplete)
2414
+ * @example
1957
2415
  * ```typescript
1958
- * const repo = new Repository(ProductModel, [
2416
+ * import mongoose from 'mongoose';
2417
+ * import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
2418
+ *
2419
+ * const productRepo = new Repository(Product, [
1959
2420
  * methodRegistryPlugin(),
1960
- * mongoOperationsPlugin(),
2421
+ * cascadePlugin({
2422
+ * relations: [
2423
+ * { model: 'StockEntry', foreignKey: 'product' },
2424
+ * { model: 'StockMovement', foreignKey: 'product' },
2425
+ * ]
2426
+ * })
1961
2427
  * ]);
1962
2428
  *
1963
- * // Works at runtime but TypeScript doesn't know about these methods
1964
- * await (repo as any).increment(productId, 'views', 1);
1965
- * await (repo as any).pushToArray(productId, 'tags', 'featured');
2429
+ * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
2430
+ * await productRepo.delete(productId);
1966
2431
  * ```
2432
+ */
2433
+ /**
2434
+ * Cascade delete plugin
1967
2435
  *
1968
- * @example With TypeScript type safety (recommended)
1969
- * ```typescript
1970
- * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
1971
- * import type { MongoOperationsMethods } from '@classytic/mongokit';
1972
- *
1973
- * class ProductRepo extends Repository<IProduct> {
1974
- * // Add your custom methods here
1975
- * }
1976
- *
1977
- * // Create with type assertion to get autocomplete for plugin methods
1978
- * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
1979
- *
1980
- * const repo = new ProductRepo(ProductModel, [
1981
- * methodRegistryPlugin(),
1982
- * mongoOperationsPlugin(),
1983
- * ]) as ProductRepoWithPlugins;
2436
+ * Deletes related documents after the parent document is deleted.
2437
+ * Works with both hard delete and soft delete scenarios.
1984
2438
  *
1985
- * // Now TypeScript provides autocomplete and type checking!
1986
- * await repo.increment(productId, 'views', 1);
1987
- * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
1988
- * await repo.pushToArray(productId, 'tags', 'featured');
1989
- * ```
2439
+ * @param options - Cascade configuration
2440
+ * @returns Plugin
1990
2441
  */
1991
- function mongoOperationsPlugin() {
2442
+ function cascadePlugin(options) {
2443
+ const { relations, parallel = true, logger } = options;
2444
+ if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
1992
2445
  return {
1993
- name: "mongo-operations",
2446
+ name: "cascade",
1994
2447
  apply(repo) {
1995
- if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
1996
- /**
1997
- * Update existing document or insert new one
1998
- */
1999
- repo.registerMethod("upsert", async function(query, data, options = {}) {
2000
- return upsert(this.Model, query, data, options);
2001
- });
2002
- const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
2003
- if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
2004
- return this.update(id, { [operator]: { [field]: value } }, options);
2005
- };
2006
- /**
2007
- * Atomically increment numeric field
2008
- */
2009
- repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
2010
- return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
2011
- });
2012
- /**
2013
- * Atomically decrement numeric field
2014
- */
2015
- repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
2016
- return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
2017
- });
2018
- const applyOperator = function(id, field, value, operator, options) {
2019
- return this.update(id, { [operator]: { [field]: value } }, options);
2020
- };
2021
- /**
2022
- * Push value to array field
2023
- */
2024
- repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
2025
- return applyOperator.call(this, id, field, value, "$push", options);
2026
- });
2027
- /**
2028
- * Remove value from array field
2029
- */
2030
- repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
2031
- return applyOperator.call(this, id, field, value, "$pull", options);
2032
- });
2033
- /**
2034
- * Add value to array only if not already present (unique)
2035
- */
2036
- repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
2037
- return applyOperator.call(this, id, field, value, "$addToSet", options);
2038
- });
2039
- /**
2040
- * Set field value (alias for update with $set)
2041
- */
2042
- repo.registerMethod("setField", async function(id, field, value, options = {}) {
2043
- return applyOperator.call(this, id, field, value, "$set", options);
2044
- });
2045
- /**
2046
- * Unset (remove) field from document
2047
- */
2048
- repo.registerMethod("unsetField", async function(id, fields, options = {}) {
2049
- const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
2050
- acc[field] = "";
2051
- return acc;
2052
- }, {});
2053
- return this.update(id, { $unset: unsetObj }, options);
2054
- });
2055
- /**
2056
- * Rename field in document
2057
- */
2058
- repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
2059
- return this.update(id, { $rename: { [oldName]: newName } }, options);
2060
- });
2061
- /**
2062
- * Multiply numeric field by value
2063
- */
2064
- repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
2065
- return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
2066
- });
2067
- /**
2068
- * Set field to minimum value (only if current value is greater)
2069
- */
2070
- repo.registerMethod("setMin", async function(id, field, value, options = {}) {
2071
- return applyOperator.call(this, id, field, value, "$min", options);
2448
+ repo.on("after:delete", async (payload) => {
2449
+ const { context } = payload;
2450
+ const deletedId = context.id;
2451
+ if (!deletedId) {
2452
+ logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
2453
+ return;
2454
+ }
2455
+ const isSoftDelete = context.softDeleted === true;
2456
+ const cascadeDelete = async (relation) => {
2457
+ const RelatedModel = mongoose.models[relation.model];
2458
+ if (!RelatedModel) {
2459
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2460
+ parentModel: context.model,
2461
+ parentId: String(deletedId)
2462
+ });
2463
+ return;
2464
+ }
2465
+ const query = { [relation.foreignKey]: deletedId };
2466
+ try {
2467
+ if (relation.softDelete ?? isSoftDelete) {
2468
+ const updateResult = await RelatedModel.updateMany(query, {
2469
+ deletedAt: /* @__PURE__ */ new Date(),
2470
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2471
+ }, { session: context.session });
2472
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2473
+ parentModel: context.model,
2474
+ parentId: String(deletedId),
2475
+ relatedModel: relation.model,
2476
+ foreignKey: relation.foreignKey,
2477
+ count: updateResult.modifiedCount
2478
+ });
2479
+ } else {
2480
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2481
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2482
+ parentModel: context.model,
2483
+ parentId: String(deletedId),
2484
+ relatedModel: relation.model,
2485
+ foreignKey: relation.foreignKey,
2486
+ count: deleteResult.deletedCount
2487
+ });
2488
+ }
2489
+ } catch (error) {
2490
+ logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2491
+ parentModel: context.model,
2492
+ parentId: String(deletedId),
2493
+ relatedModel: relation.model,
2494
+ foreignKey: relation.foreignKey,
2495
+ error: error.message
2496
+ });
2497
+ throw error;
2498
+ }
2499
+ };
2500
+ if (parallel) {
2501
+ const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
2502
+ if (failures.length) {
2503
+ const err = failures[0].reason;
2504
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2505
+ throw err;
2506
+ }
2507
+ } else for (const relation of relations) await cascadeDelete(relation);
2072
2508
  });
2073
- /**
2074
- * Set field to maximum value (only if current value is less)
2075
- */
2076
- repo.registerMethod("setMax", async function(id, field, value, options = {}) {
2077
- return applyOperator.call(this, id, field, value, "$max", options);
2509
+ repo.on("before:deleteMany", async (context) => {
2510
+ const query = context.query;
2511
+ if (!query || Object.keys(query).length === 0) return;
2512
+ context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
2078
2513
  });
2079
- /**
2080
- * Atomic update with multiple MongoDB operators in a single call
2081
- *
2082
- * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
2083
- * into one atomic database operation.
2084
- *
2085
- * @example
2086
- * // Combine $inc + $set in one atomic call
2087
- * await repo.atomicUpdate(id, {
2088
- * $inc: { views: 1, commentCount: 1 },
2089
- * $set: { lastActiveAt: new Date() }
2090
- * });
2091
- *
2092
- * // Multiple operators: $inc + $set + $push
2093
- * await repo.atomicUpdate(id, {
2094
- * $inc: { 'metrics.total': 1 },
2095
- * $set: { updatedAt: new Date() },
2096
- * $push: { history: { action: 'update', at: new Date() } }
2097
- * });
2098
- *
2099
- * // $push with $each modifier
2100
- * await repo.atomicUpdate(id, {
2101
- * $push: { tags: { $each: ['featured', 'popular'] } },
2102
- * $inc: { tagCount: 2 }
2103
- * });
2104
- *
2105
- * // With arrayFilters for positional updates
2106
- * await repo.atomicUpdate(id, {
2107
- * $set: { 'items.$[elem].quantity': 5 }
2108
- * }, { arrayFilters: [{ 'elem._id': itemId }] });
2109
- */
2110
- repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
2111
- const validOperators = new Set([
2112
- "$inc",
2113
- "$set",
2114
- "$unset",
2115
- "$push",
2116
- "$pull",
2117
- "$addToSet",
2118
- "$pop",
2119
- "$rename",
2120
- "$min",
2121
- "$max",
2122
- "$mul",
2123
- "$setOnInsert",
2124
- "$bit",
2125
- "$currentDate"
2126
- ]);
2127
- const keys = Object.keys(operators);
2128
- if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
2129
- for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
2130
- return this.update(id, operators, options);
2514
+ repo.on("after:deleteMany", async (payload) => {
2515
+ const { context } = payload;
2516
+ const ids = context._cascadeIds;
2517
+ if (!ids || ids.length === 0) return;
2518
+ const isSoftDelete = context.softDeleted === true;
2519
+ const cascadeDeleteMany = async (relation) => {
2520
+ const RelatedModel = mongoose.models[relation.model];
2521
+ if (!RelatedModel) {
2522
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
2523
+ return;
2524
+ }
2525
+ const query = { [relation.foreignKey]: { $in: ids } };
2526
+ const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2527
+ try {
2528
+ if (shouldSoftDelete) {
2529
+ const updateResult = await RelatedModel.updateMany(query, {
2530
+ deletedAt: /* @__PURE__ */ new Date(),
2531
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2532
+ }, { session: context.session });
2533
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2534
+ parentModel: context.model,
2535
+ parentCount: ids.length,
2536
+ relatedModel: relation.model,
2537
+ foreignKey: relation.foreignKey,
2538
+ count: updateResult.modifiedCount
2539
+ });
2540
+ } else {
2541
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2542
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2543
+ parentModel: context.model,
2544
+ parentCount: ids.length,
2545
+ relatedModel: relation.model,
2546
+ foreignKey: relation.foreignKey,
2547
+ count: deleteResult.deletedCount
2548
+ });
2549
+ }
2550
+ } catch (error) {
2551
+ logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2552
+ parentModel: context.model,
2553
+ relatedModel: relation.model,
2554
+ foreignKey: relation.foreignKey,
2555
+ error: error.message
2556
+ });
2557
+ throw error;
2558
+ }
2559
+ };
2560
+ if (parallel) {
2561
+ const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
2562
+ if (failures.length) {
2563
+ const err = failures[0].reason;
2564
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2565
+ throw err;
2566
+ }
2567
+ } else for (const relation of relations) await cascadeDeleteMany(relation);
2131
2568
  });
2132
2569
  }
2133
2570
  };
2134
2571
  }
2135
-
2136
2572
  //#endregion
2137
- //#region src/plugins/batch-operations.plugin.ts
2573
+ //#region src/plugins/custom-id.plugin.ts
2138
2574
  /**
2139
- * Batch operations plugin
2140
- *
2141
- * @example
2142
- * const repo = new Repository(Model, [
2143
- * methodRegistryPlugin(),
2144
- * batchOperationsPlugin(),
2575
+ * Custom ID Plugin
2576
+ *
2577
+ * Generates custom document IDs using pluggable generators.
2578
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
2579
+ * date-partitioned sequences, and fully custom generators.
2580
+ *
2581
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
2582
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
2583
+ *
2584
+ * @example Basic sequential counter
2585
+ * ```typescript
2586
+ * const invoiceRepo = new Repository(InvoiceModel, [
2587
+ * customIdPlugin({
2588
+ * field: 'invoiceNumber',
2589
+ * generator: sequentialId({
2590
+ * prefix: 'INV',
2591
+ * model: InvoiceModel,
2592
+ * }),
2593
+ * }),
2145
2594
  * ]);
2146
- *
2147
- * await repo.updateMany({ status: 'pending' }, { status: 'active' });
2148
- * await repo.deleteMany({ status: 'deleted' });
2149
- */
2150
- function batchOperationsPlugin() {
2151
- return {
2152
- name: "batch-operations",
2153
- apply(repo) {
2154
- if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
2155
- /**
2156
- * Update multiple documents
2157
- */
2158
- repo.registerMethod("updateMany", async function(query, data, options = {}) {
2159
- const context = await this._buildContext.call(this, "updateMany", {
2160
- query,
2161
- data,
2162
- ...options
2163
- });
2164
- try {
2165
- const finalQuery = context.query || query;
2166
- if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
2167
- if (Array.isArray(data) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
2168
- const result = await this.Model.updateMany(finalQuery, data, {
2169
- runValidators: true,
2170
- session: options.session,
2171
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
2172
- }).exec();
2173
- await this.emitAsync("after:updateMany", {
2174
- context,
2175
- result
2176
- });
2177
- return result;
2178
- } catch (error) {
2179
- this.emit("error:updateMany", {
2180
- context,
2181
- error
2182
- });
2183
- throw this._handleError.call(this, error);
2184
- }
2185
- });
2186
- /**
2187
- * Execute heterogeneous bulk write operations in a single database call.
2188
- *
2189
- * Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
2190
- * operations mixed together for maximum efficiency.
2191
- *
2192
- * @example
2193
- * await repo.bulkWrite([
2194
- * { insertOne: { document: { name: 'New Item', price: 10 } } },
2195
- * { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
2196
- * { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
2197
- * { deleteOne: { filter: { _id: id2 } } },
2198
- * ]);
2199
- */
2200
- repo.registerMethod("bulkWrite", async function(operations, options = {}) {
2201
- const context = await this._buildContext.call(this, "bulkWrite", {
2202
- operations,
2203
- ...options
2204
- });
2205
- try {
2206
- const finalOps = context.operations || operations;
2207
- if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
2208
- const result = await this.Model.bulkWrite(finalOps, {
2209
- ordered: options.ordered ?? true,
2210
- session: options.session
2211
- });
2212
- const bulkResult = {
2213
- ok: result.ok,
2214
- insertedCount: result.insertedCount,
2215
- upsertedCount: result.upsertedCount,
2216
- matchedCount: result.matchedCount,
2217
- modifiedCount: result.modifiedCount,
2218
- deletedCount: result.deletedCount,
2219
- insertedIds: result.insertedIds,
2220
- upsertedIds: result.upsertedIds
2221
- };
2222
- await this.emitAsync("after:bulkWrite", {
2223
- context,
2224
- result: bulkResult
2225
- });
2226
- return bulkResult;
2227
- } catch (error) {
2228
- this.emit("error:bulkWrite", {
2229
- context,
2230
- error
2231
- });
2232
- throw this._handleError.call(this, error);
2233
- }
2234
- });
2235
- /**
2236
- * Delete multiple documents
2237
- */
2238
- repo.registerMethod("deleteMany", async function(query, options = {}) {
2239
- const context = await this._buildContext.call(this, "deleteMany", {
2240
- query,
2241
- ...options
2242
- });
2243
- try {
2244
- const finalQuery = context.query || query;
2245
- if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
2246
- const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
2247
- await this.emitAsync("after:deleteMany", {
2248
- context,
2249
- result
2250
- });
2251
- return result;
2252
- } catch (error) {
2253
- this.emit("error:deleteMany", {
2254
- context,
2255
- error
2256
- });
2257
- throw this._handleError.call(this, error);
2258
- }
2259
- });
2260
- }
2261
- };
2595
+ *
2596
+ * const inv = await invoiceRepo.create({ amount: 100 });
2597
+ * // inv.invoiceNumber "INV-0001"
2598
+ * ```
2599
+ *
2600
+ * @example Date-partitioned counter (resets monthly)
2601
+ * ```typescript
2602
+ * const billRepo = new Repository(BillModel, [
2603
+ * customIdPlugin({
2604
+ * field: 'billNumber',
2605
+ * generator: dateSequentialId({
2606
+ * prefix: 'BILL',
2607
+ * model: BillModel,
2608
+ * partition: 'monthly',
2609
+ * separator: '-',
2610
+ * padding: 4,
2611
+ * }),
2612
+ * }),
2613
+ * ]);
2614
+ *
2615
+ * const bill = await billRepo.create({ total: 250 });
2616
+ * // bill.billNumber "BILL-2026-02-0001"
2617
+ * ```
2618
+ *
2619
+ * @example Custom generator function
2620
+ * ```typescript
2621
+ * const orderRepo = new Repository(OrderModel, [
2622
+ * customIdPlugin({
2623
+ * field: 'orderRef',
2624
+ * generator: async (context) => {
2625
+ * const region = context.data?.region || 'US';
2626
+ * const seq = await getNextSequence('orders');
2627
+ * return `ORD-${region}-${seq}`;
2628
+ * },
2629
+ * }),
2630
+ * ]);
2631
+ * ```
2632
+ */
2633
+ /** Schema for the internal counters collection */
2634
+ const counterSchema = new mongoose.Schema({
2635
+ _id: {
2636
+ type: String,
2637
+ required: true
2638
+ },
2639
+ seq: {
2640
+ type: Number,
2641
+ default: 0
2642
+ }
2643
+ }, {
2644
+ collection: "_mongokit_counters",
2645
+ versionKey: false
2646
+ });
2647
+ /**
2648
+ * Get or create the Counter model on the given connection.
2649
+ * Falls back to the default mongoose connection if none is provided.
2650
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
2651
+ */
2652
+ function getCounterModel(connection) {
2653
+ const conn = connection ?? mongoose.connection;
2654
+ if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
2655
+ return conn.model("_MongoKitCounter", counterSchema);
2262
2656
  }
2263
-
2264
- //#endregion
2265
- //#region src/plugins/aggregate-helpers.plugin.ts
2266
2657
  /**
2267
- * Aggregate helpers plugin
2268
- *
2658
+ * Atomically increment and return the next sequence value for a given key.
2659
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
2660
+ * heavy concurrency.
2661
+ *
2662
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
2663
+ * @param increment - Value to increment by (default: 1)
2664
+ * @returns The next sequence number (after increment)
2665
+ *
2269
2666
  * @example
2270
- * const repo = new Repository(Model, [
2271
- * methodRegistryPlugin(),
2272
- * aggregateHelpersPlugin(),
2273
- * ]);
2274
- *
2275
- * const groups = await repo.groupBy('category');
2276
- * const total = await repo.sum('amount', { status: 'completed' });
2667
+ * const seq = await getNextSequence('invoices');
2668
+ * // First call → 1, second → 2, ...
2669
+ *
2670
+ * @example Batch increment for createMany
2671
+ * const startSeq = await getNextSequence('invoices', 5);
2672
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
2277
2673
  */
2278
- function aggregateHelpersPlugin() {
2279
- return {
2280
- name: "aggregate-helpers",
2281
- apply(repo) {
2282
- if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
2283
- /**
2284
- * Group by field
2285
- */
2286
- repo.registerMethod("groupBy", async function(field, options = {}) {
2287
- const pipeline = [{ $group: {
2288
- _id: `$${field}`,
2289
- count: { $sum: 1 }
2290
- } }, { $sort: { count: -1 } }];
2291
- if (options.limit) pipeline.push({ $limit: options.limit });
2292
- return this.aggregate.call(this, pipeline, options);
2293
- });
2294
- const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
2295
- const pipeline = [{ $match: query }, { $group: {
2296
- _id: null,
2297
- [resultKey]: { [operator]: `$${field}` }
2298
- } }];
2299
- return (await this.aggregate.call(this, pipeline, options))[0]?.[resultKey] || 0;
2300
- };
2301
- /**
2302
- * Sum field values
2303
- */
2304
- repo.registerMethod("sum", async function(field, query = {}, options = {}) {
2305
- return aggregateOperation.call(this, field, "$sum", "total", query, options);
2306
- });
2307
- /**
2308
- * Average field values
2309
- */
2310
- repo.registerMethod("average", async function(field, query = {}, options = {}) {
2311
- return aggregateOperation.call(this, field, "$avg", "avg", query, options);
2312
- });
2313
- /**
2314
- * Get minimum value
2315
- */
2316
- repo.registerMethod("min", async function(field, query = {}, options = {}) {
2317
- return aggregateOperation.call(this, field, "$min", "min", query, options);
2318
- });
2319
- /**
2320
- * Get maximum value
2321
- */
2322
- repo.registerMethod("max", async function(field, query = {}, options = {}) {
2323
- return aggregateOperation.call(this, field, "$max", "max", query, options);
2324
- });
2325
- }
2326
- };
2674
+ async function getNextSequence(counterKey, increment = 1, connection) {
2675
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
2676
+ upsert: true,
2677
+ returnDocument: "after"
2678
+ });
2679
+ if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
2680
+ return result.seq;
2327
2681
  }
2328
-
2329
- //#endregion
2330
- //#region src/plugins/subdocument.plugin.ts
2331
2682
  /**
2332
- * Subdocument plugin for managing nested arrays
2333
- *
2683
+ * Generator: Simple sequential counter.
2684
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
2685
+ *
2686
+ * Uses atomic MongoDB counters — safe under concurrency.
2687
+ *
2334
2688
  * @example
2335
- * const repo = new Repository(Model, [
2336
- * methodRegistryPlugin(),
2337
- * subdocumentPlugin(),
2338
- * ]);
2339
- *
2340
- * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
2341
- * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
2342
- */
2343
- function subdocumentPlugin() {
2344
- return {
2345
- name: "subdocument",
2689
+ * ```typescript
2690
+ * customIdPlugin({
2691
+ * field: 'invoiceNumber',
2692
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2693
+ * })
2694
+ * ```
2695
+ */
2696
+ function sequentialId(options) {
2697
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
2698
+ const key = counterKey || model.modelName;
2699
+ return async (context) => {
2700
+ const seq = await getNextSequence(key, 1, context._counterConnection);
2701
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
2702
+ };
2703
+ }
2704
+ /**
2705
+ * Generator: Date-partitioned sequential counter.
2706
+ * Counter resets per period — great for invoice/bill numbering.
2707
+ *
2708
+ * Produces IDs like:
2709
+ * - yearly: `BILL-2026-0001`
2710
+ * - monthly: `BILL-2026-02-0001`
2711
+ * - daily: `BILL-2026-02-20-0001`
2712
+ *
2713
+ * @example
2714
+ * ```typescript
2715
+ * customIdPlugin({
2716
+ * field: 'billNumber',
2717
+ * generator: dateSequentialId({
2718
+ * prefix: 'BILL',
2719
+ * model: BillModel,
2720
+ * partition: 'monthly',
2721
+ * }),
2722
+ * })
2723
+ * ```
2724
+ */
2725
+ function dateSequentialId(options) {
2726
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
2727
+ return async (context) => {
2728
+ const now = /* @__PURE__ */ new Date();
2729
+ const year = String(now.getFullYear());
2730
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2731
+ const day = String(now.getDate()).padStart(2, "0");
2732
+ let datePart;
2733
+ let counterKey;
2734
+ switch (partition) {
2735
+ case "yearly":
2736
+ datePart = year;
2737
+ counterKey = `${model.modelName}:${year}`;
2738
+ break;
2739
+ case "daily":
2740
+ datePart = `${year}${separator}${month}${separator}${day}`;
2741
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
2742
+ break;
2743
+ default:
2744
+ datePart = `${year}${separator}${month}`;
2745
+ counterKey = `${model.modelName}:${year}-${month}`;
2746
+ break;
2747
+ }
2748
+ const seq = await getNextSequence(counterKey, 1, context._counterConnection);
2749
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
2750
+ };
2751
+ }
2752
+ /**
2753
+ * Generator: Prefix + random alphanumeric suffix.
2754
+ * Does NOT require a database round-trip — purely in-memory.
2755
+ *
2756
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
2757
+ *
2758
+ * Good for: user-facing IDs where ordering doesn't matter.
2759
+ * Not suitable for sequential numbering.
2760
+ *
2761
+ * @example
2762
+ * ```typescript
2763
+ * customIdPlugin({
2764
+ * field: 'publicId',
2765
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
2766
+ * })
2767
+ * ```
2768
+ */
2769
+ function prefixedId(options) {
2770
+ const { prefix, separator = "_", length = 12 } = options;
2771
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
2772
+ return (_context) => {
2773
+ let result = "";
2774
+ const bytes = new Uint8Array(length);
2775
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
2776
+ globalThis.crypto.getRandomValues(bytes);
2777
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
2778
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
2779
+ return `${prefix}${separator}${result}`;
2780
+ };
2781
+ }
2782
+ /**
2783
+ * Custom ID plugin — injects generated IDs into documents before creation.
2784
+ *
2785
+ * @param options - Configuration for ID generation
2786
+ * @returns Plugin instance
2787
+ *
2788
+ * @example
2789
+ * ```typescript
2790
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
2791
+ *
2792
+ * const invoiceRepo = new Repository(InvoiceModel, [
2793
+ * customIdPlugin({
2794
+ * field: 'invoiceNumber',
2795
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2796
+ * }),
2797
+ * ]);
2798
+ *
2799
+ * const inv = await invoiceRepo.create({ amount: 100 });
2800
+ * console.log(inv.invoiceNumber); // "INV-0001"
2801
+ * ```
2802
+ */
2803
+ function customIdPlugin(options) {
2804
+ const fieldName = options.field || "customId";
2805
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
2806
+ return {
2807
+ name: "custom-id",
2346
2808
  apply(repo) {
2347
- if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
2348
- /**
2349
- * Add subdocument to array
2350
- */
2351
- repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
2352
- return this.update.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
2809
+ const repoConnection = repo.Model.db;
2810
+ repo.on("before:create", async (context) => {
2811
+ if (!context.data) return;
2812
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
2813
+ context._counterConnection = repoConnection;
2814
+ context.data[fieldName] = await options.generator(context);
2353
2815
  });
2354
- /**
2355
- * Get subdocument from array
2356
- */
2357
- repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
2358
- return this._executeQuery.call(this, async (Model) => {
2359
- const parent = await Model.findById(parentId).session(options.session).exec();
2360
- if (!parent) throw createError(404, "Parent not found");
2361
- const arrayField = parent[arrayPath];
2362
- if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
2363
- const sub = arrayField.id(subId);
2364
- if (!sub) throw createError(404, "Subdocument not found");
2365
- return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
2816
+ repo.on("before:createMany", async (context) => {
2817
+ if (!context.dataArray) return;
2818
+ context._counterConnection = repoConnection;
2819
+ const docsNeedingIds = [];
2820
+ for (const doc of context.dataArray) {
2821
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
2822
+ docsNeedingIds.push(doc);
2823
+ }
2824
+ if (docsNeedingIds.length === 0) return;
2825
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
2826
+ ...context,
2827
+ data: doc
2366
2828
  });
2367
2829
  });
2368
- /**
2369
- * Update subdocument in array
2370
- */
2371
- repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
2372
- return this._executeQuery.call(this, async (Model) => {
2373
- const query = {
2374
- _id: parentId,
2375
- [`${arrayPath}._id`]: subId
2376
- };
2377
- const update = { $set: { [`${arrayPath}.$`]: {
2378
- ...updateData,
2379
- _id: subId
2380
- } } };
2381
- const result = await Model.findOneAndUpdate(query, update, {
2382
- returnDocument: "after",
2383
- runValidators: true,
2384
- session: options.session
2385
- }).exec();
2386
- if (!result) throw createError(404, "Parent or subdocument not found");
2387
- return result;
2830
+ }
2831
+ };
2832
+ }
2833
+ //#endregion
2834
+ //#region src/plugins/elastic.plugin.ts
2835
+ function elasticSearchPlugin(options) {
2836
+ return {
2837
+ name: "elastic-search",
2838
+ apply(repo) {
2839
+ if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
2840
+ repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
2841
+ const { client, index, idField = "_id" } = options;
2842
+ const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
2843
+ const from = Math.max(searchOptions.from || 0, 0);
2844
+ const esResponse = await client.search({
2845
+ index,
2846
+ body: {
2847
+ query: searchQuery,
2848
+ size: limit,
2849
+ from
2850
+ }
2388
2851
  });
2389
- });
2390
- /**
2391
- * Delete subdocument from array
2392
- */
2393
- repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
2394
- return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
2852
+ const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
2853
+ if (hits.length === 0) return {
2854
+ docs: [],
2855
+ total: 0,
2856
+ limit,
2857
+ from
2858
+ };
2859
+ const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
2860
+ const total = typeof totalValue === "number" ? totalValue : 0;
2861
+ const docsOrder = /* @__PURE__ */ new Map();
2862
+ const scores = /* @__PURE__ */ new Map();
2863
+ const ids = [];
2864
+ hits.forEach((hit, idx) => {
2865
+ const docId = hit._source?.[idField] || hit[idField] || hit._id;
2866
+ if (docId) {
2867
+ const strId = String(docId);
2868
+ docsOrder.set(strId, idx);
2869
+ if (hit._score !== void 0) scores.set(strId, hit._score);
2870
+ ids.push(strId);
2871
+ }
2872
+ });
2873
+ if (ids.length === 0) return {
2874
+ docs: [],
2875
+ total,
2876
+ limit,
2877
+ from
2878
+ };
2879
+ const mongoQuery = this.Model.find({ _id: { $in: ids } });
2880
+ if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
2881
+ if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
2882
+ if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
2883
+ return {
2884
+ docs: (await mongoQuery.exec()).sort((a, b) => {
2885
+ const aId = String(a._id);
2886
+ const bId = String(b._id);
2887
+ return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
2888
+ }).map((doc) => {
2889
+ const strId = String(doc._id);
2890
+ if (searchOptions.mongoOptions?.lean !== false) return {
2891
+ ...doc,
2892
+ _score: scores.get(strId)
2893
+ };
2894
+ return doc;
2895
+ }),
2896
+ total,
2897
+ limit,
2898
+ from
2899
+ };
2395
2900
  });
2396
2901
  }
2397
2902
  };
2398
2903
  }
2399
-
2400
2904
  //#endregion
2401
- //#region src/plugins/cache.plugin.ts
2905
+ //#region src/plugins/field-filter.plugin.ts
2402
2906
  /**
2403
- * Cache plugin factory
2907
+ * Field filter plugin that restricts fields based on user context
2404
2908
  *
2405
- * @param options - Cache configuration
2406
- * @returns Plugin instance
2909
+ * @example
2910
+ * const fieldPreset = {
2911
+ * public: ['id', 'name'],
2912
+ * authenticated: ['email'],
2913
+ * admin: ['createdAt', 'internalNotes']
2914
+ * };
2915
+ *
2916
+ * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
2407
2917
  */
2408
- function cachePlugin(options) {
2409
- const config = {
2410
- adapter: options.adapter,
2411
- ttl: options.ttl ?? 60,
2412
- byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
2413
- queryTtl: options.queryTtl ?? options.ttl ?? 60,
2414
- prefix: options.prefix ?? "mk",
2415
- debug: options.debug ?? false,
2416
- skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
2417
- };
2418
- const stats = {
2419
- hits: 0,
2420
- misses: 0,
2421
- sets: 0,
2422
- invalidations: 0,
2423
- errors: 0
2424
- };
2425
- const log = (msg, data) => {
2426
- if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
2918
+ function fieldFilterPlugin(fieldPreset) {
2919
+ return {
2920
+ name: "fieldFilter",
2921
+ apply(repo) {
2922
+ const applyFieldFiltering = (context) => {
2923
+ if (!fieldPreset) return;
2924
+ const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
2925
+ if (context.select) context.select = `${presetSelect} ${context.select}`;
2926
+ else context.select = presetSelect;
2927
+ };
2928
+ repo.on("before:getAll", applyFieldFiltering);
2929
+ repo.on("before:getById", applyFieldFiltering);
2930
+ repo.on("before:getByQuery", applyFieldFiltering);
2931
+ }
2427
2932
  };
2933
+ }
2934
+ //#endregion
2935
+ //#region src/plugins/method-registry.plugin.ts
2936
+ /**
2937
+ * Method registry plugin that enables dynamic method registration
2938
+ */
2939
+ function methodRegistryPlugin() {
2428
2940
  return {
2429
- name: "cache",
2941
+ name: "method-registry",
2430
2942
  apply(repo) {
2431
- const model = repo.model;
2432
- const byIdKeyRegistry = /* @__PURE__ */ new Map();
2433
- function trackByIdKey(docId, cacheKey) {
2434
- let keys = byIdKeyRegistry.get(docId);
2435
- if (!keys) {
2436
- keys = /* @__PURE__ */ new Set();
2437
- byIdKeyRegistry.set(docId, keys);
2438
- }
2439
- keys.add(cacheKey);
2440
- }
2441
- async function getVersion() {
2442
- try {
2443
- return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
2444
- } catch (e) {
2445
- log(`Cache error in getVersion for ${model}:`, e);
2446
- return 0;
2447
- }
2448
- }
2943
+ const registeredMethods = [];
2449
2944
  /**
2450
- * Bump collection version in the adapter (invalidates all list caches).
2451
- * Uses Date.now() so version always moves forward — safe after eviction or deploy.
2945
+ * Register a new method on the repository instance
2452
2946
  */
2453
- async function bumpVersion() {
2454
- const newVersion = Date.now();
2455
- try {
2456
- await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
2457
- stats.invalidations++;
2458
- log(`Bumped version for ${model} to:`, newVersion);
2459
- } catch (e) {
2460
- log(`Failed to bump version for ${model}:`, e);
2461
- }
2462
- }
2947
+ repo.registerMethod = (name, fn) => {
2948
+ 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.`);
2949
+ if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
2950
+ if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
2951
+ repo[name] = fn.bind(repo);
2952
+ registeredMethods.push(name);
2953
+ repo.emit("method:registered", {
2954
+ name,
2955
+ fn
2956
+ });
2957
+ };
2463
2958
  /**
2464
- * Invalidate a specific document by ID (all shape variants).
2465
- * Deletes every tracked shape-variant key individually via del(),
2466
- * so adapters without pattern-based clear() still get full invalidation.
2959
+ * Check if a method is registered
2467
2960
  */
2468
- async function invalidateById(id) {
2469
- try {
2470
- const baseKey = byIdKey(config.prefix, model, id);
2471
- await config.adapter.del(baseKey);
2472
- const trackedKeys = byIdKeyRegistry.get(id);
2473
- if (trackedKeys) {
2474
- for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
2475
- byIdKeyRegistry.delete(id);
2476
- }
2477
- stats.invalidations++;
2478
- log(`Invalidated byId cache for:`, id);
2479
- } catch (e) {
2480
- log(`Failed to invalidate byId cache:`, e);
2481
- }
2482
- }
2961
+ repo.hasMethod = (name) => typeof repo[name] === "function";
2483
2962
  /**
2484
- * before:getById - Check cache for document
2485
- * Runs at CACHE priority (200) — after policy hooks inject filters
2963
+ * Get list of all dynamically registered methods
2486
2964
  */
2487
- repo.on("before:getById", async (context) => {
2488
- if (context.skipCache) {
2489
- log(`Skipping cache for getById: ${context.id}`);
2490
- return;
2491
- }
2492
- const id = String(context.id);
2493
- const key = byIdKey(config.prefix, model, id, {
2494
- select: context.select,
2495
- populate: context.populate,
2496
- lean: context.lean
2497
- });
2498
- try {
2499
- const cached = await config.adapter.get(key);
2500
- if (cached !== null) {
2501
- stats.hits++;
2502
- log(`Cache HIT for getById:`, key);
2503
- context._cacheHit = true;
2504
- context._cachedResult = cached;
2505
- } else {
2506
- stats.misses++;
2507
- log(`Cache MISS for getById:`, key);
2508
- }
2509
- } catch (e) {
2510
- log(`Cache error for getById:`, e);
2511
- stats.errors++;
2512
- }
2513
- }, { priority: HOOK_PRIORITY.CACHE });
2965
+ repo.getRegisteredMethods = () => [...registeredMethods];
2966
+ }
2967
+ };
2968
+ }
2969
+ //#endregion
2970
+ //#region src/plugins/mongo-operations.plugin.ts
2971
+ /**
2972
+ * MongoDB Operations Plugin
2973
+ *
2974
+ * Adds MongoDB-specific operations to repositories.
2975
+ * Requires method-registry.plugin.js to be loaded first.
2976
+ */
2977
+ /**
2978
+ * MongoDB operations plugin
2979
+ *
2980
+ * Adds MongoDB-specific atomic operations to repositories:
2981
+ * - upsert: Create or update document
2982
+ * - increment/decrement: Atomic numeric operations
2983
+ * - pushToArray/pullFromArray/addToSet: Array operations
2984
+ * - setField/unsetField/renameField: Field operations
2985
+ * - multiplyField: Multiply numeric field
2986
+ * - setMin/setMax: Conditional min/max updates
2987
+ *
2988
+ * @example Basic usage (no TypeScript autocomplete)
2989
+ * ```typescript
2990
+ * const repo = new Repository(ProductModel, [
2991
+ * methodRegistryPlugin(),
2992
+ * mongoOperationsPlugin(),
2993
+ * ]);
2994
+ *
2995
+ * // Works at runtime but TypeScript doesn't know about these methods
2996
+ * await (repo as any).increment(productId, 'views', 1);
2997
+ * await (repo as any).pushToArray(productId, 'tags', 'featured');
2998
+ * ```
2999
+ *
3000
+ * @example With TypeScript type safety (recommended)
3001
+ * ```typescript
3002
+ * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
3003
+ * import type { MongoOperationsMethods } from '@classytic/mongokit';
3004
+ *
3005
+ * class ProductRepo extends Repository<IProduct> {
3006
+ * // Add your custom methods here
3007
+ * }
3008
+ *
3009
+ * // Create with type assertion to get autocomplete for plugin methods
3010
+ * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
3011
+ *
3012
+ * const repo = new ProductRepo(ProductModel, [
3013
+ * methodRegistryPlugin(),
3014
+ * mongoOperationsPlugin(),
3015
+ * ]) as ProductRepoWithPlugins;
3016
+ *
3017
+ * // Now TypeScript provides autocomplete and type checking!
3018
+ * await repo.increment(productId, 'views', 1);
3019
+ * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
3020
+ * await repo.pushToArray(productId, 'tags', 'featured');
3021
+ * ```
3022
+ */
3023
+ function mongoOperationsPlugin() {
3024
+ return {
3025
+ name: "mongo-operations",
3026
+ apply(repo) {
3027
+ if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
2514
3028
  /**
2515
- * before:getByQuery - Check cache for single-doc query
2516
- * Runs at CACHE priority (200) — after policy hooks inject filters
3029
+ * Update existing document or insert new one
2517
3030
  */
2518
- repo.on("before:getByQuery", async (context) => {
2519
- if (context.skipCache) {
2520
- log(`Skipping cache for getByQuery`);
2521
- return;
2522
- }
2523
- const collectionVersion = await getVersion();
2524
- const query = context.query || {};
2525
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
2526
- select: context.select,
2527
- populate: context.populate
2528
- });
2529
- try {
2530
- const cached = await config.adapter.get(key);
2531
- if (cached !== null) {
2532
- stats.hits++;
2533
- log(`Cache HIT for getByQuery:`, key);
2534
- context._cacheHit = true;
2535
- context._cachedResult = cached;
2536
- } else {
2537
- stats.misses++;
2538
- log(`Cache MISS for getByQuery:`, key);
2539
- }
2540
- } catch (e) {
2541
- log(`Cache error for getByQuery:`, e);
2542
- stats.errors++;
2543
- }
2544
- }, { priority: HOOK_PRIORITY.CACHE });
3031
+ repo.registerMethod("upsert", async function(query, data, options = {}) {
3032
+ return upsert(this.Model, query, data, options);
3033
+ });
3034
+ const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
3035
+ if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
3036
+ return this.update(id, { [operator]: { [field]: value } }, options);
3037
+ };
2545
3038
  /**
2546
- * before:getAll - Check cache for list query
2547
- * Runs at CACHE priority (200) — after policy hooks inject filters
3039
+ * Atomically increment numeric field
2548
3040
  */
2549
- repo.on("before:getAll", async (context) => {
2550
- if (context.skipCache) {
2551
- log(`Skipping cache for getAll`);
2552
- return;
2553
- }
2554
- const limit = context.limit;
2555
- if (limit && limit > config.skipIfLargeLimit) {
2556
- log(`Skipping cache for large query (limit: ${limit})`);
2557
- return;
2558
- }
2559
- const collectionVersion = await getVersion();
2560
- const params = {
2561
- filters: context.filters,
2562
- sort: context.sort,
2563
- page: context.page,
2564
- limit,
2565
- after: context.after,
2566
- select: context.select,
2567
- populate: context.populate,
2568
- search: context.search,
2569
- mode: context.mode,
2570
- lean: context.lean,
2571
- readPreference: context.readPreference,
2572
- hint: context.hint,
2573
- maxTimeMS: context.maxTimeMS,
2574
- countStrategy: context.countStrategy
2575
- };
2576
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
2577
- try {
2578
- const cached = await config.adapter.get(key);
2579
- if (cached !== null) {
2580
- stats.hits++;
2581
- log(`Cache HIT for getAll:`, key);
2582
- context._cacheHit = true;
2583
- context._cachedResult = cached;
2584
- } else {
2585
- stats.misses++;
2586
- log(`Cache MISS for getAll:`, key);
2587
- }
2588
- } catch (e) {
2589
- log(`Cache error for getAll:`, e);
2590
- stats.errors++;
2591
- }
2592
- }, { priority: HOOK_PRIORITY.CACHE });
3041
+ repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
3042
+ return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
3043
+ });
2593
3044
  /**
2594
- * after:getById - Cache the result
3045
+ * Atomically decrement numeric field
2595
3046
  */
2596
- repo.on("after:getById", async (payload) => {
2597
- const { context, result } = payload;
2598
- if (context._cacheHit) return;
2599
- if (context.skipCache) return;
2600
- if (result === null) return;
2601
- const id = String(context.id);
2602
- const key = byIdKey(config.prefix, model, id, {
2603
- select: context.select,
2604
- populate: context.populate,
2605
- lean: context.lean
2606
- });
2607
- const ttl = context.cacheTtl ?? config.byIdTtl;
2608
- try {
2609
- await config.adapter.set(key, result, ttl);
2610
- trackByIdKey(id, key);
2611
- stats.sets++;
2612
- log(`Cached getById result:`, key);
2613
- } catch (e) {
2614
- log(`Failed to cache getById:`, e);
2615
- }
3047
+ repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
3048
+ return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
2616
3049
  });
3050
+ const applyOperator = function(id, field, value, operator, options) {
3051
+ return this.update(id, { [operator]: { [field]: value } }, options);
3052
+ };
2617
3053
  /**
2618
- * after:getByQuery - Cache the result
3054
+ * Push value to array field
2619
3055
  */
2620
- repo.on("after:getByQuery", async (payload) => {
2621
- const { context, result } = payload;
2622
- if (context._cacheHit) return;
2623
- if (context.skipCache) return;
2624
- if (result === null) return;
2625
- const collectionVersion = await getVersion();
2626
- const query = context.query || {};
2627
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
2628
- select: context.select,
2629
- populate: context.populate
2630
- });
2631
- const ttl = context.cacheTtl ?? config.queryTtl;
2632
- try {
2633
- await config.adapter.set(key, result, ttl);
2634
- stats.sets++;
2635
- log(`Cached getByQuery result:`, key);
2636
- } catch (e) {
2637
- log(`Failed to cache getByQuery:`, e);
2638
- }
3056
+ repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
3057
+ return applyOperator.call(this, id, field, value, "$push", options);
2639
3058
  });
2640
3059
  /**
2641
- * after:getAll - Cache the result
3060
+ * Remove value from array field
2642
3061
  */
2643
- repo.on("after:getAll", async (payload) => {
2644
- const { context, result } = payload;
2645
- if (context._cacheHit) return;
2646
- if (context.skipCache) return;
2647
- const limit = context.limit;
2648
- if (limit && limit > config.skipIfLargeLimit) return;
2649
- const collectionVersion = await getVersion();
2650
- const params = {
2651
- filters: context.filters,
2652
- sort: context.sort,
2653
- page: context.page,
2654
- limit,
2655
- after: context.after,
2656
- select: context.select,
2657
- populate: context.populate,
2658
- search: context.search,
2659
- mode: context.mode,
2660
- lean: context.lean,
2661
- readPreference: context.readPreference,
2662
- hint: context.hint,
2663
- maxTimeMS: context.maxTimeMS,
2664
- countStrategy: context.countStrategy
2665
- };
2666
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
2667
- const ttl = context.cacheTtl ?? config.queryTtl;
2668
- try {
2669
- await config.adapter.set(key, result, ttl);
2670
- stats.sets++;
2671
- log(`Cached getAll result:`, key);
2672
- } catch (e) {
2673
- log(`Failed to cache getAll:`, e);
2674
- }
3062
+ repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
3063
+ return applyOperator.call(this, id, field, value, "$pull", options);
2675
3064
  });
2676
3065
  /**
2677
- * after:create - Bump version to invalidate list caches
3066
+ * Add value to array only if not already present (unique)
2678
3067
  */
2679
- repo.on("after:create", async () => {
2680
- await bumpVersion();
3068
+ repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
3069
+ return applyOperator.call(this, id, field, value, "$addToSet", options);
2681
3070
  });
2682
3071
  /**
2683
- * after:createMany - Bump version to invalidate list caches
3072
+ * Set field value (alias for update with $set)
2684
3073
  */
2685
- repo.on("after:createMany", async () => {
2686
- await bumpVersion();
3074
+ repo.registerMethod("setField", async function(id, field, value, options = {}) {
3075
+ return applyOperator.call(this, id, field, value, "$set", options);
2687
3076
  });
2688
3077
  /**
2689
- * after:update - Invalidate by ID and bump version
3078
+ * Unset (remove) field from document
2690
3079
  */
2691
- repo.on("after:update", async (payload) => {
2692
- const { context } = payload;
2693
- const id = String(context.id);
2694
- await Promise.all([invalidateById(id), bumpVersion()]);
3080
+ repo.registerMethod("unsetField", async function(id, fields, options = {}) {
3081
+ const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
3082
+ acc[field] = "";
3083
+ return acc;
3084
+ }, {});
3085
+ return this.update(id, { $unset: unsetObj }, options);
2695
3086
  });
2696
3087
  /**
2697
- * after:updateMany - Bump version (can't track individual IDs efficiently)
3088
+ * Rename field in document
2698
3089
  */
2699
- repo.on("after:updateMany", async () => {
2700
- await bumpVersion();
3090
+ repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
3091
+ return this.update(id, { $rename: { [oldName]: newName } }, options);
2701
3092
  });
2702
3093
  /**
2703
- * after:delete - Invalidate by ID and bump version
3094
+ * Multiply numeric field by value
2704
3095
  */
2705
- repo.on("after:delete", async (payload) => {
2706
- const { context } = payload;
2707
- const id = String(context.id);
2708
- await Promise.all([invalidateById(id), bumpVersion()]);
3096
+ repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
3097
+ return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
2709
3098
  });
2710
3099
  /**
2711
- * after:deleteMany - Bump version
3100
+ * Set field to minimum value (only if current value is greater)
2712
3101
  */
2713
- repo.on("after:deleteMany", async () => {
2714
- await bumpVersion();
3102
+ repo.registerMethod("setMin", async function(id, field, value, options = {}) {
3103
+ return applyOperator.call(this, id, field, value, "$min", options);
2715
3104
  });
2716
3105
  /**
2717
- * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
3106
+ * Set field to maximum value (only if current value is less)
2718
3107
  */
2719
- repo.on("after:bulkWrite", async () => {
2720
- await bumpVersion();
3108
+ repo.registerMethod("setMax", async function(id, field, value, options = {}) {
3109
+ return applyOperator.call(this, id, field, value, "$max", options);
2721
3110
  });
2722
3111
  /**
2723
- * Invalidate cache for a specific document
2724
- * Use when document was updated outside this service
3112
+ * Atomic update with multiple MongoDB operators in a single call
2725
3113
  *
2726
- * @example
2727
- * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
2728
- */
2729
- repo.invalidateCache = async (id) => {
2730
- await invalidateById(id);
2731
- log(`Manual invalidation for ID:`, id);
2732
- };
2733
- /**
2734
- * Invalidate all list caches for this model
2735
- * Use when bulk changes happened outside this service
3114
+ * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
3115
+ * into one atomic database operation.
2736
3116
  *
2737
3117
  * @example
2738
- * await userRepo.invalidateListCache();
2739
- */
2740
- repo.invalidateListCache = async () => {
2741
- await bumpVersion();
2742
- log(`Manual list cache invalidation for ${model}`);
2743
- };
2744
- /**
2745
- * Invalidate ALL cache entries for this model
2746
- * Nuclear option - use sparingly
3118
+ * // Combine $inc + $set in one atomic call
3119
+ * await repo.atomicUpdate(id, {
3120
+ * $inc: { views: 1, commentCount: 1 },
3121
+ * $set: { lastActiveAt: new Date() }
3122
+ * });
2747
3123
  *
2748
- * @example
2749
- * await userRepo.invalidateAllCache();
2750
- */
2751
- repo.invalidateAllCache = async () => {
2752
- if (config.adapter.clear) try {
2753
- await config.adapter.clear(modelPattern(config.prefix, model));
2754
- stats.invalidations++;
2755
- log(`Full cache invalidation for ${model}`);
2756
- } catch (e) {
2757
- log(`Failed full cache invalidation for ${model}:`, e);
2758
- }
2759
- else {
2760
- await bumpVersion();
2761
- log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
2762
- }
2763
- };
2764
- /**
2765
- * Get cache statistics for monitoring
3124
+ * // Multiple operators: $inc + $set + $push
3125
+ * await repo.atomicUpdate(id, {
3126
+ * $inc: { 'metrics.total': 1 },
3127
+ * $set: { updatedAt: new Date() },
3128
+ * $push: { history: { action: 'update', at: new Date() } }
3129
+ * });
2766
3130
  *
2767
- * @example
2768
- * const stats = userRepo.getCacheStats();
2769
- * console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
2770
- */
2771
- repo.getCacheStats = () => ({ ...stats });
2772
- /**
2773
- * Reset cache statistics
3131
+ * // $push with $each modifier
3132
+ * await repo.atomicUpdate(id, {
3133
+ * $push: { tags: { $each: ['featured', 'popular'] } },
3134
+ * $inc: { tagCount: 2 }
3135
+ * });
3136
+ *
3137
+ * // With arrayFilters for positional updates
3138
+ * await repo.atomicUpdate(id, {
3139
+ * $set: { 'items.$[elem].quantity': 5 }
3140
+ * }, { arrayFilters: [{ 'elem._id': itemId }] });
2774
3141
  */
2775
- repo.resetCacheStats = () => {
2776
- stats.hits = 0;
2777
- stats.misses = 0;
2778
- stats.sets = 0;
2779
- stats.invalidations = 0;
2780
- stats.errors = 0;
2781
- };
2782
- }
2783
- };
2784
- }
2785
-
2786
- //#endregion
2787
- //#region src/plugins/cascade.plugin.ts
2788
- /**
2789
- * Cascade Delete Plugin
2790
- * Automatically deletes related documents when a parent document is deleted
2791
- *
2792
- * @example
2793
- * ```typescript
2794
- * import mongoose from 'mongoose';
2795
- * import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
2796
- *
2797
- * const productRepo = new Repository(Product, [
2798
- * methodRegistryPlugin(),
2799
- * cascadePlugin({
2800
- * relations: [
2801
- * { model: 'StockEntry', foreignKey: 'product' },
2802
- * { model: 'StockMovement', foreignKey: 'product' },
2803
- * ]
2804
- * })
2805
- * ]);
2806
- *
2807
- * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
2808
- * await productRepo.delete(productId);
2809
- * ```
2810
- */
2811
- /**
2812
- * Cascade delete plugin
2813
- *
2814
- * Deletes related documents after the parent document is deleted.
2815
- * Works with both hard delete and soft delete scenarios.
2816
- *
2817
- * @param options - Cascade configuration
2818
- * @returns Plugin
2819
- */
2820
- function cascadePlugin(options) {
2821
- const { relations, parallel = true, logger } = options;
2822
- if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
2823
- return {
2824
- name: "cascade",
2825
- apply(repo) {
2826
- repo.on("after:delete", async (payload) => {
2827
- const { context } = payload;
2828
- const deletedId = context.id;
2829
- if (!deletedId) {
2830
- logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
2831
- return;
2832
- }
2833
- const isSoftDelete = context.softDeleted === true;
2834
- const cascadeDelete = async (relation) => {
2835
- const RelatedModel = mongoose.models[relation.model];
2836
- if (!RelatedModel) {
2837
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2838
- parentModel: context.model,
2839
- parentId: String(deletedId)
2840
- });
2841
- return;
2842
- }
2843
- const query = { [relation.foreignKey]: deletedId };
2844
- try {
2845
- if (relation.softDelete ?? isSoftDelete) {
2846
- const updateResult = await RelatedModel.updateMany(query, {
2847
- deletedAt: /* @__PURE__ */ new Date(),
2848
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2849
- }, { session: context.session });
2850
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2851
- parentModel: context.model,
2852
- parentId: String(deletedId),
2853
- relatedModel: relation.model,
2854
- foreignKey: relation.foreignKey,
2855
- count: updateResult.modifiedCount
2856
- });
2857
- } else {
2858
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2859
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2860
- parentModel: context.model,
2861
- parentId: String(deletedId),
2862
- relatedModel: relation.model,
2863
- foreignKey: relation.foreignKey,
2864
- count: deleteResult.deletedCount
2865
- });
2866
- }
2867
- } catch (error) {
2868
- logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2869
- parentModel: context.model,
2870
- parentId: String(deletedId),
2871
- relatedModel: relation.model,
2872
- foreignKey: relation.foreignKey,
2873
- error: error.message
2874
- });
2875
- throw error;
2876
- }
2877
- };
2878
- if (parallel) {
2879
- const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
2880
- if (failures.length) {
2881
- const err = failures[0].reason;
2882
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2883
- throw err;
2884
- }
2885
- } else for (const relation of relations) await cascadeDelete(relation);
2886
- });
2887
- repo.on("before:deleteMany", async (context) => {
2888
- const query = context.query;
2889
- if (!query || Object.keys(query).length === 0) return;
2890
- context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
2891
- });
2892
- repo.on("after:deleteMany", async (payload) => {
2893
- const { context } = payload;
2894
- const ids = context._cascadeIds;
2895
- if (!ids || ids.length === 0) return;
2896
- const isSoftDelete = context.softDeleted === true;
2897
- const cascadeDeleteMany = async (relation) => {
2898
- const RelatedModel = mongoose.models[relation.model];
2899
- if (!RelatedModel) {
2900
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
2901
- return;
2902
- }
2903
- const query = { [relation.foreignKey]: { $in: ids } };
2904
- const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2905
- try {
2906
- if (shouldSoftDelete) {
2907
- const updateResult = await RelatedModel.updateMany(query, {
2908
- deletedAt: /* @__PURE__ */ new Date(),
2909
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2910
- }, { session: context.session });
2911
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2912
- parentModel: context.model,
2913
- parentCount: ids.length,
2914
- relatedModel: relation.model,
2915
- foreignKey: relation.foreignKey,
2916
- count: updateResult.modifiedCount
2917
- });
2918
- } else {
2919
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2920
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2921
- parentModel: context.model,
2922
- parentCount: ids.length,
2923
- relatedModel: relation.model,
2924
- foreignKey: relation.foreignKey,
2925
- count: deleteResult.deletedCount
2926
- });
2927
- }
2928
- } catch (error) {
2929
- logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2930
- parentModel: context.model,
2931
- relatedModel: relation.model,
2932
- foreignKey: relation.foreignKey,
2933
- error: error.message
2934
- });
2935
- throw error;
2936
- }
2937
- };
2938
- if (parallel) {
2939
- const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
2940
- if (failures.length) {
2941
- const err = failures[0].reason;
2942
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2943
- throw err;
2944
- }
2945
- } else for (const relation of relations) await cascadeDeleteMany(relation);
3142
+ repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
3143
+ const validOperators = new Set([
3144
+ "$inc",
3145
+ "$set",
3146
+ "$unset",
3147
+ "$push",
3148
+ "$pull",
3149
+ "$addToSet",
3150
+ "$pop",
3151
+ "$rename",
3152
+ "$min",
3153
+ "$max",
3154
+ "$mul",
3155
+ "$setOnInsert",
3156
+ "$bit",
3157
+ "$currentDate"
3158
+ ]);
3159
+ const keys = Object.keys(operators);
3160
+ if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
3161
+ for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
3162
+ return this.update(id, operators, options);
2946
3163
  });
2947
3164
  }
2948
3165
  };
2949
3166
  }
2950
-
2951
3167
  //#endregion
2952
3168
  //#region src/plugins/multi-tenant.plugin.ts
2953
3169
  /**
@@ -3086,7 +3302,6 @@ function multiTenantPlugin(options = {}) {
3086
3302
  }
3087
3303
  };
3088
3304
  }
3089
-
3090
3305
  //#endregion
3091
3306
  //#region src/plugins/observability.plugin.ts
3092
3307
  const DEFAULT_OPS = [
@@ -3147,691 +3362,538 @@ function observabilityPlugin(options) {
3147
3362
  }
3148
3363
  };
3149
3364
  }
3150
-
3151
3365
  //#endregion
3152
- //#region src/plugins/audit-trail.plugin.ts
3366
+ //#region src/plugins/soft-delete.plugin.ts
3153
3367
  /**
3154
- * Audit Trail Plugin
3368
+ * Build filter condition based on filter mode
3369
+ */
3370
+ function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
3371
+ if (includeDeleted) return {};
3372
+ if (filterMode === "exists") return { [deletedField]: { $exists: false } };
3373
+ return { [deletedField]: null };
3374
+ }
3375
+ /**
3376
+ * Build filter condition for finding deleted documents
3377
+ */
3378
+ function buildGetDeletedFilter(deletedField, filterMode) {
3379
+ if (filterMode === "exists") return { [deletedField]: {
3380
+ $exists: true,
3381
+ $ne: null
3382
+ } };
3383
+ return { [deletedField]: { $ne: null } };
3384
+ }
3385
+ /**
3386
+ * Soft delete plugin
3155
3387
  *
3156
- * Persists operation audit entries to a MongoDB collection.
3157
- * Fire-and-forget: writes happen async and never block or fail the main operation.
3388
+ * @example Basic usage
3389
+ * ```typescript
3390
+ * const repo = new Repository(Model, [
3391
+ * softDeletePlugin({ deletedField: 'deletedAt' })
3392
+ * ]);
3158
3393
  *
3159
- * Features:
3160
- * - Tracks create, update, delete operations
3161
- * - Field-level change tracking (before/after diff on updates)
3162
- * - TTL auto-cleanup via MongoDB TTL index
3163
- * - Custom metadata per entry (IP, user-agent, etc.)
3164
- * - Shared `audit_trails` collection across all models
3394
+ * // Delete (soft)
3395
+ * await repo.delete(id);
3165
3396
  *
3166
- * @example
3397
+ * // Restore
3398
+ * await repo.restore(id);
3399
+ *
3400
+ * // Get deleted documents
3401
+ * await repo.getDeleted({ page: 1, limit: 20 });
3402
+ * ```
3403
+ *
3404
+ * @example With null filter mode (for schemas with default: null)
3167
3405
  * ```typescript
3168
- * const repo = new Repository(Job, [
3169
- * auditTrailPlugin({
3170
- * operations: ['create', 'update', 'delete'],
3171
- * trackChanges: true,
3172
- * ttlDays: 90,
3173
- * metadata: (context) => ({
3174
- * ip: context.req?.ip,
3175
- * }),
3176
- * }),
3406
+ * // Schema: { deletedAt: { type: Date, default: null } }
3407
+ * const repo = new Repository(Model, [
3408
+ * softDeletePlugin({
3409
+ * deletedField: 'deletedAt',
3410
+ * filterMode: 'null', // default - works with default: null
3411
+ * })
3412
+ * ]);
3413
+ * ```
3414
+ *
3415
+ * @example With TTL for auto-cleanup
3416
+ * ```typescript
3417
+ * const repo = new Repository(Model, [
3418
+ * softDeletePlugin({
3419
+ * deletedField: 'deletedAt',
3420
+ * ttlDays: 30, // Auto-delete after 30 days
3421
+ * })
3177
3422
  * ]);
3178
3423
  * ```
3179
3424
  */
3180
- const modelCache = /* @__PURE__ */ new Map();
3181
- function getAuditModel(collectionName, ttlDays) {
3182
- const existing = modelCache.get(collectionName);
3183
- if (existing) return existing;
3184
- const schema = new mongoose.Schema({
3185
- model: {
3186
- type: String,
3187
- required: true,
3188
- index: true
3189
- },
3190
- operation: {
3191
- type: String,
3192
- required: true,
3193
- enum: [
3194
- "create",
3195
- "update",
3196
- "delete"
3197
- ]
3198
- },
3199
- documentId: {
3200
- type: mongoose.Schema.Types.Mixed,
3201
- required: true,
3202
- index: true
3203
- },
3204
- userId: {
3205
- type: mongoose.Schema.Types.Mixed,
3206
- index: true
3207
- },
3208
- orgId: {
3209
- type: mongoose.Schema.Types.Mixed,
3210
- index: true
3211
- },
3212
- changes: { type: mongoose.Schema.Types.Mixed },
3213
- document: { type: mongoose.Schema.Types.Mixed },
3214
- metadata: { type: mongoose.Schema.Types.Mixed },
3215
- timestamp: {
3216
- type: Date,
3217
- default: Date.now,
3218
- index: true
3219
- }
3220
- }, {
3221
- collection: collectionName,
3222
- versionKey: false
3223
- });
3224
- schema.index({
3225
- model: 1,
3226
- documentId: 1,
3227
- timestamp: -1
3228
- });
3229
- schema.index({
3230
- orgId: 1,
3231
- userId: 1,
3232
- timestamp: -1
3233
- });
3234
- if (ttlDays !== void 0 && ttlDays > 0) {
3235
- const ttlSeconds = ttlDays * 24 * 60 * 60;
3236
- schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
3237
- }
3238
- const modelName = `AuditTrail_${collectionName}`;
3239
- const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
3240
- modelCache.set(collectionName, model);
3241
- return model;
3242
- }
3243
- /** Compute field-level diff between previous and updated document */
3244
- function computeChanges(prev, next, excludeFields) {
3245
- const changes = {};
3246
- const exclude = new Set(excludeFields);
3247
- for (const key of Object.keys(next)) {
3248
- if (exclude.has(key)) continue;
3249
- if (key === "_id" || key === "__v" || key === "updatedAt") continue;
3250
- const prevVal = prev[key];
3251
- const nextVal = next[key];
3252
- if (!deepEqual(prevVal, nextVal)) changes[key] = {
3253
- from: prevVal,
3254
- to: nextVal
3255
- };
3256
- }
3257
- return Object.keys(changes).length > 0 ? changes : void 0;
3258
- }
3259
- /** Simple deep equality check for audit diffing */
3260
- function deepEqual(a, b) {
3261
- if (a === b) return true;
3262
- if (a == null && b == null) return true;
3263
- if (a == null || b == null) return false;
3264
- if (typeof a === "object" && typeof b === "object") {
3265
- const aStr = a.toString?.();
3266
- const bStr = b.toString?.();
3267
- if (aStr && bStr && aStr === bStr) return true;
3268
- }
3269
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
3270
- try {
3271
- return JSON.stringify(a) === JSON.stringify(b);
3272
- } catch {
3273
- return false;
3274
- }
3275
- }
3276
- /** Extract user ID from context */
3277
- function getUserId(context) {
3278
- return context.user?._id || context.user?.id;
3279
- }
3280
- /** Fire-and-forget: write audit entry, never throw */
3281
- function writeAudit(AuditModel, entry) {
3282
- Promise.resolve().then(() => {
3283
- AuditModel.create({
3284
- ...entry,
3285
- timestamp: /* @__PURE__ */ new Date()
3286
- }).catch((err) => {
3287
- warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
3288
- });
3289
- });
3290
- }
3291
- const snapshots = /* @__PURE__ */ new WeakMap();
3292
- function auditTrailPlugin(options = {}) {
3293
- const { operations = [
3294
- "create",
3295
- "update",
3296
- "delete"
3297
- ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
3298
- const opsSet = new Set(operations);
3425
+ function softDeletePlugin(options = {}) {
3426
+ const deletedField = options.deletedField || "deletedAt";
3427
+ const deletedByField = options.deletedByField || "deletedBy";
3428
+ const filterMode = options.filterMode || "null";
3429
+ const addRestoreMethod = options.addRestoreMethod !== false;
3430
+ const addGetDeletedMethod = options.addGetDeletedMethod !== false;
3431
+ const ttlDays = options.ttlDays;
3299
3432
  return {
3300
- name: "auditTrail",
3433
+ name: "softDelete",
3301
3434
  apply(repo) {
3302
- const AuditModel = getAuditModel(collectionName, ttlDays);
3303
- if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
3304
- const doc = toPlainObject(result);
3305
- writeAudit(AuditModel, {
3306
- model: context.model || repo.model,
3307
- operation: "create",
3308
- documentId: doc?._id,
3309
- userId: getUserId(context),
3310
- orgId: context.organizationId,
3311
- document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
3312
- metadata: metadata?.(context)
3435
+ try {
3436
+ const schemaPaths = repo.Model.schema.paths;
3437
+ for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
3438
+ if (pathName === "_id" || pathName === deletedField) continue;
3439
+ 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 } }`);
3440
+ }
3441
+ } catch (err) {
3442
+ warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
3443
+ }
3444
+ if (ttlDays !== void 0 && ttlDays > 0) {
3445
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
3446
+ repo.Model.collection.createIndex({ [deletedField]: 1 }, {
3447
+ expireAfterSeconds: ttlSeconds,
3448
+ partialFilterExpression: { [deletedField]: { $type: "date" } }
3449
+ }).catch((err) => {
3450
+ if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
3313
3451
  });
3314
- });
3315
- if (opsSet.has("update")) {
3316
- if (trackChanges) repo.on("before:update", async (context) => {
3317
- if (!context.id) return;
3318
- try {
3319
- const prev = await repo.Model.findById(context.id).lean();
3320
- if (prev) snapshots.set(context, prev);
3321
- } catch (err) {
3322
- warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
3452
+ }
3453
+ repo.on("before:delete", async (context) => {
3454
+ if (options.soft !== false) {
3455
+ const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
3456
+ if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
3457
+ const deleteQuery = {
3458
+ _id: context.id,
3459
+ ...context.query || {}
3460
+ };
3461
+ if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
3462
+ const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
3463
+ error.status = 404;
3464
+ throw error;
3323
3465
  }
3324
- });
3325
- repo.on("after:update", ({ context, result }) => {
3326
- const doc = result;
3327
- let changes;
3328
- if (trackChanges) {
3329
- const prev = snapshots.get(context);
3330
- if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
3331
- snapshots.delete(context);
3466
+ context.softDeleted = true;
3467
+ }
3468
+ }, { priority: HOOK_PRIORITY.POLICY });
3469
+ repo.on("before:getAll", (context) => {
3470
+ if (options.soft !== false) {
3471
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3472
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
3473
+ ...context.filters || {},
3474
+ ...deleteFilter
3475
+ };
3476
+ }
3477
+ }, { priority: HOOK_PRIORITY.POLICY });
3478
+ repo.on("before:getById", (context) => {
3479
+ if (options.soft !== false) {
3480
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3481
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3482
+ ...context.query || {},
3483
+ ...deleteFilter
3484
+ };
3485
+ }
3486
+ }, { priority: HOOK_PRIORITY.POLICY });
3487
+ repo.on("before:getByQuery", (context) => {
3488
+ if (options.soft !== false) {
3489
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3490
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3491
+ ...context.query || {},
3492
+ ...deleteFilter
3493
+ };
3494
+ }
3495
+ }, { priority: HOOK_PRIORITY.POLICY });
3496
+ repo.on("before:count", (context) => {
3497
+ if (options.soft !== false) {
3498
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3499
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3500
+ ...context.query || {},
3501
+ ...deleteFilter
3502
+ };
3503
+ }
3504
+ }, { priority: HOOK_PRIORITY.POLICY });
3505
+ repo.on("before:exists", (context) => {
3506
+ if (options.soft !== false) {
3507
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3508
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3509
+ ...context.query || {},
3510
+ ...deleteFilter
3511
+ };
3512
+ }
3513
+ }, { priority: HOOK_PRIORITY.POLICY });
3514
+ repo.on("before:getOrCreate", (context) => {
3515
+ if (options.soft !== false) {
3516
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3517
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3518
+ ...context.query || {},
3519
+ ...deleteFilter
3520
+ };
3521
+ }
3522
+ }, { priority: HOOK_PRIORITY.POLICY });
3523
+ repo.on("before:distinct", (context) => {
3524
+ if (options.soft !== false) {
3525
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3526
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3527
+ ...context.query || {},
3528
+ ...deleteFilter
3529
+ };
3530
+ }
3531
+ }, { priority: HOOK_PRIORITY.POLICY });
3532
+ repo.on("before:updateMany", (context) => {
3533
+ if (options.soft !== false) {
3534
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3535
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3536
+ ...context.query || {},
3537
+ ...deleteFilter
3538
+ };
3539
+ }
3540
+ }, { priority: HOOK_PRIORITY.POLICY });
3541
+ repo.on("before:deleteMany", async (context) => {
3542
+ if (options.soft !== false) {
3543
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
3544
+ const finalQuery = {
3545
+ ...context.query || {},
3546
+ ...deleteFilter
3547
+ };
3548
+ await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
3549
+ context.softDeleted = true;
3550
+ }
3551
+ }, { priority: HOOK_PRIORITY.POLICY });
3552
+ repo.on("before:aggregate", (context) => {
3553
+ if (options.soft !== false) {
3554
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3555
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3556
+ ...context.query || {},
3557
+ ...deleteFilter
3558
+ };
3559
+ }
3560
+ }, { priority: HOOK_PRIORITY.POLICY });
3561
+ repo.on("before:aggregatePaginate", (context) => {
3562
+ if (options.soft !== false) {
3563
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3564
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
3565
+ ...context.filters || {},
3566
+ ...deleteFilter
3567
+ };
3568
+ }
3569
+ }, { priority: HOOK_PRIORITY.POLICY });
3570
+ if (addRestoreMethod) {
3571
+ const restoreMethod = async function(id, restoreOptions = {}) {
3572
+ const context = await this._buildContext.call(this, "restore", {
3573
+ id,
3574
+ ...restoreOptions
3575
+ });
3576
+ const updateData = {
3577
+ [deletedField]: null,
3578
+ [deletedByField]: null
3579
+ };
3580
+ const restoreQuery = {
3581
+ _id: id,
3582
+ ...context.query || {}
3583
+ };
3584
+ const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
3585
+ returnDocument: "after",
3586
+ session: restoreOptions.session
3587
+ });
3588
+ if (!result) {
3589
+ const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
3590
+ error.status = 404;
3591
+ throw error;
3332
3592
  }
3333
- writeAudit(AuditModel, {
3334
- model: context.model || repo.model,
3335
- operation: "update",
3336
- documentId: context.id || doc?._id,
3337
- userId: getUserId(context),
3338
- orgId: context.organizationId,
3339
- changes,
3340
- metadata: metadata?.(context)
3593
+ await this.emitAsync("after:restore", {
3594
+ id,
3595
+ result,
3596
+ context
3341
3597
  });
3342
- });
3343
- }
3344
- if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
3345
- writeAudit(AuditModel, {
3346
- model: context.model || repo.model,
3347
- operation: "delete",
3348
- documentId: context.id,
3349
- userId: getUserId(context),
3350
- orgId: context.organizationId,
3351
- metadata: metadata?.(context)
3352
- });
3353
- });
3354
- if (typeof repo.registerMethod === "function")
3355
- /**
3356
- * Get audit trail for a specific document
3357
- */
3358
- repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
3359
- const { page = 1, limit = 20, operation } = queryOptions;
3360
- const skip = (page - 1) * limit;
3361
- const filter = {
3362
- model: this.model,
3363
- documentId
3364
- };
3365
- if (operation) filter.operation = operation;
3366
- const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
3367
- return {
3368
- docs,
3369
- page,
3370
- limit,
3371
- total,
3372
- pages: Math.ceil(total / limit),
3373
- hasNext: page < Math.ceil(total / limit),
3374
- hasPrev: page > 1
3598
+ return result;
3375
3599
  };
3376
- });
3377
- }
3378
- };
3379
- }
3380
- /** Convert Mongoose document to plain object */
3381
- function toPlainObject(doc) {
3382
- if (!doc) return {};
3383
- if (typeof doc.toObject === "function") return doc.toObject();
3384
- return doc;
3385
- }
3386
- /** Remove excluded fields from a document snapshot */
3387
- function sanitizeDoc(doc, excludeFields) {
3388
- if (excludeFields.length === 0) return doc;
3389
- const result = { ...doc };
3390
- for (const field of excludeFields) delete result[field];
3391
- return result;
3392
- }
3393
- /**
3394
- * Standalone audit trail query utility.
3395
- * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
3396
- *
3397
- * @example
3398
- * ```typescript
3399
- * import { AuditTrailQuery } from '@classytic/mongokit';
3400
- *
3401
- * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
3402
- *
3403
- * // All audits for an org
3404
- * const orgAudits = await auditQuery.query({ orgId: '...' });
3405
- *
3406
- * // All updates by a user
3407
- * const userUpdates = await auditQuery.query({
3408
- * userId: '...',
3409
- * operation: 'update',
3410
- * });
3411
- *
3412
- * // All audits for a specific document
3413
- * const docHistory = await auditQuery.query({
3414
- * model: 'Job',
3415
- * documentId: '...',
3416
- * });
3417
- *
3418
- * // Date range
3419
- * const recent = await auditQuery.query({
3420
- * from: new Date('2025-01-01'),
3421
- * to: new Date(),
3422
- * page: 1,
3423
- * limit: 50,
3424
- * });
3425
- *
3426
- * // Direct model access for custom queries
3427
- * const model = auditQuery.getModel();
3428
- * const count = await model.countDocuments({ operation: 'delete' });
3429
- * ```
3430
- */
3431
- var AuditTrailQuery = class {
3432
- model;
3433
- constructor(collectionName = "audit_trails", ttlDays) {
3434
- this.model = getAuditModel(collectionName, ttlDays);
3435
- }
3436
- /**
3437
- * Get the underlying Mongoose model for custom queries
3438
- */
3439
- getModel() {
3440
- return this.model;
3441
- }
3442
- /**
3443
- * Query audit entries with filters and pagination
3444
- */
3445
- async query(options = {}) {
3446
- const { page = 1, limit = 20 } = options;
3447
- const skip = (page - 1) * limit;
3448
- const filter = {};
3449
- if (options.model) filter.model = options.model;
3450
- if (options.documentId) filter.documentId = options.documentId;
3451
- if (options.userId) filter.userId = options.userId;
3452
- if (options.orgId) filter.orgId = options.orgId;
3453
- if (options.operation) filter.operation = options.operation;
3454
- if (options.from || options.to) {
3455
- const dateFilter = {};
3456
- if (options.from) dateFilter.$gte = options.from;
3457
- if (options.to) dateFilter.$lte = options.to;
3458
- filter.timestamp = dateFilter;
3459
- }
3460
- const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
3461
- const pages = Math.ceil(total / limit);
3462
- return {
3463
- docs,
3464
- page,
3465
- limit,
3466
- total,
3467
- pages,
3468
- hasNext: page < pages,
3469
- hasPrev: page > 1
3470
- };
3471
- }
3472
- /**
3473
- * Get audit trail for a specific document
3474
- */
3475
- async getDocumentTrail(model, documentId, options = {}) {
3476
- return this.query({
3477
- model,
3478
- documentId,
3479
- ...options
3480
- });
3481
- }
3482
- /**
3483
- * Get all audits for a user
3484
- */
3485
- async getUserTrail(userId, options = {}) {
3486
- return this.query({
3487
- userId,
3488
- ...options
3489
- });
3490
- }
3491
- /**
3492
- * Get all audits for an organization
3493
- */
3494
- async getOrgTrail(orgId, options = {}) {
3495
- return this.query({
3496
- orgId,
3497
- ...options
3498
- });
3499
- }
3500
- };
3501
-
3502
- //#endregion
3503
- //#region src/plugins/elastic.plugin.ts
3504
- function elasticSearchPlugin(options) {
3505
- return {
3506
- name: "elastic-search",
3507
- apply(repo) {
3508
- if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
3509
- repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
3510
- const { client, index, idField = "_id" } = options;
3511
- const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
3512
- const from = Math.max(searchOptions.from || 0, 0);
3513
- const esResponse = await client.search({
3514
- index,
3515
- body: {
3516
- query: searchQuery,
3517
- size: limit,
3518
- from
3600
+ if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
3601
+ else repo.restore = restoreMethod.bind(repo);
3602
+ }
3603
+ if (addGetDeletedMethod) {
3604
+ const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
3605
+ const context = await this._buildContext.call(this, "getDeleted", {
3606
+ ...params,
3607
+ ...getDeletedOptions
3608
+ });
3609
+ const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
3610
+ const combinedFilters = {
3611
+ ...params.filters || {},
3612
+ ...deletedFilter,
3613
+ ...context.filters || {},
3614
+ ...context.query || {}
3615
+ };
3616
+ const page = params.page || 1;
3617
+ const limit = params.limit || 20;
3618
+ const skip = (page - 1) * limit;
3619
+ let sortSpec = { [deletedField]: -1 };
3620
+ if (params.sort) if (typeof params.sort === "string") {
3621
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
3622
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
3623
+ } else sortSpec = params.sort;
3624
+ let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
3625
+ if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
3626
+ if (getDeletedOptions.select) {
3627
+ const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
3628
+ query = query.select(selectValue);
3519
3629
  }
3520
- });
3521
- const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
3522
- if (hits.length === 0) return {
3523
- docs: [],
3524
- total: 0,
3525
- limit,
3526
- from
3527
- };
3528
- const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
3529
- const total = typeof totalValue === "number" ? totalValue : 0;
3530
- const docsOrder = /* @__PURE__ */ new Map();
3531
- const scores = /* @__PURE__ */ new Map();
3532
- const ids = [];
3533
- hits.forEach((hit, idx) => {
3534
- const docId = hit._source?.[idField] || hit[idField] || hit._id;
3535
- if (docId) {
3536
- const strId = String(docId);
3537
- docsOrder.set(strId, idx);
3538
- if (hit._score !== void 0) scores.set(strId, hit._score);
3539
- ids.push(strId);
3630
+ if (getDeletedOptions.populate) {
3631
+ const populateSpec = getDeletedOptions.populate;
3632
+ if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
3633
+ else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
3634
+ else query = query.populate(populateSpec);
3540
3635
  }
3541
- });
3542
- if (ids.length === 0) return {
3543
- docs: [],
3544
- total,
3545
- limit,
3546
- from
3547
- };
3548
- const mongoQuery = this.Model.find({ _id: { $in: ids } });
3549
- if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
3550
- if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
3551
- if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
3552
- return {
3553
- docs: (await mongoQuery.exec()).sort((a, b) => {
3554
- const aId = String(a._id);
3555
- const bId = String(b._id);
3556
- return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
3557
- }).map((doc) => {
3558
- const strId = String(doc._id);
3559
- if (searchOptions.mongoOptions?.lean !== false) return {
3560
- ...doc,
3561
- _score: scores.get(strId)
3562
- };
3563
- return doc;
3564
- }),
3565
- total,
3566
- limit,
3567
- from
3636
+ if (getDeletedOptions.lean !== false) query = query.lean();
3637
+ const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
3638
+ const pages = Math.ceil(total / limit);
3639
+ return {
3640
+ method: "offset",
3641
+ docs,
3642
+ page,
3643
+ limit,
3644
+ total,
3645
+ pages,
3646
+ hasNext: page < pages,
3647
+ hasPrev: page > 1
3648
+ };
3568
3649
  };
3569
- });
3650
+ if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
3651
+ else repo.getDeleted = getDeletedMethod.bind(repo);
3652
+ }
3570
3653
  }
3571
3654
  };
3572
3655
  }
3573
-
3574
3656
  //#endregion
3575
- //#region src/plugins/custom-id.plugin.ts
3657
+ //#region src/plugins/subdocument.plugin.ts
3576
3658
  /**
3577
- * Custom ID Plugin
3578
- *
3579
- * Generates custom document IDs using pluggable generators.
3580
- * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
3581
- * date-partitioned sequences, and fully custom generators.
3582
- *
3583
- * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
3584
- * counters collection — guaranteeing no duplicate IDs under concurrency.
3585
- *
3586
- * @example Basic sequential counter
3587
- * ```typescript
3588
- * const invoiceRepo = new Repository(InvoiceModel, [
3589
- * customIdPlugin({
3590
- * field: 'invoiceNumber',
3591
- * generator: sequentialId({
3592
- * prefix: 'INV',
3593
- * model: InvoiceModel,
3594
- * }),
3595
- * }),
3596
- * ]);
3597
- *
3598
- * const inv = await invoiceRepo.create({ amount: 100 });
3599
- * // inv.invoiceNumber → "INV-0001"
3600
- * ```
3659
+ * Subdocument plugin for managing nested arrays
3601
3660
  *
3602
- * @example Date-partitioned counter (resets monthly)
3603
- * ```typescript
3604
- * const billRepo = new Repository(BillModel, [
3605
- * customIdPlugin({
3606
- * field: 'billNumber',
3607
- * generator: dateSequentialId({
3608
- * prefix: 'BILL',
3609
- * model: BillModel,
3610
- * partition: 'monthly',
3611
- * separator: '-',
3612
- * padding: 4,
3613
- * }),
3614
- * }),
3661
+ * @example
3662
+ * const repo = new Repository(Model, [
3663
+ * methodRegistryPlugin(),
3664
+ * subdocumentPlugin(),
3615
3665
  * ]);
3616
3666
  *
3617
- * const bill = await billRepo.create({ total: 250 });
3618
- * // bill.billNumber "BILL-2026-02-0001"
3619
- * ```
3620
- *
3621
- * @example Custom generator function
3622
- * ```typescript
3623
- * const orderRepo = new Repository(OrderModel, [
3624
- * customIdPlugin({
3625
- * field: 'orderRef',
3626
- * generator: async (context) => {
3627
- * const region = context.data?.region || 'US';
3628
- * const seq = await getNextSequence('orders');
3629
- * return `ORD-${region}-${seq}`;
3630
- * },
3631
- * }),
3632
- * ]);
3633
- * ```
3667
+ * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
3668
+ * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
3634
3669
  */
3635
- /** Schema for the internal counters collection */
3636
- const counterSchema = new mongoose.Schema({
3637
- _id: {
3638
- type: String,
3639
- required: true
3640
- },
3641
- seq: {
3642
- type: Number,
3643
- default: 0
3644
- }
3645
- }, {
3646
- collection: "_mongokit_counters",
3647
- versionKey: false
3648
- });
3670
+ function subdocumentPlugin() {
3671
+ return {
3672
+ name: "subdocument",
3673
+ apply(repo) {
3674
+ if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
3675
+ /**
3676
+ * Add subdocument to array
3677
+ */
3678
+ repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
3679
+ return this.update.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
3680
+ });
3681
+ /**
3682
+ * Get subdocument from array
3683
+ */
3684
+ repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
3685
+ return this._executeQuery.call(this, async (Model) => {
3686
+ const parent = await Model.findById(parentId).session(options.session).exec();
3687
+ if (!parent) throw createError(404, "Parent not found");
3688
+ const arrayField = parent[arrayPath];
3689
+ if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
3690
+ const sub = arrayField.id(subId);
3691
+ if (!sub) throw createError(404, "Subdocument not found");
3692
+ return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
3693
+ });
3694
+ });
3695
+ /**
3696
+ * Update subdocument in array
3697
+ */
3698
+ repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
3699
+ return this._executeQuery.call(this, async (Model) => {
3700
+ const query = {
3701
+ _id: parentId,
3702
+ [`${arrayPath}._id`]: subId
3703
+ };
3704
+ const update = { $set: { [`${arrayPath}.$`]: {
3705
+ ...updateData,
3706
+ _id: subId
3707
+ } } };
3708
+ const result = await Model.findOneAndUpdate(query, update, {
3709
+ returnDocument: "after",
3710
+ runValidators: true,
3711
+ session: options.session
3712
+ }).exec();
3713
+ if (!result) throw createError(404, "Parent or subdocument not found");
3714
+ return result;
3715
+ });
3716
+ });
3717
+ /**
3718
+ * Delete subdocument from array
3719
+ */
3720
+ repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
3721
+ return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
3722
+ });
3723
+ }
3724
+ };
3725
+ }
3726
+ //#endregion
3727
+ //#region src/plugins/timestamp.plugin.ts
3649
3728
  /**
3650
- * Get or create the Counter model on the given connection.
3651
- * Falls back to the default mongoose connection if none is provided.
3652
- * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
3729
+ * Timestamp plugin that auto-injects timestamps
3730
+ *
3731
+ * @example
3732
+ * const repo = new Repository(Model, [timestampPlugin()]);
3653
3733
  */
3654
- function getCounterModel(connection) {
3655
- const conn = connection ?? mongoose.connection;
3656
- if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
3657
- return conn.model("_MongoKitCounter", counterSchema);
3734
+ function timestampPlugin() {
3735
+ return {
3736
+ name: "timestamp",
3737
+ apply(repo) {
3738
+ repo.on("before:create", (context) => {
3739
+ if (!context.data) return;
3740
+ const now = /* @__PURE__ */ new Date();
3741
+ if (!context.data.createdAt) context.data.createdAt = now;
3742
+ if (!context.data.updatedAt) context.data.updatedAt = now;
3743
+ });
3744
+ repo.on("before:update", (context) => {
3745
+ if (!context.data) return;
3746
+ context.data.updatedAt = /* @__PURE__ */ new Date();
3747
+ });
3748
+ }
3749
+ };
3658
3750
  }
3751
+ //#endregion
3752
+ //#region src/plugins/validation-chain.plugin.ts
3659
3753
  /**
3660
- * Atomically increment and return the next sequence value for a given key.
3661
- * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
3662
- * heavy concurrency.
3663
- *
3664
- * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
3665
- * @param increment - Value to increment by (default: 1)
3666
- * @returns The next sequence number (after increment)
3754
+ * Validation chain plugin
3667
3755
  *
3668
3756
  * @example
3669
- * const seq = await getNextSequence('invoices');
3670
- * // First call → 1, second → 2, ...
3671
- *
3672
- * @example Batch increment for createMany
3673
- * const startSeq = await getNextSequence('invoices', 5);
3674
- * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
3757
+ * const repo = new Repository(Model, [
3758
+ * validationChainPlugin([
3759
+ * requireField('email'),
3760
+ * uniqueField('email', 'Email already exists'),
3761
+ * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
3762
+ * ])
3763
+ * ]);
3675
3764
  */
3676
- async function getNextSequence(counterKey, increment = 1, connection) {
3677
- const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
3678
- upsert: true,
3679
- returnDocument: "after"
3765
+ function validationChainPlugin(validators = [], options = {}) {
3766
+ const { stopOnFirstError = true } = options;
3767
+ validators.forEach((v, idx) => {
3768
+ if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
3769
+ if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
3680
3770
  });
3681
- if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
3682
- return result.seq;
3771
+ const validatorsByOperation = {
3772
+ create: [],
3773
+ update: [],
3774
+ delete: [],
3775
+ createMany: []
3776
+ };
3777
+ const allOperationsValidators = [];
3778
+ validators.forEach((v) => {
3779
+ if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
3780
+ else v.operations.forEach((op) => {
3781
+ if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
3782
+ });
3783
+ });
3784
+ return {
3785
+ name: "validation-chain",
3786
+ apply(repo) {
3787
+ const getValidatorsForOperation = (operation) => {
3788
+ const specific = validatorsByOperation[operation] || [];
3789
+ return [...allOperationsValidators, ...specific];
3790
+ };
3791
+ const runValidators = async (operation, context) => {
3792
+ const operationValidators = getValidatorsForOperation(operation);
3793
+ const errors = [];
3794
+ for (const validator of operationValidators) try {
3795
+ await validator.validate(context, repo);
3796
+ } catch (error) {
3797
+ if (stopOnFirstError) throw error;
3798
+ errors.push({
3799
+ validator: validator.name,
3800
+ error: error.message || String(error)
3801
+ });
3802
+ }
3803
+ if (errors.length > 0) {
3804
+ const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
3805
+ err.validationErrors = errors;
3806
+ throw err;
3807
+ }
3808
+ };
3809
+ repo.on("before:create", async (context) => runValidators("create", context));
3810
+ repo.on("before:createMany", async (context) => runValidators("createMany", context));
3811
+ repo.on("before:update", async (context) => runValidators("update", context));
3812
+ repo.on("before:delete", async (context) => runValidators("delete", context));
3813
+ }
3814
+ };
3683
3815
  }
3684
3816
  /**
3685
- * Generator: Simple sequential counter.
3686
- * Produces IDs like `INV-0001`, `INV-0002`, etc.
3687
- *
3688
- * Uses atomic MongoDB counters — safe under concurrency.
3817
+ * Block operation if condition is true
3689
3818
  *
3690
3819
  * @example
3691
- * ```typescript
3692
- * customIdPlugin({
3693
- * field: 'invoiceNumber',
3694
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
3695
- * })
3696
- * ```
3820
+ * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
3697
3821
  */
3698
- function sequentialId(options) {
3699
- const { prefix, model, padding = 4, separator = "-", counterKey } = options;
3700
- const key = counterKey || model.modelName;
3701
- return async (context) => {
3702
- const seq = await getNextSequence(key, 1, context._counterConnection);
3703
- return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
3822
+ function blockIf(name, operations, condition, errorMessage) {
3823
+ return {
3824
+ name,
3825
+ operations,
3826
+ validate: (context) => {
3827
+ if (condition(context)) throw createError(403, errorMessage);
3828
+ }
3704
3829
  };
3705
3830
  }
3706
3831
  /**
3707
- * Generator: Date-partitioned sequential counter.
3708
- * Counter resets per period — great for invoice/bill numbering.
3709
- *
3710
- * Produces IDs like:
3711
- * - yearly: `BILL-2026-0001`
3712
- * - monthly: `BILL-2026-02-0001`
3713
- * - daily: `BILL-2026-02-20-0001`
3714
- *
3715
- * @example
3716
- * ```typescript
3717
- * customIdPlugin({
3718
- * field: 'billNumber',
3719
- * generator: dateSequentialId({
3720
- * prefix: 'BILL',
3721
- * model: BillModel,
3722
- * partition: 'monthly',
3723
- * }),
3724
- * })
3725
- * ```
3832
+ * Require a field to be present
3726
3833
  */
3727
- function dateSequentialId(options) {
3728
- const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
3729
- return async (context) => {
3730
- const now = /* @__PURE__ */ new Date();
3731
- const year = String(now.getFullYear());
3732
- const month = String(now.getMonth() + 1).padStart(2, "0");
3733
- const day = String(now.getDate()).padStart(2, "0");
3734
- let datePart;
3735
- let counterKey;
3736
- switch (partition) {
3737
- case "yearly":
3738
- datePart = year;
3739
- counterKey = `${model.modelName}:${year}`;
3740
- break;
3741
- case "daily":
3742
- datePart = `${year}${separator}${month}${separator}${day}`;
3743
- counterKey = `${model.modelName}:${year}-${month}-${day}`;
3744
- break;
3745
- default:
3746
- datePart = `${year}${separator}${month}`;
3747
- counterKey = `${model.modelName}:${year}-${month}`;
3748
- break;
3834
+ function requireField(field, operations = ["create"]) {
3835
+ return {
3836
+ name: `require-${field}`,
3837
+ operations,
3838
+ validate: (context) => {
3839
+ if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
3749
3840
  }
3750
- const seq = await getNextSequence(counterKey, 1, context._counterConnection);
3751
- return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
3752
3841
  };
3753
3842
  }
3754
3843
  /**
3755
- * Generator: Prefix + random alphanumeric suffix.
3756
- * Does NOT require a database round-trip — purely in-memory.
3757
- *
3758
- * Produces IDs like: `USR_a7b3xk9m2p1q`
3759
- *
3760
- * Good for: user-facing IDs where ordering doesn't matter.
3761
- * Not suitable for sequential numbering.
3762
- *
3763
- * @example
3764
- * ```typescript
3765
- * customIdPlugin({
3766
- * field: 'publicId',
3767
- * generator: prefixedId({ prefix: 'USR', length: 10 }),
3768
- * })
3769
- * ```
3844
+ * Auto-inject a value if not present
3770
3845
  */
3771
- function prefixedId(options) {
3772
- const { prefix, separator = "_", length = 12 } = options;
3773
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
3774
- return (_context) => {
3775
- let result = "";
3776
- const bytes = new Uint8Array(length);
3777
- if (typeof globalThis.crypto?.getRandomValues === "function") {
3778
- globalThis.crypto.getRandomValues(bytes);
3779
- for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
3780
- } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
3781
- return `${prefix}${separator}${result}`;
3846
+ function autoInject(field, getter, operations = ["create"]) {
3847
+ return {
3848
+ name: `auto-inject-${field}`,
3849
+ operations,
3850
+ validate: (context) => {
3851
+ if (context.data && !(field in context.data)) {
3852
+ const value = getter(context);
3853
+ if (value !== null && value !== void 0) context.data[field] = value;
3854
+ }
3855
+ }
3782
3856
  };
3783
3857
  }
3784
3858
  /**
3785
- * Custom ID plugin injects generated IDs into documents before creation.
3786
- *
3787
- * @param options - Configuration for ID generation
3788
- * @returns Plugin instance
3789
- *
3790
- * @example
3791
- * ```typescript
3792
- * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
3793
- *
3794
- * const invoiceRepo = new Repository(InvoiceModel, [
3795
- * customIdPlugin({
3796
- * field: 'invoiceNumber',
3797
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
3798
- * }),
3799
- * ]);
3800
- *
3801
- * const inv = await invoiceRepo.create({ amount: 100 });
3802
- * console.log(inv.invoiceNumber); // "INV-0001"
3803
- * ```
3859
+ * Make a field immutable (cannot be updated)
3804
3860
  */
3805
- function customIdPlugin(options) {
3806
- const fieldName = options.field || "customId";
3807
- const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
3861
+ function immutableField(field) {
3808
3862
  return {
3809
- name: "custom-id",
3810
- apply(repo) {
3811
- const repoConnection = repo.Model.db;
3812
- repo.on("before:create", async (context) => {
3813
- if (!context.data) return;
3814
- if (generateOnlyIfEmpty && context.data[fieldName]) return;
3815
- context._counterConnection = repoConnection;
3816
- context.data[fieldName] = await options.generator(context);
3817
- });
3818
- repo.on("before:createMany", async (context) => {
3819
- if (!context.dataArray) return;
3820
- context._counterConnection = repoConnection;
3821
- const docsNeedingIds = [];
3822
- for (const doc of context.dataArray) {
3823
- if (generateOnlyIfEmpty && doc[fieldName]) continue;
3824
- docsNeedingIds.push(doc);
3825
- }
3826
- if (docsNeedingIds.length === 0) return;
3827
- for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
3828
- ...context,
3829
- data: doc
3830
- });
3863
+ name: `immutable-${field}`,
3864
+ operations: ["update"],
3865
+ validate: (context) => {
3866
+ if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
3867
+ }
3868
+ };
3869
+ }
3870
+ /**
3871
+ * Ensure field value is unique
3872
+ */
3873
+ function uniqueField(field, errorMessage) {
3874
+ return {
3875
+ name: `unique-${field}`,
3876
+ operations: ["create", "update"],
3877
+ validate: async (context, repo) => {
3878
+ if (!context.data?.[field]) return;
3879
+ if (!repo) {
3880
+ warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
3881
+ return;
3882
+ }
3883
+ const query = { [field]: context.data[field] };
3884
+ const getByQuery = repo.getByQuery;
3885
+ if (typeof getByQuery !== "function") {
3886
+ warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
3887
+ return;
3888
+ }
3889
+ const existing = await getByQuery.call(repo, query, {
3890
+ select: "_id",
3891
+ lean: true,
3892
+ throwOnNotFound: false
3831
3893
  });
3894
+ if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
3832
3895
  }
3833
3896
  };
3834
3897
  }
3835
-
3836
3898
  //#endregion
3837
- export { AggregationBuilder as A, methodRegistryPlugin as C, fieldFilterPlugin as D, timestampPlugin as E, HOOK_PRIORITY as O, validationChainPlugin as S, auditLogPlugin as T, autoInject as _, sequentialId as a, requireField as b, auditTrailPlugin as c, cascadePlugin as d, cachePlugin as f, mongoOperationsPlugin as g, batchOperationsPlugin as h, prefixedId as i, Repository as k, observabilityPlugin as l, aggregateHelpersPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, subdocumentPlugin as p, getNextSequence as r, AuditTrailQuery as s, customIdPlugin as t, multiTenantPlugin as u, blockIf as v, softDeletePlugin as w, uniqueField as x, immutableField as y };
3899
+ export { aggregateHelpersPlugin as A, HOOK_PRIORITY as C, AuditTrailQuery as D, batchOperationsPlugin as E, auditTrailPlugin as O, cachePlugin as S, AggregationBuilder as T, dateSequentialId as _, uniqueField as a, sequentialId as b, subdocumentPlugin as c, multiTenantPlugin as d, mongoOperationsPlugin as f, customIdPlugin as g, elasticSearchPlugin as h, requireField as i, auditLogPlugin as k, softDeletePlugin as l, fieldFilterPlugin as m, blockIf as n, validationChainPlugin as o, methodRegistryPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, observabilityPlugin as u, getNextSequence as v, Repository as w, cascadePlugin as x, prefixedId as y };