@classytic/mongokit 3.3.2 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +137 -7
  2. package/dist/PaginationEngine-nY04eGUM.mjs +290 -0
  3. package/dist/actions/index.d.mts +2 -9
  4. package/dist/actions/index.mjs +3 -5
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/ai/index.mjs +3 -3
  7. package/dist/chunk-CfYAbeIz.mjs +13 -0
  8. package/dist/{limits-s1-d8rWb.mjs → cursor-CHToazHy.mjs} +122 -171
  9. package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
  10. package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-reyDRzXf.mjs} +110 -112
  11. package/dist/{aggregate-BkOG9qwr.d.mts → index-BuoZIZ15.d.mts} +132 -129
  12. package/dist/index.d.mts +549 -543
  13. package/dist/index.mjs +33 -101
  14. package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-B6Qyl8BK.mjs} +13 -12
  15. package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-RX9YfJLu.d.mts} +24 -17
  16. package/dist/pagination/PaginationEngine.d.mts +1 -1
  17. package/dist/pagination/PaginationEngine.mjs +2 -209
  18. package/dist/plugins/index.d.mts +1 -2
  19. package/dist/plugins/index.mjs +2 -3
  20. package/dist/sort-C-BJEWUZ.mjs +57 -0
  21. package/dist/{types-pVY0w1Pp.d.mts → types-COINbsdL.d.mts} +57 -27
  22. package/dist/{aggregate-BClp040M.mjs → update-DGKMmBgG.mjs} +575 -565
  23. package/dist/utils/index.d.mts +2 -2
  24. package/dist/utils/index.mjs +4 -5
  25. package/dist/{custom-id.plugin-BJ3FSnzt.d.mts → validation-chain.plugin-BNoaKDOm.d.mts} +832 -832
  26. package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-da3fOo8A.mjs} +2410 -2246
  27. package/package.json +11 -6
  28. package/dist/chunk-DQk6qfdC.mjs +0 -18
@@ -1,101 +1,715 @@
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-DGKMmBgG.mjs";
3
+ import { t as PaginationEngine } from "./PaginationEngine-nY04eGUM.mjs";
4
+ import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-reyDRzXf.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(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(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
96
473
  */
97
- project(projection) {
98
- this.pipeline.push({ $project: projection });
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("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(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("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(error);
579
+ }
580
+ });
581
+ /**
582
+ * Delete multiple documents
583
+ */
584
+ repo.registerMethod("deleteMany", async function(query, options = {}) {
585
+ const context = await this._buildContext("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(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
710
+ */
711
+ project(projection) {
712
+ this.pipeline.push({ $project: projection });
99
713
  return this;
100
714
  }
101
715
  /**
@@ -489,35 +1103,15 @@ 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
- /**
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
- */
1108
+ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1109
+ if (!projection || !sort) return projection;
1110
+ if (!Object.values(projection).some((value) => value === 1)) return projection;
1111
+ const nextProjection = { ...projection };
1112
+ for (const field of [...Object.keys(sort), "_id"]) nextProjection[field] = 1;
1113
+ return nextProjection;
1114
+ }
521
1115
  /**
522
1116
  * Plugin phase priorities (lower = runs first)
523
1117
  * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
@@ -546,7 +1140,9 @@ var Repository = class {
546
1140
  this._hooks = /* @__PURE__ */ new Map();
547
1141
  this._pagination = new PaginationEngine(Model, paginationConfig);
548
1142
  this._hookMode = options.hooks ?? "async";
549
- plugins.forEach((plugin) => this.use(plugin));
1143
+ plugins.forEach((plugin) => {
1144
+ this.use(plugin);
1145
+ });
550
1146
  }
551
1147
  /**
552
1148
  * Register a plugin
@@ -567,7 +1163,7 @@ var Repository = class {
567
1163
  */
568
1164
  on(event, listener, options) {
569
1165
  if (!this._hooks.has(event)) this._hooks.set(event, []);
570
- const hooks = this._hooks.get(event);
1166
+ const hooks = this._hooks.get(event) ?? [];
571
1167
  const priority = options?.priority ?? HOOK_PRIORITY.DEFAULT;
572
1168
  hooks.push({
573
1169
  listener,
@@ -807,7 +1403,7 @@ var Repository = class {
807
1403
  let useKeyset = false;
808
1404
  if (mode) useKeyset = mode === "keyset";
809
1405
  else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
810
- let query = { ...filters };
1406
+ const query = { ...filters };
811
1407
  if (search) {
812
1408
  if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
813
1409
  if (this._hasTextIndex) query.$text = { $search: search };
@@ -824,20 +1420,49 @@ var Repository = class {
824
1420
  session: options.session,
825
1421
  hint: context.hint ?? params.hint,
826
1422
  maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
827
- readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
1423
+ readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
1424
+ collation: context.collation ?? params.collation
828
1425
  };
829
- try {
830
- let result;
831
- if (useKeyset) result = await this._pagination.stream({
832
- ...paginationOptions,
1426
+ const lookups = context.lookups ?? params.lookups;
1427
+ if (lookups && lookups.length > 0) try {
1428
+ const lookupResult = await this.lookupPopulate({
1429
+ filters: query,
1430
+ lookups,
833
1431
  sort: paginationOptions.sort,
834
- after
835
- });
836
- else result = await this._pagination.paginate({
837
- ...paginationOptions,
838
- page: page || 1,
1432
+ page: useKeyset ? void 0 : page || 1,
1433
+ after: useKeyset ? after : void 0,
1434
+ limit,
1435
+ select: paginationOptions.select,
1436
+ session: options.session,
1437
+ readPreference: paginationOptions.readPreference,
1438
+ collation: paginationOptions.collation,
839
1439
  countStrategy: context.countStrategy ?? params.countStrategy
840
1440
  });
1441
+ let result;
1442
+ if (lookupResult.next !== void 0) result = {
1443
+ method: "keyset",
1444
+ docs: lookupResult.data,
1445
+ limit: lookupResult.limit ?? limit,
1446
+ hasMore: lookupResult.hasMore ?? false,
1447
+ next: lookupResult.next ?? null
1448
+ };
1449
+ else {
1450
+ const total = lookupResult.total ?? 0;
1451
+ const resultLimit = lookupResult.limit ?? limit;
1452
+ const totalPages = Math.ceil(total / resultLimit);
1453
+ const currentPage = lookupResult.page ?? 1;
1454
+ const hasNext = lookupResult.hasMore !== void 0 ? lookupResult.hasMore : currentPage < totalPages;
1455
+ result = {
1456
+ method: "offset",
1457
+ docs: lookupResult.data,
1458
+ page: currentPage,
1459
+ limit: resultLimit,
1460
+ total,
1461
+ pages: totalPages,
1462
+ hasNext,
1463
+ hasPrev: currentPage > 1
1464
+ };
1465
+ }
841
1466
  await this._emitHook("after:getAll", {
842
1467
  context,
843
1468
  result
@@ -850,8 +1475,32 @@ var Repository = class {
850
1475
  });
851
1476
  throw this._handleError(error);
852
1477
  }
853
- }
854
- /**
1478
+ try {
1479
+ let result;
1480
+ if (useKeyset) result = await this._pagination.stream({
1481
+ ...paginationOptions,
1482
+ sort: paginationOptions.sort,
1483
+ after
1484
+ });
1485
+ else result = await this._pagination.paginate({
1486
+ ...paginationOptions,
1487
+ page: page || 1,
1488
+ countStrategy: context.countStrategy ?? params.countStrategy
1489
+ });
1490
+ await this._emitHook("after:getAll", {
1491
+ context,
1492
+ result
1493
+ });
1494
+ return result;
1495
+ } catch (error) {
1496
+ await this._emitErrorHook("error:getAll", {
1497
+ context,
1498
+ error
1499
+ });
1500
+ throw this._handleError(error);
1501
+ }
1502
+ }
1503
+ /**
855
1504
  * Get or create document
856
1505
  * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
857
1506
  */
@@ -1116,42 +1765,120 @@ var Repository = class {
1116
1765
  async lookupPopulate(options) {
1117
1766
  const context = await this._buildContext("lookupPopulate", options);
1118
1767
  try {
1119
- const builder = new AggregationBuilder();
1768
+ const MAX_LOOKUPS = 10;
1769
+ const lookups = context.lookups ?? options.lookups;
1770
+ if (lookups.length > MAX_LOOKUPS) throw createError(400, `Too many lookups (${lookups.length}). Maximum is ${MAX_LOOKUPS}.`);
1120
1771
  const filters = context.filters ?? options.filters;
1121
- if (filters && Object.keys(filters).length > 0) builder.match(filters);
1122
- builder.multiLookup(options.lookups);
1123
1772
  const sort = context.sort ?? options.sort;
1124
- if (sort) builder.sort(this._parseSort(sort));
1125
- const page = context.page ?? options.page ?? 1;
1126
1773
  const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
1127
- const skip = (page - 1) * limit;
1128
- const SAFE_LIMIT = 1e3;
1129
- const SAFE_MAX_OFFSET = 1e4;
1130
- if (limit > SAFE_LIMIT) warn(`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`);
1131
- if (skip > SAFE_MAX_OFFSET) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`);
1132
- const dataStages = [{ $skip: skip }, { $limit: limit }];
1774
+ const readPref = context.readPreference ?? options.readPreference;
1775
+ const session = context.session ?? options.session;
1776
+ const collation = context.collation ?? options.collation;
1777
+ const after = context.after ?? options.after;
1778
+ const pageFromContext = context.page ?? options.page;
1779
+ const isKeyset = !!after || !pageFromContext && !!sort;
1780
+ const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
1133
1781
  const selectSpec = context.select ?? options.select;
1782
+ let projection;
1134
1783
  if (selectSpec) {
1135
- let projection;
1136
1784
  if (typeof selectSpec === "string") {
1137
1785
  projection = {};
1138
- const fields = selectSpec.split(",").map((f) => f.trim());
1139
- for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1786
+ for (const field of selectSpec.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1140
1787
  else projection[field] = 1;
1141
1788
  } else if (Array.isArray(selectSpec)) {
1142
1789
  projection = {};
1143
1790
  for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
1144
1791
  else projection[field] = 1;
1145
- } else projection = selectSpec;
1146
- dataStages.push({ $project: projection });
1792
+ } else projection = { ...selectSpec };
1793
+ if (Object.values(projection).some((v) => v === 1)) for (const lookup of lookups) {
1794
+ const asField = lookup.as || lookup.from;
1795
+ if (!(asField in projection)) projection[asField] = 1;
1796
+ }
1147
1797
  }
1148
- builder.facet({
1149
- metadata: [{ $count: "total" }],
1150
- data: dataStages
1151
- });
1152
- const pipeline = builder.build();
1153
- const aggregation = this.Model.aggregate(pipeline).session(options.session || null);
1154
- const readPref = context.readPreference ?? options.readPreference;
1798
+ const appendLookupStages = (pipeline) => {
1799
+ pipeline.push(...LookupBuilder.multiple(lookups));
1800
+ for (const lookup of lookups) if (lookup.single) {
1801
+ const asField = lookup.as || lookup.from;
1802
+ pipeline.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
1803
+ }
1804
+ const finalProjection = ensureLookupProjectionIncludesCursorFields(projection, isKeyset && sort ? this._parseSort(sort) : void 0);
1805
+ if (finalProjection) pipeline.push({ $project: finalProjection });
1806
+ };
1807
+ if (isKeyset && sort) {
1808
+ const parsedSort = this._parseSort(sort);
1809
+ const { validateKeysetSort } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
1810
+ const { encodeCursor, resolveCursorFilter } = await import("./cursor-CHToazHy.mjs").then((n) => n.t);
1811
+ const { getPrimaryField } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
1812
+ const normalizedSort = validateKeysetSort(parsedSort);
1813
+ const cursorVersion = this._pagination.config.cursorVersion ?? 1;
1814
+ const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }) : { ...filters || {} };
1815
+ if (projection) {
1816
+ if (Object.values(projection).some((v) => v === 1)) {
1817
+ for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
1818
+ }
1819
+ }
1820
+ const pipeline = [];
1821
+ if (Object.keys(matchFilters).length > 0) pipeline.push({ $match: matchFilters });
1822
+ pipeline.push({ $sort: normalizedSort });
1823
+ pipeline.push({ $limit: limit + 1 });
1824
+ appendLookupStages(pipeline);
1825
+ const aggregation = this.Model.aggregate(pipeline).session(session || null);
1826
+ if (collation) aggregation.collation(collation);
1827
+ if (readPref) aggregation.read(readPref);
1828
+ const docs = await aggregation;
1829
+ const hasMore = docs.length > limit;
1830
+ if (hasMore) docs.pop();
1831
+ const primaryField = getPrimaryField(normalizedSort);
1832
+ const nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null;
1833
+ await this._emitHook("after:lookupPopulate", {
1834
+ context,
1835
+ result: docs
1836
+ });
1837
+ return {
1838
+ data: docs,
1839
+ limit,
1840
+ next: nextCursor,
1841
+ hasMore
1842
+ };
1843
+ }
1844
+ const page = pageFromContext ?? 1;
1845
+ const skip = (page - 1) * limit;
1846
+ if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
1847
+ const dataPipeline = [];
1848
+ if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
1849
+ if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
1850
+ if (countStrategy === "none") {
1851
+ dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
1852
+ appendLookupStages(dataPipeline);
1853
+ const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
1854
+ if (collation) aggregation.collation(collation);
1855
+ if (readPref) aggregation.read(readPref);
1856
+ const docs = await aggregation;
1857
+ const hasNext = docs.length > limit;
1858
+ if (hasNext) docs.pop();
1859
+ await this._emitHook("after:lookupPopulate", {
1860
+ context,
1861
+ result: docs
1862
+ });
1863
+ return {
1864
+ data: docs,
1865
+ total: 0,
1866
+ page,
1867
+ limit,
1868
+ hasMore: hasNext
1869
+ };
1870
+ }
1871
+ dataPipeline.push({ $skip: skip }, { $limit: limit });
1872
+ appendLookupStages(dataPipeline);
1873
+ const countPipeline = [];
1874
+ if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
1875
+ countPipeline.push({ $count: "total" });
1876
+ const pipeline = [{ $facet: {
1877
+ metadata: countPipeline,
1878
+ data: dataPipeline
1879
+ } }];
1880
+ const aggregation = this.Model.aggregate(pipeline).session(session || null);
1881
+ if (collation) aggregation.collation(collation);
1155
1882
  if (readPref) aggregation.read(readPref);
1156
1883
  const result = (await aggregation)[0] || {
1157
1884
  metadata: [],
@@ -1340,1614 +2067,1205 @@ var Repository = class {
1340
2067
  _handleError(error) {
1341
2068
  if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
1342
2069
  if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
2070
+ const duplicateErr = parseDuplicateKeyError(error);
2071
+ if (duplicateErr) return duplicateErr;
1343
2072
  if (error.status && error.message) return error;
1344
2073
  return createError(500, error.message || "Internal Server Error");
1345
2074
  }
1346
2075
  };
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
2076
  //#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
- }
2077
+ //#region src/plugins/cache.plugin.ts
1492
2078
  /**
1493
- * Soft delete plugin
2079
+ * Cache Plugin
1494
2080
  *
1495
- * @example Basic usage
1496
- * ```typescript
1497
- * const repo = new Repository(Model, [
1498
- * softDeletePlugin({ deletedField: 'deletedAt' })
1499
- * ]);
2081
+ * Optional caching layer for MongoKit with automatic invalidation.
2082
+ * Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
1500
2083
  *
1501
- * // Delete (soft)
1502
- * await repo.delete(id);
2084
+ * Features:
2085
+ * - Cache-aside (read-through) pattern with configurable TTLs
2086
+ * - Automatic invalidation on create/update/delete
2087
+ * - Collection version tags for efficient list cache invalidation
2088
+ * - Manual invalidation methods for microservice scenarios
2089
+ * - Skip cache per-operation with `skipCache: true`
1503
2090
  *
1504
- * // Restore
1505
- * await repo.restore(id);
2091
+ * @example
2092
+ * ```typescript
2093
+ * import { Repository, cachePlugin } from '@classytic/mongokit';
2094
+ * import Redis from 'ioredis';
1506
2095
  *
1507
- * // Get deleted documents
1508
- * await repo.getDeleted({ page: 1, limit: 20 });
1509
- * ```
2096
+ * const redis = new Redis();
1510
2097
  *
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
2098
+ * const userRepo = new Repository(UserModel, [
2099
+ * cachePlugin({
2100
+ * adapter: {
2101
+ * async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
2102
+ * async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
2103
+ * async del(key) { await redis.del(key); },
2104
+ * async clear(pattern) {
2105
+ * const keys = await redis.keys(pattern || '*');
2106
+ * if (keys.length) await redis.del(...keys);
2107
+ * }
2108
+ * },
2109
+ * ttl: 60, // 1 minute default
1518
2110
  * })
1519
2111
  * ]);
1520
- * ```
1521
2112
  *
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
- * ]);
2113
+ * // Reads check cache first
2114
+ * const user = await userRepo.getById(id); // cached
2115
+ *
2116
+ * // Skip cache for fresh data
2117
+ * const fresh = await userRepo.getById(id, { skipCache: true });
2118
+ *
2119
+ * // Mutations auto-invalidate
2120
+ * await userRepo.update(id, { name: 'New Name' }); // invalidates cache
2121
+ *
2122
+ * // Manual invalidation for microservice sync
2123
+ * await userRepo.invalidateCache(id); // invalidate single doc
2124
+ * await userRepo.invalidateAllCache(); // invalidate all for this model
1530
2125
  * ```
1531
2126
  */
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;
2127
+ /**
2128
+ * Cache plugin factory
2129
+ *
2130
+ * @param options - Cache configuration
2131
+ * @returns Plugin instance
2132
+ */
2133
+ function cachePlugin(options) {
2134
+ const config = {
2135
+ adapter: options.adapter,
2136
+ ttl: options.ttl ?? 60,
2137
+ byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
2138
+ queryTtl: options.queryTtl ?? options.ttl ?? 60,
2139
+ prefix: options.prefix ?? "mk",
2140
+ debug: options.debug ?? false,
2141
+ skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
2142
+ };
2143
+ const stats = {
2144
+ hits: 0,
2145
+ misses: 0,
2146
+ sets: 0,
2147
+ invalidations: 0,
2148
+ errors: 0
2149
+ };
2150
+ const log = (msg, data) => {
2151
+ if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
2152
+ };
1539
2153
  return {
1540
- name: "softDelete",
2154
+ name: "cache",
1541
2155
  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 } }`);
2156
+ const model = repo.model;
2157
+ const byIdKeyRegistry = /* @__PURE__ */ new Map();
2158
+ function trackByIdKey(docId, cacheKey) {
2159
+ let keys = byIdKeyRegistry.get(docId);
2160
+ if (!keys) {
2161
+ keys = /* @__PURE__ */ new Set();
2162
+ byIdKeyRegistry.set(docId, keys);
1547
2163
  }
1548
- } catch (err) {
1549
- warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
1550
- }
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
- });
2164
+ keys.add(cacheKey);
1559
2165
  }
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;
2166
+ async function getVersion() {
2167
+ try {
2168
+ return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
2169
+ } catch (e) {
2170
+ log(`Cache error in getVersion for ${model}:`, e);
2171
+ return 0;
1574
2172
  }
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
- };
2173
+ }
2174
+ /**
2175
+ * Bump collection version in the adapter (invalidates all list caches).
2176
+ * Uses Date.now() so version always moves forward — safe after eviction or deploy.
2177
+ */
2178
+ async function bumpVersion() {
2179
+ const newVersion = Date.now();
2180
+ try {
2181
+ await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
2182
+ stats.invalidations++;
2183
+ log(`Bumped version for ${model} to:`, newVersion);
2184
+ } catch (e) {
2185
+ log(`Failed to bump version for ${model}:`, e);
1583
2186
  }
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
- };
2187
+ }
2188
+ /**
2189
+ * Invalidate a specific document by ID (all shape variants).
2190
+ * Deletes every tracked shape-variant key individually via del(),
2191
+ * so adapters without pattern-based clear() still get full invalidation.
2192
+ */
2193
+ async function invalidateById(id) {
2194
+ try {
2195
+ const baseKey = byIdKey(config.prefix, model, id);
2196
+ await config.adapter.del(baseKey);
2197
+ const trackedKeys = byIdKeyRegistry.get(id);
2198
+ if (trackedKeys) {
2199
+ for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
2200
+ byIdKeyRegistry.delete(id);
2201
+ }
2202
+ stats.invalidations++;
2203
+ log(`Invalidated byId cache for:`, id);
2204
+ } catch (e) {
2205
+ log(`Failed to invalidate byId cache:`, e);
1592
2206
  }
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
- };
2207
+ }
2208
+ /**
2209
+ * before:getById - Check cache for document
2210
+ * Runs at CACHE priority (200) after policy hooks inject filters
2211
+ */
2212
+ repo.on("before:getById", async (context) => {
2213
+ if (context.skipCache) {
2214
+ log(`Skipping cache for getById: ${context.id}`);
2215
+ return;
1601
2216
  }
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
- };
2217
+ const id = String(context.id);
2218
+ const key = byIdKey(config.prefix, model, id, {
2219
+ select: context.select,
2220
+ populate: context.populate,
2221
+ lean: context.lean
2222
+ });
2223
+ try {
2224
+ const cached = await config.adapter.get(key);
2225
+ if (cached !== null) {
2226
+ stats.hits++;
2227
+ log(`Cache HIT for getById:`, key);
2228
+ context._cacheHit = true;
2229
+ context._cachedResult = cached;
2230
+ } else {
2231
+ stats.misses++;
2232
+ log(`Cache MISS for getById:`, key);
2233
+ }
2234
+ } catch (e) {
2235
+ log(`Cache error for getById:`, e);
2236
+ stats.errors++;
1610
2237
  }
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
- };
2238
+ }, { priority: HOOK_PRIORITY.CACHE });
2239
+ /**
2240
+ * before:getByQuery - Check cache for single-doc query
2241
+ * Runs at CACHE priority (200) after policy hooks inject filters
2242
+ */
2243
+ repo.on("before:getByQuery", async (context) => {
2244
+ if (context.skipCache) {
2245
+ log(`Skipping cache for getByQuery`);
2246
+ return;
1619
2247
  }
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
- };
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
2253
+ });
2254
+ try {
2255
+ const cached = await config.adapter.get(key);
2256
+ if (cached !== null) {
2257
+ stats.hits++;
2258
+ log(`Cache HIT for getByQuery:`, key);
2259
+ context._cacheHit = true;
2260
+ context._cachedResult = cached;
2261
+ } else {
2262
+ stats.misses++;
2263
+ log(`Cache MISS for getByQuery:`, key);
2264
+ }
2265
+ } catch (e) {
2266
+ log(`Cache error for getByQuery:`, e);
2267
+ stats.errors++;
1628
2268
  }
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
- };
2269
+ }, { priority: HOOK_PRIORITY.CACHE });
2270
+ /**
2271
+ * before:getAll - Check cache for list query
2272
+ * Runs at CACHE priority (200) after policy hooks inject filters
2273
+ */
2274
+ repo.on("before:getAll", async (context) => {
2275
+ if (context.skipCache) {
2276
+ log(`Skipping cache for getAll`);
2277
+ return;
1637
2278
  }
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
- };
2279
+ const limit = context.limit;
2280
+ if (limit && limit > config.skipIfLargeLimit) {
2281
+ log(`Skipping cache for large query (limit: ${limit})`);
2282
+ return;
1646
2283
  }
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
- };
2284
+ const collectionVersion = await getVersion();
2285
+ const params = {
2286
+ filters: context.filters,
2287
+ sort: context.sort,
2288
+ page: context.page,
2289
+ limit,
2290
+ after: context.after,
2291
+ select: context.select,
2292
+ populate: context.populate,
2293
+ search: context.search,
2294
+ mode: context.mode,
2295
+ lean: context.lean,
2296
+ readPreference: context.readPreference,
2297
+ hint: context.hint,
2298
+ maxTimeMS: context.maxTimeMS,
2299
+ countStrategy: context.countStrategy
2300
+ };
2301
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
2302
+ try {
2303
+ const cached = await config.adapter.get(key);
2304
+ if (cached !== null) {
2305
+ stats.hits++;
2306
+ log(`Cache HIT for getAll:`, key);
2307
+ context._cacheHit = true;
2308
+ context._cachedResult = cached;
2309
+ } else {
2310
+ stats.misses++;
2311
+ log(`Cache MISS for getAll:`, key);
2312
+ }
2313
+ } catch (e) {
2314
+ log(`Cache error for getAll:`, e);
2315
+ stats.errors++;
1655
2316
  }
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 = [];
2317
+ }, { priority: HOOK_PRIORITY.CACHE });
1754
2318
  /**
1755
- * Register a new method on the repository instance
2319
+ * after:getById - Cache the result
1756
2320
  */
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
2321
+ repo.on("after:getById", async (payload) => {
2322
+ const { context, result } = payload;
2323
+ if (context._cacheHit) return;
2324
+ if (context.skipCache) return;
2325
+ if (result === null) return;
2326
+ const id = String(context.id);
2327
+ const key = byIdKey(config.prefix, model, id, {
2328
+ select: context.select,
2329
+ populate: context.populate,
2330
+ lean: context.lean
1766
2331
  });
1767
- };
2332
+ const ttl = context.cacheTtl ?? config.byIdTtl;
2333
+ try {
2334
+ await config.adapter.set(key, result, ttl);
2335
+ trackByIdKey(id, key);
2336
+ stats.sets++;
2337
+ log(`Cached getById result:`, key);
2338
+ } catch (e) {
2339
+ log(`Failed to cache getById:`, e);
2340
+ }
2341
+ });
1768
2342
  /**
1769
- * Check if a method is registered
2343
+ * after:getByQuery - Cache the result
2344
+ */
2345
+ repo.on("after:getByQuery", async (payload) => {
2346
+ const { context, result } = payload;
2347
+ if (context._cacheHit) return;
2348
+ if (context.skipCache) return;
2349
+ if (result === null) return;
2350
+ const collectionVersion = await getVersion();
2351
+ const query = context.query || {};
2352
+ const key = byQueryKey(config.prefix, model, collectionVersion, query, {
2353
+ select: context.select,
2354
+ populate: context.populate
2355
+ });
2356
+ const ttl = context.cacheTtl ?? config.queryTtl;
2357
+ try {
2358
+ await config.adapter.set(key, result, ttl);
2359
+ stats.sets++;
2360
+ log(`Cached getByQuery result:`, key);
2361
+ } catch (e) {
2362
+ log(`Failed to cache getByQuery:`, e);
2363
+ }
2364
+ });
2365
+ /**
2366
+ * after:getAll - Cache the result
2367
+ */
2368
+ repo.on("after:getAll", async (payload) => {
2369
+ const { context, result } = payload;
2370
+ if (context._cacheHit) return;
2371
+ if (context.skipCache) return;
2372
+ const limit = context.limit;
2373
+ if (limit && limit > config.skipIfLargeLimit) return;
2374
+ const collectionVersion = await getVersion();
2375
+ const params = {
2376
+ filters: context.filters,
2377
+ sort: context.sort,
2378
+ page: context.page,
2379
+ limit,
2380
+ after: context.after,
2381
+ select: context.select,
2382
+ populate: context.populate,
2383
+ search: context.search,
2384
+ mode: context.mode,
2385
+ lean: context.lean,
2386
+ readPreference: context.readPreference,
2387
+ hint: context.hint,
2388
+ maxTimeMS: context.maxTimeMS,
2389
+ countStrategy: context.countStrategy
2390
+ };
2391
+ const key = listQueryKey(config.prefix, model, collectionVersion, params);
2392
+ const ttl = context.cacheTtl ?? config.queryTtl;
2393
+ try {
2394
+ await config.adapter.set(key, result, ttl);
2395
+ stats.sets++;
2396
+ log(`Cached getAll result:`, key);
2397
+ } catch (e) {
2398
+ log(`Failed to cache getAll:`, e);
2399
+ }
2400
+ });
2401
+ /**
2402
+ * after:create - Bump version to invalidate list caches
2403
+ */
2404
+ repo.on("after:create", async () => {
2405
+ await bumpVersion();
2406
+ });
2407
+ /**
2408
+ * after:createMany - Bump version to invalidate list caches
2409
+ */
2410
+ repo.on("after:createMany", async () => {
2411
+ await bumpVersion();
2412
+ });
2413
+ /**
2414
+ * after:update - Invalidate by ID and bump version
2415
+ */
2416
+ repo.on("after:update", async (payload) => {
2417
+ const { context } = payload;
2418
+ const id = String(context.id);
2419
+ await Promise.all([invalidateById(id), bumpVersion()]);
2420
+ });
2421
+ /**
2422
+ * after:updateMany - Bump version (can't track individual IDs efficiently)
2423
+ */
2424
+ repo.on("after:updateMany", async () => {
2425
+ await bumpVersion();
2426
+ });
2427
+ /**
2428
+ * after:delete - Invalidate by ID and bump version
2429
+ */
2430
+ repo.on("after:delete", async (payload) => {
2431
+ const { context } = payload;
2432
+ const id = String(context.id);
2433
+ await Promise.all([invalidateById(id), bumpVersion()]);
2434
+ });
2435
+ /**
2436
+ * after:deleteMany - Bump version
2437
+ */
2438
+ repo.on("after:deleteMany", async () => {
2439
+ await bumpVersion();
2440
+ });
2441
+ /**
2442
+ * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
1770
2443
  */
1771
- repo.hasMethod = function(name) {
1772
- return typeof repo[name] === "function";
2444
+ repo.on("after:bulkWrite", async () => {
2445
+ await bumpVersion();
2446
+ });
2447
+ /**
2448
+ * Invalidate cache for a specific document
2449
+ * Use when document was updated outside this service
2450
+ *
2451
+ * @example
2452
+ * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
2453
+ */
2454
+ repo.invalidateCache = async (id) => {
2455
+ await invalidateById(id);
2456
+ log(`Manual invalidation for ID:`, id);
1773
2457
  };
1774
2458
  /**
1775
- * Get list of all dynamically registered methods
2459
+ * Invalidate all list caches for this model
2460
+ * Use when bulk changes happened outside this service
2461
+ *
2462
+ * @example
2463
+ * await userRepo.invalidateListCache();
1776
2464
  */
1777
- repo.getRegisteredMethods = function() {
1778
- return [...registeredMethods];
2465
+ repo.invalidateListCache = async () => {
2466
+ await bumpVersion();
2467
+ log(`Manual list cache invalidation for ${model}`);
1779
2468
  };
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
- });
2469
+ /**
2470
+ * Invalidate ALL cache entries for this model
2471
+ * Nuclear option - use sparingly
2472
+ *
2473
+ * @example
2474
+ * await userRepo.invalidateAllCache();
2475
+ */
2476
+ repo.invalidateAllCache = async () => {
2477
+ if (config.adapter.clear) try {
2478
+ await config.adapter.clear(modelPattern(config.prefix, model));
2479
+ stats.invalidations++;
2480
+ log(`Full cache invalidation for ${model}`);
2481
+ } catch (e) {
2482
+ log(`Failed full cache invalidation for ${model}:`, e);
1840
2483
  }
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;
2484
+ else {
2485
+ await bumpVersion();
2486
+ log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
1845
2487
  }
1846
2488
  };
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`);
2489
+ /**
2490
+ * Get cache statistics for monitoring
2491
+ *
2492
+ * @example
2493
+ * const stats = userRepo.getCacheStats();
2494
+ * console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
2495
+ */
2496
+ repo.getCacheStats = () => ({ ...stats });
2497
+ /**
2498
+ * Reset cache statistics
2499
+ */
2500
+ repo.resetCacheStats = () => {
2501
+ stats.hits = 0;
2502
+ stats.misses = 0;
2503
+ stats.sets = 0;
2504
+ stats.invalidations = 0;
2505
+ stats.errors = 0;
2506
+ };
1933
2507
  }
1934
2508
  };
1935
2509
  }
1936
-
1937
2510
  //#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
- */
2511
+ //#region src/plugins/cascade.plugin.ts
1945
2512
  /**
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
2513
+ * Cascade Delete Plugin
2514
+ * Automatically deletes related documents when a parent document is deleted
1955
2515
  *
1956
- * @example Basic usage (no TypeScript autocomplete)
2516
+ * @example
1957
2517
  * ```typescript
1958
- * const repo = new Repository(ProductModel, [
2518
+ * import mongoose from 'mongoose';
2519
+ * import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
2520
+ *
2521
+ * const productRepo = new Repository(Product, [
1959
2522
  * methodRegistryPlugin(),
1960
- * mongoOperationsPlugin(),
2523
+ * cascadePlugin({
2524
+ * relations: [
2525
+ * { model: 'StockEntry', foreignKey: 'product' },
2526
+ * { model: 'StockMovement', foreignKey: 'product' },
2527
+ * ]
2528
+ * })
1961
2529
  * ]);
1962
2530
  *
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');
2531
+ * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
2532
+ * await productRepo.delete(productId);
1966
2533
  * ```
2534
+ */
2535
+ /**
2536
+ * Cascade delete plugin
1967
2537
  *
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;
2538
+ * Deletes related documents after the parent document is deleted.
2539
+ * Works with both hard delete and soft delete scenarios.
1984
2540
  *
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
- * ```
2541
+ * @param options - Cascade configuration
2542
+ * @returns Plugin
1990
2543
  */
1991
- function mongoOperationsPlugin() {
2544
+ function cascadePlugin(options) {
2545
+ const { relations, parallel = true, logger } = options;
2546
+ if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
1992
2547
  return {
1993
- name: "mongo-operations",
2548
+ name: "cascade",
1994
2549
  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);
2550
+ repo.on("after:delete", async (payload) => {
2551
+ const { context } = payload;
2552
+ const deletedId = context.id;
2553
+ if (!deletedId) {
2554
+ logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
2555
+ return;
2556
+ }
2557
+ const isSoftDelete = context.softDeleted === true;
2558
+ const cascadeDelete = async (relation) => {
2559
+ const RelatedModel = mongoose.models[relation.model];
2560
+ if (!RelatedModel) {
2561
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2562
+ parentModel: context.model,
2563
+ parentId: String(deletedId)
2564
+ });
2565
+ return;
2566
+ }
2567
+ const query = { [relation.foreignKey]: deletedId };
2568
+ try {
2569
+ if (relation.softDelete ?? isSoftDelete) {
2570
+ const updateResult = await RelatedModel.updateMany(query, {
2571
+ deletedAt: /* @__PURE__ */ new Date(),
2572
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2573
+ }, { session: context.session });
2574
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2575
+ parentModel: context.model,
2576
+ parentId: String(deletedId),
2577
+ relatedModel: relation.model,
2578
+ foreignKey: relation.foreignKey,
2579
+ count: updateResult.modifiedCount
2580
+ });
2581
+ } else {
2582
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2583
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2584
+ parentModel: context.model,
2585
+ parentId: String(deletedId),
2586
+ relatedModel: relation.model,
2587
+ foreignKey: relation.foreignKey,
2588
+ count: deleteResult.deletedCount
2589
+ });
2590
+ }
2591
+ } catch (error) {
2592
+ logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2593
+ parentModel: context.model,
2594
+ parentId: String(deletedId),
2595
+ relatedModel: relation.model,
2596
+ foreignKey: relation.foreignKey,
2597
+ error: error.message
2598
+ });
2599
+ throw error;
2600
+ }
2601
+ };
2602
+ if (parallel) {
2603
+ const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
2604
+ if (failures.length) {
2605
+ const err = failures[0].reason;
2606
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2607
+ throw err;
2608
+ }
2609
+ } else for (const relation of relations) await cascadeDelete(relation);
2072
2610
  });
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);
2611
+ repo.on("before:deleteMany", async (context) => {
2612
+ const query = context.query;
2613
+ if (!query || Object.keys(query).length === 0) return;
2614
+ context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
2078
2615
  });
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);
2616
+ repo.on("after:deleteMany", async (payload) => {
2617
+ const { context } = payload;
2618
+ const ids = context._cascadeIds;
2619
+ if (!ids || ids.length === 0) return;
2620
+ const isSoftDelete = context.softDeleted === true;
2621
+ const cascadeDeleteMany = async (relation) => {
2622
+ const RelatedModel = mongoose.models[relation.model];
2623
+ if (!RelatedModel) {
2624
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
2625
+ return;
2626
+ }
2627
+ const query = { [relation.foreignKey]: { $in: ids } };
2628
+ const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2629
+ try {
2630
+ if (shouldSoftDelete) {
2631
+ const updateResult = await RelatedModel.updateMany(query, {
2632
+ deletedAt: /* @__PURE__ */ new Date(),
2633
+ ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2634
+ }, { session: context.session });
2635
+ logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2636
+ parentModel: context.model,
2637
+ parentCount: ids.length,
2638
+ relatedModel: relation.model,
2639
+ foreignKey: relation.foreignKey,
2640
+ count: updateResult.modifiedCount
2641
+ });
2642
+ } else {
2643
+ const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2644
+ logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2645
+ parentModel: context.model,
2646
+ parentCount: ids.length,
2647
+ relatedModel: relation.model,
2648
+ foreignKey: relation.foreignKey,
2649
+ count: deleteResult.deletedCount
2650
+ });
2651
+ }
2652
+ } catch (error) {
2653
+ logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2654
+ parentModel: context.model,
2655
+ relatedModel: relation.model,
2656
+ foreignKey: relation.foreignKey,
2657
+ error: error.message
2658
+ });
2659
+ throw error;
2660
+ }
2661
+ };
2662
+ if (parallel) {
2663
+ const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
2664
+ if (failures.length) {
2665
+ const err = failures[0].reason;
2666
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2667
+ throw err;
2668
+ }
2669
+ } else for (const relation of relations) await cascadeDeleteMany(relation);
2131
2670
  });
2132
2671
  }
2133
2672
  };
2134
2673
  }
2135
-
2136
2674
  //#endregion
2137
- //#region src/plugins/batch-operations.plugin.ts
2675
+ //#region src/plugins/custom-id.plugin.ts
2138
2676
  /**
2139
- * Batch operations plugin
2140
- *
2141
- * @example
2142
- * const repo = new Repository(Model, [
2143
- * methodRegistryPlugin(),
2144
- * batchOperationsPlugin(),
2145
- * ]);
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
- };
2262
- }
2263
-
2264
- //#endregion
2265
- //#region src/plugins/aggregate-helpers.plugin.ts
2266
- /**
2267
- * Aggregate helpers plugin
2268
- *
2269
- * @example
2270
- * const repo = new Repository(Model, [
2271
- * methodRegistryPlugin(),
2272
- * aggregateHelpersPlugin(),
2677
+ * Custom ID Plugin
2678
+ *
2679
+ * Generates custom document IDs using pluggable generators.
2680
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
2681
+ * date-partitioned sequences, and fully custom generators.
2682
+ *
2683
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
2684
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
2685
+ *
2686
+ * @example Basic sequential counter
2687
+ * ```typescript
2688
+ * const invoiceRepo = new Repository(InvoiceModel, [
2689
+ * customIdPlugin({
2690
+ * field: 'invoiceNumber',
2691
+ * generator: sequentialId({
2692
+ * prefix: 'INV',
2693
+ * model: InvoiceModel,
2694
+ * }),
2695
+ * }),
2273
2696
  * ]);
2274
- *
2275
- * const groups = await repo.groupBy('category');
2276
- * const total = await repo.sum('amount', { status: 'completed' });
2697
+ *
2698
+ * const inv = await invoiceRepo.create({ amount: 100 });
2699
+ * // inv.invoiceNumber "INV-0001"
2700
+ * ```
2701
+ *
2702
+ * @example Date-partitioned counter (resets monthly)
2703
+ * ```typescript
2704
+ * const billRepo = new Repository(BillModel, [
2705
+ * customIdPlugin({
2706
+ * field: 'billNumber',
2707
+ * generator: dateSequentialId({
2708
+ * prefix: 'BILL',
2709
+ * model: BillModel,
2710
+ * partition: 'monthly',
2711
+ * separator: '-',
2712
+ * padding: 4,
2713
+ * }),
2714
+ * }),
2715
+ * ]);
2716
+ *
2717
+ * const bill = await billRepo.create({ total: 250 });
2718
+ * // bill.billNumber → "BILL-2026-02-0001"
2719
+ * ```
2720
+ *
2721
+ * @example Custom generator function
2722
+ * ```typescript
2723
+ * const orderRepo = new Repository(OrderModel, [
2724
+ * customIdPlugin({
2725
+ * field: 'orderRef',
2726
+ * generator: async (context) => {
2727
+ * const region = context.data?.region || 'US';
2728
+ * const seq = await getNextSequence('orders');
2729
+ * return `ORD-${region}-${seq}`;
2730
+ * },
2731
+ * }),
2732
+ * ]);
2733
+ * ```
2277
2734
  */
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
- };
2735
+ /** Schema for the internal counters collection */
2736
+ const counterSchema = new mongoose.Schema({
2737
+ _id: {
2738
+ type: String,
2739
+ required: true
2740
+ },
2741
+ seq: {
2742
+ type: Number,
2743
+ default: 0
2744
+ }
2745
+ }, {
2746
+ collection: "_mongokit_counters",
2747
+ versionKey: false
2748
+ });
2749
+ /**
2750
+ * Get or create the Counter model on the given connection.
2751
+ * Falls back to the default mongoose connection if none is provided.
2752
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
2753
+ */
2754
+ function getCounterModel(connection) {
2755
+ const conn = connection ?? mongoose.connection;
2756
+ if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
2757
+ return conn.model("_MongoKitCounter", counterSchema);
2327
2758
  }
2328
-
2329
- //#endregion
2330
- //#region src/plugins/subdocument.plugin.ts
2331
2759
  /**
2332
- * Subdocument plugin for managing nested arrays
2333
- *
2760
+ * Atomically increment and return the next sequence value for a given key.
2761
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
2762
+ * heavy concurrency.
2763
+ *
2764
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
2765
+ * @param increment - Value to increment by (default: 1)
2766
+ * @returns The next sequence number (after increment)
2767
+ *
2334
2768
  * @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' });
2769
+ * const seq = await getNextSequence('invoices');
2770
+ * // First call → 1, second → 2, ...
2771
+ *
2772
+ * @example Batch increment for createMany
2773
+ * const startSeq = await getNextSequence('invoices', 5);
2774
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
2342
2775
  */
2343
- function subdocumentPlugin() {
2776
+ async function getNextSequence(counterKey, increment = 1, connection) {
2777
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
2778
+ upsert: true,
2779
+ returnDocument: "after"
2780
+ });
2781
+ if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
2782
+ return result.seq;
2783
+ }
2784
+ /**
2785
+ * Generator: Simple sequential counter.
2786
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
2787
+ *
2788
+ * Uses atomic MongoDB counters — safe under concurrency.
2789
+ *
2790
+ * @example
2791
+ * ```typescript
2792
+ * customIdPlugin({
2793
+ * field: 'invoiceNumber',
2794
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2795
+ * })
2796
+ * ```
2797
+ */
2798
+ function sequentialId(options) {
2799
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
2800
+ const key = counterKey || model.modelName;
2801
+ return async (context) => {
2802
+ const seq = await getNextSequence(key, 1, context._counterConnection);
2803
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
2804
+ };
2805
+ }
2806
+ /**
2807
+ * Generator: Date-partitioned sequential counter.
2808
+ * Counter resets per period — great for invoice/bill numbering.
2809
+ *
2810
+ * Produces IDs like:
2811
+ * - yearly: `BILL-2026-0001`
2812
+ * - monthly: `BILL-2026-02-0001`
2813
+ * - daily: `BILL-2026-02-20-0001`
2814
+ *
2815
+ * @example
2816
+ * ```typescript
2817
+ * customIdPlugin({
2818
+ * field: 'billNumber',
2819
+ * generator: dateSequentialId({
2820
+ * prefix: 'BILL',
2821
+ * model: BillModel,
2822
+ * partition: 'monthly',
2823
+ * }),
2824
+ * })
2825
+ * ```
2826
+ */
2827
+ function dateSequentialId(options) {
2828
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
2829
+ return async (context) => {
2830
+ const now = /* @__PURE__ */ new Date();
2831
+ const year = String(now.getFullYear());
2832
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2833
+ const day = String(now.getDate()).padStart(2, "0");
2834
+ let datePart;
2835
+ let counterKey;
2836
+ switch (partition) {
2837
+ case "yearly":
2838
+ datePart = year;
2839
+ counterKey = `${model.modelName}:${year}`;
2840
+ break;
2841
+ case "daily":
2842
+ datePart = `${year}${separator}${month}${separator}${day}`;
2843
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
2844
+ break;
2845
+ default:
2846
+ datePart = `${year}${separator}${month}`;
2847
+ counterKey = `${model.modelName}:${year}-${month}`;
2848
+ break;
2849
+ }
2850
+ const seq = await getNextSequence(counterKey, 1, context._counterConnection);
2851
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
2852
+ };
2853
+ }
2854
+ /**
2855
+ * Generator: Prefix + random alphanumeric suffix.
2856
+ * Does NOT require a database round-trip — purely in-memory.
2857
+ *
2858
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
2859
+ *
2860
+ * Good for: user-facing IDs where ordering doesn't matter.
2861
+ * Not suitable for sequential numbering.
2862
+ *
2863
+ * @example
2864
+ * ```typescript
2865
+ * customIdPlugin({
2866
+ * field: 'publicId',
2867
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
2868
+ * })
2869
+ * ```
2870
+ */
2871
+ function prefixedId(options) {
2872
+ const { prefix, separator = "_", length = 12 } = options;
2873
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
2874
+ return (_context) => {
2875
+ let result = "";
2876
+ const bytes = new Uint8Array(length);
2877
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
2878
+ globalThis.crypto.getRandomValues(bytes);
2879
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
2880
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
2881
+ return `${prefix}${separator}${result}`;
2882
+ };
2883
+ }
2884
+ /**
2885
+ * Custom ID plugin — injects generated IDs into documents before creation.
2886
+ *
2887
+ * @param options - Configuration for ID generation
2888
+ * @returns Plugin instance
2889
+ *
2890
+ * @example
2891
+ * ```typescript
2892
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
2893
+ *
2894
+ * const invoiceRepo = new Repository(InvoiceModel, [
2895
+ * customIdPlugin({
2896
+ * field: 'invoiceNumber',
2897
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
2898
+ * }),
2899
+ * ]);
2900
+ *
2901
+ * const inv = await invoiceRepo.create({ amount: 100 });
2902
+ * console.log(inv.invoiceNumber); // "INV-0001"
2903
+ * ```
2904
+ */
2905
+ function customIdPlugin(options) {
2906
+ const fieldName = options.field || "customId";
2907
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
2344
2908
  return {
2345
- name: "subdocument",
2909
+ name: "custom-id",
2346
2910
  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);
2353
- });
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;
2366
- });
2911
+ const repoConnection = repo.Model.db;
2912
+ repo.on("before:create", async (context) => {
2913
+ if (!context.data) return;
2914
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
2915
+ context._counterConnection = repoConnection;
2916
+ context.data[fieldName] = await options.generator(context);
2367
2917
  });
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;
2918
+ repo.on("before:createMany", async (context) => {
2919
+ if (!context.dataArray) return;
2920
+ context._counterConnection = repoConnection;
2921
+ const docsNeedingIds = [];
2922
+ for (const doc of context.dataArray) {
2923
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
2924
+ docsNeedingIds.push(doc);
2925
+ }
2926
+ if (docsNeedingIds.length === 0) return;
2927
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
2928
+ ...context,
2929
+ data: doc
2388
2930
  });
2389
2931
  });
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);
2395
- });
2396
2932
  }
2397
2933
  };
2398
2934
  }
2399
-
2400
2935
  //#endregion
2401
- //#region src/plugins/cache.plugin.ts
2402
- /**
2403
- * Cache plugin factory
2404
- *
2405
- * @param options - Cache configuration
2406
- * @returns Plugin instance
2407
- */
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 ?? "");
2427
- };
2936
+ //#region src/plugins/elastic.plugin.ts
2937
+ function elasticSearchPlugin(options) {
2428
2938
  return {
2429
- name: "cache",
2939
+ name: "elastic-search",
2430
2940
  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
- }
2449
- /**
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.
2452
- */
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
- }
2463
- /**
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.
2467
- */
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);
2941
+ if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
2942
+ repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
2943
+ const { client, index, idField = "_id" } = options;
2944
+ const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
2945
+ const from = Math.max(searchOptions.from || 0, 0);
2946
+ const esResponse = await client.search({
2947
+ index,
2948
+ body: {
2949
+ query: searchQuery,
2950
+ size: limit,
2951
+ from
2476
2952
  }
2477
- stats.invalidations++;
2478
- log(`Invalidated byId cache for:`, id);
2479
- } catch (e) {
2480
- log(`Failed to invalidate byId cache:`, e);
2481
- }
2482
- }
2483
- /**
2484
- * before:getById - Check cache for document
2485
- * Runs at CACHE priority (200) — after policy hooks inject filters
2486
- */
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
2953
  });
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);
2954
+ const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
2955
+ if (hits.length === 0) return {
2956
+ docs: [],
2957
+ total: 0,
2958
+ limit,
2959
+ from
2960
+ };
2961
+ const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
2962
+ const total = typeof totalValue === "number" ? totalValue : 0;
2963
+ const docsOrder = /* @__PURE__ */ new Map();
2964
+ const scores = /* @__PURE__ */ new Map();
2965
+ const ids = [];
2966
+ hits.forEach((hit, idx) => {
2967
+ const docId = hit._source?.[idField] || hit[idField] || hit._id;
2968
+ if (docId) {
2969
+ const strId = String(docId);
2970
+ docsOrder.set(strId, idx);
2971
+ if (hit._score !== void 0) scores.set(strId, hit._score);
2972
+ ids.push(strId);
2508
2973
  }
2509
- } catch (e) {
2510
- log(`Cache error for getById:`, e);
2511
- stats.errors++;
2512
- }
2513
- }, { priority: HOOK_PRIORITY.CACHE });
2514
- /**
2515
- * before:getByQuery - Check cache for single-doc query
2516
- * Runs at CACHE priority (200) — after policy hooks inject filters
2517
- */
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
2974
  });
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 });
2545
- /**
2546
- * before:getAll - Check cache for list query
2547
- * Runs at CACHE priority (200) — after policy hooks inject filters
2548
- */
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,
2975
+ if (ids.length === 0) return {
2976
+ docs: [],
2977
+ total,
2564
2978
  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
2979
+ from
2575
2980
  };
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 });
2981
+ const mongoQuery = this.Model.find({ _id: { $in: ids } });
2982
+ if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
2983
+ if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
2984
+ if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
2985
+ return {
2986
+ docs: (await mongoQuery.exec()).sort((a, b) => {
2987
+ const aId = String(a._id);
2988
+ const bId = String(b._id);
2989
+ return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
2990
+ }).map((doc) => {
2991
+ const strId = String(doc._id);
2992
+ if (searchOptions.mongoOptions?.lean !== false) return {
2993
+ ...doc,
2994
+ _score: scores.get(strId)
2995
+ };
2996
+ return doc;
2997
+ }),
2998
+ total,
2999
+ limit,
3000
+ from
3001
+ };
3002
+ });
3003
+ }
3004
+ };
3005
+ }
3006
+ //#endregion
3007
+ //#region src/plugins/field-filter.plugin.ts
3008
+ /**
3009
+ * Field filter plugin that restricts fields based on user context
3010
+ *
3011
+ * @example
3012
+ * const fieldPreset = {
3013
+ * public: ['id', 'name'],
3014
+ * authenticated: ['email'],
3015
+ * admin: ['createdAt', 'internalNotes']
3016
+ * };
3017
+ *
3018
+ * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
3019
+ */
3020
+ function fieldFilterPlugin(fieldPreset) {
3021
+ return {
3022
+ name: "fieldFilter",
3023
+ apply(repo) {
3024
+ const applyFieldFiltering = (context) => {
3025
+ if (!fieldPreset) return;
3026
+ const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
3027
+ if (context.select) context.select = `${presetSelect} ${context.select}`;
3028
+ else context.select = presetSelect;
3029
+ };
3030
+ repo.on("before:getAll", applyFieldFiltering);
3031
+ repo.on("before:getById", applyFieldFiltering);
3032
+ repo.on("before:getByQuery", applyFieldFiltering);
3033
+ }
3034
+ };
3035
+ }
3036
+ //#endregion
3037
+ //#region src/plugins/method-registry.plugin.ts
3038
+ /**
3039
+ * Method registry plugin that enables dynamic method registration
3040
+ */
3041
+ function methodRegistryPlugin() {
3042
+ return {
3043
+ name: "method-registry",
3044
+ apply(repo) {
3045
+ const registeredMethods = [];
2593
3046
  /**
2594
- * after:getById - Cache the result
3047
+ * Register a new method on the repository instance
2595
3048
  */
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
3049
+ repo.registerMethod = (name, fn) => {
3050
+ 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.`);
3051
+ if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
3052
+ if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
3053
+ repo[name] = fn.bind(repo);
3054
+ registeredMethods.push(name);
3055
+ repo.emit("method:registered", {
3056
+ name,
3057
+ fn
2606
3058
  });
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
- }
2616
- });
3059
+ };
2617
3060
  /**
2618
- * after:getByQuery - Cache the result
3061
+ * Check if a method is registered
2619
3062
  */
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
- }
3063
+ repo.hasMethod = (name) => typeof repo[name] === "function";
3064
+ /**
3065
+ * Get list of all dynamically registered methods
3066
+ */
3067
+ repo.getRegisteredMethods = () => [...registeredMethods];
3068
+ }
3069
+ };
3070
+ }
3071
+ //#endregion
3072
+ //#region src/plugins/mongo-operations.plugin.ts
3073
+ /**
3074
+ * MongoDB Operations Plugin
3075
+ *
3076
+ * Adds MongoDB-specific operations to repositories.
3077
+ * Requires method-registry.plugin.js to be loaded first.
3078
+ */
3079
+ /**
3080
+ * MongoDB operations plugin
3081
+ *
3082
+ * Adds MongoDB-specific atomic operations to repositories:
3083
+ * - upsert: Create or update document
3084
+ * - increment/decrement: Atomic numeric operations
3085
+ * - pushToArray/pullFromArray/addToSet: Array operations
3086
+ * - setField/unsetField/renameField: Field operations
3087
+ * - multiplyField: Multiply numeric field
3088
+ * - setMin/setMax: Conditional min/max updates
3089
+ *
3090
+ * @example Basic usage (no TypeScript autocomplete)
3091
+ * ```typescript
3092
+ * const repo = new Repository(ProductModel, [
3093
+ * methodRegistryPlugin(),
3094
+ * mongoOperationsPlugin(),
3095
+ * ]);
3096
+ *
3097
+ * // Works at runtime but TypeScript doesn't know about these methods
3098
+ * await (repo as any).increment(productId, 'views', 1);
3099
+ * await (repo as any).pushToArray(productId, 'tags', 'featured');
3100
+ * ```
3101
+ *
3102
+ * @example With TypeScript type safety (recommended)
3103
+ * ```typescript
3104
+ * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
3105
+ * import type { MongoOperationsMethods } from '@classytic/mongokit';
3106
+ *
3107
+ * class ProductRepo extends Repository<IProduct> {
3108
+ * // Add your custom methods here
3109
+ * }
3110
+ *
3111
+ * // Create with type assertion to get autocomplete for plugin methods
3112
+ * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
3113
+ *
3114
+ * const repo = new ProductRepo(ProductModel, [
3115
+ * methodRegistryPlugin(),
3116
+ * mongoOperationsPlugin(),
3117
+ * ]) as ProductRepoWithPlugins;
3118
+ *
3119
+ * // Now TypeScript provides autocomplete and type checking!
3120
+ * await repo.increment(productId, 'views', 1);
3121
+ * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
3122
+ * await repo.pushToArray(productId, 'tags', 'featured');
3123
+ * ```
3124
+ */
3125
+ function mongoOperationsPlugin() {
3126
+ return {
3127
+ name: "mongo-operations",
3128
+ apply(repo) {
3129
+ if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
3130
+ /**
3131
+ * Update existing document or insert new one
3132
+ */
3133
+ repo.registerMethod("upsert", async function(query, data, options = {}) {
3134
+ return upsert(this.Model, query, data, options);
2639
3135
  });
3136
+ const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
3137
+ if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
3138
+ return this.update(id, { [operator]: { [field]: value } }, options);
3139
+ };
2640
3140
  /**
2641
- * after:getAll - Cache the result
3141
+ * Atomically increment numeric field
2642
3142
  */
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
- }
3143
+ repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
3144
+ return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
2675
3145
  });
2676
3146
  /**
2677
- * after:create - Bump version to invalidate list caches
3147
+ * Atomically decrement numeric field
2678
3148
  */
2679
- repo.on("after:create", async () => {
2680
- await bumpVersion();
3149
+ repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
3150
+ return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
2681
3151
  });
3152
+ const applyOperator = function(id, field, value, operator, options) {
3153
+ return this.update(id, { [operator]: { [field]: value } }, options);
3154
+ };
2682
3155
  /**
2683
- * after:createMany - Bump version to invalidate list caches
3156
+ * Push value to array field
2684
3157
  */
2685
- repo.on("after:createMany", async () => {
2686
- await bumpVersion();
3158
+ repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
3159
+ return applyOperator.call(this, id, field, value, "$push", options);
2687
3160
  });
2688
3161
  /**
2689
- * after:update - Invalidate by ID and bump version
3162
+ * Remove value from array field
2690
3163
  */
2691
- repo.on("after:update", async (payload) => {
2692
- const { context } = payload;
2693
- const id = String(context.id);
2694
- await Promise.all([invalidateById(id), bumpVersion()]);
3164
+ repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
3165
+ return applyOperator.call(this, id, field, value, "$pull", options);
2695
3166
  });
2696
3167
  /**
2697
- * after:updateMany - Bump version (can't track individual IDs efficiently)
3168
+ * Add value to array only if not already present (unique)
2698
3169
  */
2699
- repo.on("after:updateMany", async () => {
2700
- await bumpVersion();
3170
+ repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
3171
+ return applyOperator.call(this, id, field, value, "$addToSet", options);
2701
3172
  });
2702
3173
  /**
2703
- * after:delete - Invalidate by ID and bump version
3174
+ * Set field value (alias for update with $set)
2704
3175
  */
2705
- repo.on("after:delete", async (payload) => {
2706
- const { context } = payload;
2707
- const id = String(context.id);
2708
- await Promise.all([invalidateById(id), bumpVersion()]);
3176
+ repo.registerMethod("setField", async function(id, field, value, options = {}) {
3177
+ return applyOperator.call(this, id, field, value, "$set", options);
2709
3178
  });
2710
3179
  /**
2711
- * after:deleteMany - Bump version
3180
+ * Unset (remove) field from document
2712
3181
  */
2713
- repo.on("after:deleteMany", async () => {
2714
- await bumpVersion();
3182
+ repo.registerMethod("unsetField", async function(id, fields, options = {}) {
3183
+ const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
3184
+ acc[field] = "";
3185
+ return acc;
3186
+ }, {});
3187
+ return this.update(id, { $unset: unsetObj }, options);
2715
3188
  });
2716
3189
  /**
2717
- * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
3190
+ * Rename field in document
2718
3191
  */
2719
- repo.on("after:bulkWrite", async () => {
2720
- await bumpVersion();
3192
+ repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
3193
+ return this.update(id, { $rename: { [oldName]: newName } }, options);
2721
3194
  });
2722
3195
  /**
2723
- * Invalidate cache for a specific document
2724
- * Use when document was updated outside this service
2725
- *
2726
- * @example
2727
- * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
3196
+ * Multiply numeric field by value
2728
3197
  */
2729
- repo.invalidateCache = async (id) => {
2730
- await invalidateById(id);
2731
- log(`Manual invalidation for ID:`, id);
2732
- };
3198
+ repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
3199
+ return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
3200
+ });
2733
3201
  /**
2734
- * Invalidate all list caches for this model
2735
- * Use when bulk changes happened outside this service
2736
- *
2737
- * @example
2738
- * await userRepo.invalidateListCache();
3202
+ * Set field to minimum value (only if current value is greater)
2739
3203
  */
2740
- repo.invalidateListCache = async () => {
2741
- await bumpVersion();
2742
- log(`Manual list cache invalidation for ${model}`);
2743
- };
3204
+ repo.registerMethod("setMin", async function(id, field, value, options = {}) {
3205
+ return applyOperator.call(this, id, field, value, "$min", options);
3206
+ });
2744
3207
  /**
2745
- * Invalidate ALL cache entries for this model
2746
- * Nuclear option - use sparingly
2747
- *
2748
- * @example
2749
- * await userRepo.invalidateAllCache();
3208
+ * Set field to maximum value (only if current value is less)
2750
3209
  */
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
- };
3210
+ repo.registerMethod("setMax", async function(id, field, value, options = {}) {
3211
+ return applyOperator.call(this, id, field, value, "$max", options);
3212
+ });
2764
3213
  /**
2765
- * Get cache statistics for monitoring
3214
+ * Atomic update with multiple MongoDB operators in a single call
3215
+ *
3216
+ * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
3217
+ * into one atomic database operation.
2766
3218
  *
2767
3219
  * @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
2774
- */
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);
3220
+ * // Combine $inc + $set in one atomic call
3221
+ * await repo.atomicUpdate(id, {
3222
+ * $inc: { views: 1, commentCount: 1 },
3223
+ * $set: { lastActiveAt: new Date() }
3224
+ * });
3225
+ *
3226
+ * // Multiple operators: $inc + $set + $push
3227
+ * await repo.atomicUpdate(id, {
3228
+ * $inc: { 'metrics.total': 1 },
3229
+ * $set: { updatedAt: new Date() },
3230
+ * $push: { history: { action: 'update', at: new Date() } }
3231
+ * });
3232
+ *
3233
+ * // $push with $each modifier
3234
+ * await repo.atomicUpdate(id, {
3235
+ * $push: { tags: { $each: ['featured', 'popular'] } },
3236
+ * $inc: { tagCount: 2 }
3237
+ * });
3238
+ *
3239
+ * // With arrayFilters for positional updates
3240
+ * await repo.atomicUpdate(id, {
3241
+ * $set: { 'items.$[elem].quantity': 5 }
3242
+ * }, { arrayFilters: [{ 'elem._id': itemId }] });
3243
+ */
3244
+ repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
3245
+ const validOperators = new Set([
3246
+ "$inc",
3247
+ "$set",
3248
+ "$unset",
3249
+ "$push",
3250
+ "$pull",
3251
+ "$addToSet",
3252
+ "$pop",
3253
+ "$rename",
3254
+ "$min",
3255
+ "$max",
3256
+ "$mul",
3257
+ "$setOnInsert",
3258
+ "$bit",
3259
+ "$currentDate"
3260
+ ]);
3261
+ const keys = Object.keys(operators);
3262
+ if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
3263
+ for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
3264
+ return this.update(id, operators, options);
2946
3265
  });
2947
3266
  }
2948
3267
  };
2949
3268
  }
2950
-
2951
3269
  //#endregion
2952
3270
  //#region src/plugins/multi-tenant.plugin.ts
2953
3271
  /**
@@ -3086,7 +3404,6 @@ function multiTenantPlugin(options = {}) {
3086
3404
  }
3087
3405
  };
3088
3406
  }
3089
-
3090
3407
  //#endregion
3091
3408
  //#region src/plugins/observability.plugin.ts
3092
3409
  const DEFAULT_OPS = [
@@ -3147,691 +3464,538 @@ function observabilityPlugin(options) {
3147
3464
  }
3148
3465
  };
3149
3466
  }
3150
-
3151
3467
  //#endregion
3152
- //#region src/plugins/audit-trail.plugin.ts
3468
+ //#region src/plugins/soft-delete.plugin.ts
3153
3469
  /**
3154
- * Audit Trail Plugin
3470
+ * Build filter condition based on filter mode
3471
+ */
3472
+ function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
3473
+ if (includeDeleted) return {};
3474
+ if (filterMode === "exists") return { [deletedField]: { $exists: false } };
3475
+ return { [deletedField]: null };
3476
+ }
3477
+ /**
3478
+ * Build filter condition for finding deleted documents
3479
+ */
3480
+ function buildGetDeletedFilter(deletedField, filterMode) {
3481
+ if (filterMode === "exists") return { [deletedField]: {
3482
+ $exists: true,
3483
+ $ne: null
3484
+ } };
3485
+ return { [deletedField]: { $ne: null } };
3486
+ }
3487
+ /**
3488
+ * Soft delete plugin
3155
3489
  *
3156
- * Persists operation audit entries to a MongoDB collection.
3157
- * Fire-and-forget: writes happen async and never block or fail the main operation.
3490
+ * @example Basic usage
3491
+ * ```typescript
3492
+ * const repo = new Repository(Model, [
3493
+ * softDeletePlugin({ deletedField: 'deletedAt' })
3494
+ * ]);
3158
3495
  *
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
3496
+ * // Delete (soft)
3497
+ * await repo.delete(id);
3165
3498
  *
3166
- * @example
3499
+ * // Restore
3500
+ * await repo.restore(id);
3501
+ *
3502
+ * // Get deleted documents
3503
+ * await repo.getDeleted({ page: 1, limit: 20 });
3504
+ * ```
3505
+ *
3506
+ * @example With null filter mode (for schemas with default: null)
3167
3507
  * ```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
- * }),
3508
+ * // Schema: { deletedAt: { type: Date, default: null } }
3509
+ * const repo = new Repository(Model, [
3510
+ * softDeletePlugin({
3511
+ * deletedField: 'deletedAt',
3512
+ * filterMode: 'null', // default - works with default: null
3513
+ * })
3514
+ * ]);
3515
+ * ```
3516
+ *
3517
+ * @example With TTL for auto-cleanup
3518
+ * ```typescript
3519
+ * const repo = new Repository(Model, [
3520
+ * softDeletePlugin({
3521
+ * deletedField: 'deletedAt',
3522
+ * ttlDays: 30, // Auto-delete after 30 days
3523
+ * })
3177
3524
  * ]);
3178
3525
  * ```
3179
3526
  */
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);
3527
+ function softDeletePlugin(options = {}) {
3528
+ const deletedField = options.deletedField || "deletedAt";
3529
+ const deletedByField = options.deletedByField || "deletedBy";
3530
+ const filterMode = options.filterMode || "null";
3531
+ const addRestoreMethod = options.addRestoreMethod !== false;
3532
+ const addGetDeletedMethod = options.addGetDeletedMethod !== false;
3533
+ const ttlDays = options.ttlDays;
3299
3534
  return {
3300
- name: "auditTrail",
3535
+ name: "softDelete",
3301
3536
  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)
3313
- });
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}`);
3323
- }
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);
3332
- }
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)
3341
- });
3342
- });
3537
+ try {
3538
+ const schemaPaths = repo.Model.schema.paths;
3539
+ for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
3540
+ if (pathName === "_id" || pathName === deletedField) continue;
3541
+ 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 } }`);
3542
+ }
3543
+ } catch (err) {
3544
+ warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
3343
3545
  }
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)
3546
+ if (ttlDays !== void 0 && ttlDays > 0) {
3547
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
3548
+ repo.Model.collection.createIndex({ [deletedField]: 1 }, {
3549
+ expireAfterSeconds: ttlSeconds,
3550
+ partialFilterExpression: { [deletedField]: { $type: "date" } }
3551
+ }).catch((err) => {
3552
+ if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
3352
3553
  });
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
3375
- };
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
3554
+ }
3555
+ repo.on("before:delete", async (context) => {
3556
+ if (options.soft !== false) {
3557
+ const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
3558
+ if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
3559
+ const deleteQuery = {
3560
+ _id: context.id,
3561
+ ...context.query || {}
3562
+ };
3563
+ if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
3564
+ const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
3565
+ error.status = 404;
3566
+ throw error;
3519
3567
  }
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);
3568
+ context.softDeleted = true;
3569
+ }
3570
+ }, { priority: HOOK_PRIORITY.POLICY });
3571
+ repo.on("before:getAll", (context) => {
3572
+ if (options.soft !== false) {
3573
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3574
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
3575
+ ...context.filters || {},
3576
+ ...deleteFilter
3577
+ };
3578
+ }
3579
+ }, { priority: HOOK_PRIORITY.POLICY });
3580
+ repo.on("before:getById", (context) => {
3581
+ if (options.soft !== false) {
3582
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3583
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3584
+ ...context.query || {},
3585
+ ...deleteFilter
3586
+ };
3587
+ }
3588
+ }, { priority: HOOK_PRIORITY.POLICY });
3589
+ repo.on("before:getByQuery", (context) => {
3590
+ if (options.soft !== false) {
3591
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3592
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3593
+ ...context.query || {},
3594
+ ...deleteFilter
3595
+ };
3596
+ }
3597
+ }, { priority: HOOK_PRIORITY.POLICY });
3598
+ repo.on("before:count", (context) => {
3599
+ if (options.soft !== false) {
3600
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3601
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3602
+ ...context.query || {},
3603
+ ...deleteFilter
3604
+ };
3605
+ }
3606
+ }, { priority: HOOK_PRIORITY.POLICY });
3607
+ repo.on("before:exists", (context) => {
3608
+ if (options.soft !== false) {
3609
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3610
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3611
+ ...context.query || {},
3612
+ ...deleteFilter
3613
+ };
3614
+ }
3615
+ }, { priority: HOOK_PRIORITY.POLICY });
3616
+ repo.on("before:getOrCreate", (context) => {
3617
+ if (options.soft !== false) {
3618
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3619
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3620
+ ...context.query || {},
3621
+ ...deleteFilter
3622
+ };
3623
+ }
3624
+ }, { priority: HOOK_PRIORITY.POLICY });
3625
+ repo.on("before:distinct", (context) => {
3626
+ if (options.soft !== false) {
3627
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3628
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3629
+ ...context.query || {},
3630
+ ...deleteFilter
3631
+ };
3632
+ }
3633
+ }, { priority: HOOK_PRIORITY.POLICY });
3634
+ repo.on("before:updateMany", (context) => {
3635
+ if (options.soft !== false) {
3636
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3637
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3638
+ ...context.query || {},
3639
+ ...deleteFilter
3640
+ };
3641
+ }
3642
+ }, { priority: HOOK_PRIORITY.POLICY });
3643
+ repo.on("before:deleteMany", async (context) => {
3644
+ if (options.soft !== false) {
3645
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
3646
+ const finalQuery = {
3647
+ ...context.query || {},
3648
+ ...deleteFilter
3649
+ };
3650
+ await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
3651
+ context.softDeleted = true;
3652
+ }
3653
+ }, { priority: HOOK_PRIORITY.POLICY });
3654
+ repo.on("before:aggregate", (context) => {
3655
+ if (options.soft !== false) {
3656
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3657
+ if (Object.keys(deleteFilter).length > 0) context.query = {
3658
+ ...context.query || {},
3659
+ ...deleteFilter
3660
+ };
3661
+ }
3662
+ }, { priority: HOOK_PRIORITY.POLICY });
3663
+ repo.on("before:aggregatePaginate", (context) => {
3664
+ if (options.soft !== false) {
3665
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
3666
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
3667
+ ...context.filters || {},
3668
+ ...deleteFilter
3669
+ };
3670
+ }
3671
+ }, { priority: HOOK_PRIORITY.POLICY });
3672
+ if (addRestoreMethod) {
3673
+ const restoreMethod = async function(id, restoreOptions = {}) {
3674
+ const context = await this._buildContext("restore", {
3675
+ id,
3676
+ ...restoreOptions
3677
+ });
3678
+ const updateData = {
3679
+ [deletedField]: null,
3680
+ [deletedByField]: null
3681
+ };
3682
+ const restoreQuery = {
3683
+ _id: id,
3684
+ ...context.query || {}
3685
+ };
3686
+ const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
3687
+ returnDocument: "after",
3688
+ session: restoreOptions.session
3689
+ });
3690
+ if (!result) {
3691
+ const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
3692
+ error.status = 404;
3693
+ throw error;
3540
3694
  }
3541
- });
3542
- if (ids.length === 0) return {
3543
- docs: [],
3544
- total,
3545
- limit,
3546
- from
3695
+ await this.emitAsync("after:restore", {
3696
+ id,
3697
+ result,
3698
+ context
3699
+ });
3700
+ return result;
3547
3701
  };
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
3702
+ if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
3703
+ else repo.restore = restoreMethod.bind(repo);
3704
+ }
3705
+ if (addGetDeletedMethod) {
3706
+ const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
3707
+ const context = await this._buildContext("getDeleted", {
3708
+ ...params,
3709
+ ...getDeletedOptions
3710
+ });
3711
+ const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
3712
+ const combinedFilters = {
3713
+ ...params.filters || {},
3714
+ ...deletedFilter,
3715
+ ...context.filters || {},
3716
+ ...context.query || {}
3717
+ };
3718
+ const page = params.page || 1;
3719
+ const limit = params.limit || 20;
3720
+ const skip = (page - 1) * limit;
3721
+ let sortSpec = { [deletedField]: -1 };
3722
+ if (params.sort) if (typeof params.sort === "string") {
3723
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
3724
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
3725
+ } else sortSpec = params.sort;
3726
+ let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
3727
+ if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
3728
+ if (getDeletedOptions.select) {
3729
+ const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
3730
+ query = query.select(selectValue);
3731
+ }
3732
+ if (getDeletedOptions.populate) {
3733
+ const populateSpec = getDeletedOptions.populate;
3734
+ if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
3735
+ else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
3736
+ else query = query.populate(populateSpec);
3737
+ }
3738
+ if (getDeletedOptions.lean !== false) query = query.lean();
3739
+ const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
3740
+ const pages = Math.ceil(total / limit);
3741
+ return {
3742
+ method: "offset",
3743
+ docs,
3744
+ page,
3745
+ limit,
3746
+ total,
3747
+ pages,
3748
+ hasNext: page < pages,
3749
+ hasPrev: page > 1
3750
+ };
3568
3751
  };
3569
- });
3752
+ if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
3753
+ else repo.getDeleted = getDeletedMethod.bind(repo);
3754
+ }
3570
3755
  }
3571
3756
  };
3572
3757
  }
3573
-
3574
3758
  //#endregion
3575
- //#region src/plugins/custom-id.plugin.ts
3759
+ //#region src/plugins/subdocument.plugin.ts
3576
3760
  /**
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
- * ```
3761
+ * Subdocument plugin for managing nested arrays
3601
3762
  *
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
- * }),
3763
+ * @example
3764
+ * const repo = new Repository(Model, [
3765
+ * methodRegistryPlugin(),
3766
+ * subdocumentPlugin(),
3615
3767
  * ]);
3616
3768
  *
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
- * ```
3769
+ * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
3770
+ * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
3634
3771
  */
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
- });
3772
+ function subdocumentPlugin() {
3773
+ return {
3774
+ name: "subdocument",
3775
+ apply(repo) {
3776
+ if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
3777
+ /**
3778
+ * Add subdocument to array
3779
+ */
3780
+ repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
3781
+ return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
3782
+ });
3783
+ /**
3784
+ * Get subdocument from array
3785
+ */
3786
+ repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
3787
+ return this._executeQuery(async (Model) => {
3788
+ const parent = await Model.findById(parentId).session(options.session).exec();
3789
+ if (!parent) throw createError(404, "Parent not found");
3790
+ const arrayField = parent[arrayPath];
3791
+ if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
3792
+ const sub = arrayField.id(subId);
3793
+ if (!sub) throw createError(404, "Subdocument not found");
3794
+ return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
3795
+ });
3796
+ });
3797
+ /**
3798
+ * Update subdocument in array
3799
+ */
3800
+ repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
3801
+ return this._executeQuery(async (Model) => {
3802
+ const query = {
3803
+ _id: parentId,
3804
+ [`${arrayPath}._id`]: subId
3805
+ };
3806
+ const update = { $set: { [`${arrayPath}.$`]: {
3807
+ ...updateData,
3808
+ _id: subId
3809
+ } } };
3810
+ const result = await Model.findOneAndUpdate(query, update, {
3811
+ returnDocument: "after",
3812
+ runValidators: true,
3813
+ session: options.session
3814
+ }).exec();
3815
+ if (!result) throw createError(404, "Parent or subdocument not found");
3816
+ return result;
3817
+ });
3818
+ });
3819
+ /**
3820
+ * Delete subdocument from array
3821
+ */
3822
+ repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
3823
+ return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
3824
+ });
3825
+ }
3826
+ };
3827
+ }
3828
+ //#endregion
3829
+ //#region src/plugins/timestamp.plugin.ts
3649
3830
  /**
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.
3831
+ * Timestamp plugin that auto-injects timestamps
3832
+ *
3833
+ * @example
3834
+ * const repo = new Repository(Model, [timestampPlugin()]);
3653
3835
  */
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);
3836
+ function timestampPlugin() {
3837
+ return {
3838
+ name: "timestamp",
3839
+ apply(repo) {
3840
+ repo.on("before:create", (context) => {
3841
+ if (!context.data) return;
3842
+ const now = /* @__PURE__ */ new Date();
3843
+ if (!context.data.createdAt) context.data.createdAt = now;
3844
+ if (!context.data.updatedAt) context.data.updatedAt = now;
3845
+ });
3846
+ repo.on("before:update", (context) => {
3847
+ if (!context.data) return;
3848
+ context.data.updatedAt = /* @__PURE__ */ new Date();
3849
+ });
3850
+ }
3851
+ };
3658
3852
  }
3853
+ //#endregion
3854
+ //#region src/plugins/validation-chain.plugin.ts
3659
3855
  /**
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)
3856
+ * Validation chain plugin
3667
3857
  *
3668
3858
  * @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)
3859
+ * const repo = new Repository(Model, [
3860
+ * validationChainPlugin([
3861
+ * requireField('email'),
3862
+ * uniqueField('email', 'Email already exists'),
3863
+ * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
3864
+ * ])
3865
+ * ]);
3675
3866
  */
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"
3867
+ function validationChainPlugin(validators = [], options = {}) {
3868
+ const { stopOnFirstError = true } = options;
3869
+ validators.forEach((v, idx) => {
3870
+ if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
3871
+ if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
3680
3872
  });
3681
- if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
3682
- return result.seq;
3873
+ const validatorsByOperation = {
3874
+ create: [],
3875
+ update: [],
3876
+ delete: [],
3877
+ createMany: []
3878
+ };
3879
+ const allOperationsValidators = [];
3880
+ validators.forEach((v) => {
3881
+ if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
3882
+ else v.operations.forEach((op) => {
3883
+ if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
3884
+ });
3885
+ });
3886
+ return {
3887
+ name: "validation-chain",
3888
+ apply(repo) {
3889
+ const getValidatorsForOperation = (operation) => {
3890
+ const specific = validatorsByOperation[operation] || [];
3891
+ return [...allOperationsValidators, ...specific];
3892
+ };
3893
+ const runValidators = async (operation, context) => {
3894
+ const operationValidators = getValidatorsForOperation(operation);
3895
+ const errors = [];
3896
+ for (const validator of operationValidators) try {
3897
+ await validator.validate(context, repo);
3898
+ } catch (error) {
3899
+ if (stopOnFirstError) throw error;
3900
+ errors.push({
3901
+ validator: validator.name,
3902
+ error: error.message || String(error)
3903
+ });
3904
+ }
3905
+ if (errors.length > 0) {
3906
+ const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
3907
+ err.validationErrors = errors;
3908
+ throw err;
3909
+ }
3910
+ };
3911
+ repo.on("before:create", async (context) => runValidators("create", context));
3912
+ repo.on("before:createMany", async (context) => runValidators("createMany", context));
3913
+ repo.on("before:update", async (context) => runValidators("update", context));
3914
+ repo.on("before:delete", async (context) => runValidators("delete", context));
3915
+ }
3916
+ };
3683
3917
  }
3684
3918
  /**
3685
- * Generator: Simple sequential counter.
3686
- * Produces IDs like `INV-0001`, `INV-0002`, etc.
3687
- *
3688
- * Uses atomic MongoDB counters — safe under concurrency.
3919
+ * Block operation if condition is true
3689
3920
  *
3690
3921
  * @example
3691
- * ```typescript
3692
- * customIdPlugin({
3693
- * field: 'invoiceNumber',
3694
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
3695
- * })
3696
- * ```
3922
+ * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
3697
3923
  */
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")}`;
3924
+ function blockIf(name, operations, condition, errorMessage) {
3925
+ return {
3926
+ name,
3927
+ operations,
3928
+ validate: (context) => {
3929
+ if (condition(context)) throw createError(403, errorMessage);
3930
+ }
3704
3931
  };
3705
3932
  }
3706
3933
  /**
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
- * ```
3934
+ * Require a field to be present
3726
3935
  */
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;
3936
+ function requireField(field, operations = ["create"]) {
3937
+ return {
3938
+ name: `require-${field}`,
3939
+ operations,
3940
+ validate: (context) => {
3941
+ if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
3749
3942
  }
3750
- const seq = await getNextSequence(counterKey, 1, context._counterConnection);
3751
- return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
3752
3943
  };
3753
3944
  }
3754
3945
  /**
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
- * ```
3946
+ * Auto-inject a value if not present
3770
3947
  */
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}`;
3948
+ function autoInject(field, getter, operations = ["create"]) {
3949
+ return {
3950
+ name: `auto-inject-${field}`,
3951
+ operations,
3952
+ validate: (context) => {
3953
+ if (context.data && !(field in context.data)) {
3954
+ const value = getter(context);
3955
+ if (value !== null && value !== void 0) context.data[field] = value;
3956
+ }
3957
+ }
3782
3958
  };
3783
3959
  }
3784
3960
  /**
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
- * ```
3961
+ * Make a field immutable (cannot be updated)
3804
3962
  */
3805
- function customIdPlugin(options) {
3806
- const fieldName = options.field || "customId";
3807
- const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
3963
+ function immutableField(field) {
3808
3964
  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
- });
3965
+ name: `immutable-${field}`,
3966
+ operations: ["update"],
3967
+ validate: (context) => {
3968
+ if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
3969
+ }
3970
+ };
3971
+ }
3972
+ /**
3973
+ * Ensure field value is unique
3974
+ */
3975
+ function uniqueField(field, errorMessage) {
3976
+ return {
3977
+ name: `unique-${field}`,
3978
+ operations: ["create", "update"],
3979
+ validate: async (context, repo) => {
3980
+ if (!context.data?.[field]) return;
3981
+ if (!repo) {
3982
+ warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
3983
+ return;
3984
+ }
3985
+ const query = { [field]: context.data[field] };
3986
+ const getByQuery = repo.getByQuery;
3987
+ if (typeof getByQuery !== "function") {
3988
+ warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
3989
+ return;
3990
+ }
3991
+ const existing = await getByQuery.call(repo, query, {
3992
+ select: "_id",
3993
+ lean: true,
3994
+ throwOnNotFound: false
3831
3995
  });
3996
+ if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
3832
3997
  }
3833
3998
  };
3834
3999
  }
3835
-
3836
4000
  //#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 };
4001
+ 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 };