@classytic/mongokit 3.11.1 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +75 -5
  2. package/dist/{PaginationEngine-CvDFfVXT.mjs → PaginationEngine-CfQRM1yz.mjs} +16 -16
  3. package/dist/Repository-BY40uhcv.mjs +3603 -0
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/actions/index.mjs +1 -1
  6. package/dist/adapter/index.d.mts +190 -0
  7. package/dist/adapter/index.mjs +2 -0
  8. package/dist/adapter-DTDMnUOH.mjs +158 -0
  9. package/dist/ai/index.d.mts +1 -1
  10. package/dist/ai/index.mjs +3 -3
  11. package/dist/better-auth/index.d.mts +149 -0
  12. package/dist/better-auth/index.mjs +81 -0
  13. package/dist/field-selection-Br3WPOEb.mjs +99 -0
  14. package/dist/{index-dNTmpgK8.d.mts → index-BbHaKwi5.d.mts} +33 -13
  15. package/dist/index.d.mts +678 -36
  16. package/dist/index.mjs +240 -17
  17. package/dist/{logger-DJ7egMnd.mjs → logger-DprijP5h.mjs} +1 -5
  18. package/dist/{mongooseToJsonSchema-D1sdCOmj.mjs → mongooseToJsonSchema-BYbG894y.mjs} +68 -20
  19. package/dist/{mongooseToJsonSchema-DXxjFkLB.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +10 -2
  20. package/dist/pagination/PaginationEngine.d.mts +3 -2
  21. package/dist/pagination/PaginationEngine.mjs +1 -1
  22. package/dist/plugins/index.d.mts +2 -2
  23. package/dist/plugins/index.mjs +2 -2
  24. package/dist/query/primitives/coercion.d.mts +1 -1
  25. package/dist/query/primitives/coercion.mjs +1 -1
  26. package/dist/query/primitives/indexes.d.mts +1 -1
  27. package/dist/{types-Dc2aDVUi.d.mts → types-BbaPZleN.d.mts} +419 -205
  28. package/dist/{update-DEJYcgfV.mjs → update-DLcSSa5b.mjs} +184 -45
  29. package/dist/utils/index.d.mts +2 -91
  30. package/dist/utils/index.mjs +5 -5
  31. package/dist/{validation-chain.plugin-B0VSdcwa.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +228 -98
  32. package/dist/validation-chain.plugin-_-WuNml2.mjs +2399 -0
  33. package/package.json +25 -5
  34. package/dist/field-selection-dI0m4RBs.mjs +0 -221
  35. package/dist/validation-chain.plugin-Dllv-cml.mjs +0 -5122
  36. /package/dist/{coercion-oM8G4b6m.d.mts → coercion-TRMmR49h.d.mts} +0 -0
  37. /package/dist/{id-resolution-e4BT9zAV.mjs → id-resolution-B0Odvvou.mjs} +0 -0
  38. /package/dist/{indexes-C26GNZRU.d.mts → indexes-CgOF7Wjd.d.mts} +0 -0
@@ -0,0 +1,2399 @@
1
+ import { _ as upsert } from "./update-DLcSSa5b.mjs";
2
+ import { n as warn } from "./logger-DprijP5h.mjs";
3
+ import { t as createError } from "./error-B00pI7dV.mjs";
4
+ import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-BY40uhcv.mjs";
5
+ import { r as getFieldsForUser } from "./field-selection-Br3WPOEb.mjs";
6
+ import mongoose from "mongoose";
7
+ import { cachePlugin } from "@classytic/repo-core/cache";
8
+ import { adminBypass, payloadHasTenantField } from "@classytic/repo-core/plugins";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ //#region src/plugins/aggregate-helpers.plugin.ts
11
+ /**
12
+ * Aggregate helpers plugin
13
+ *
14
+ * @example
15
+ * const repo = new Repository(Model, [
16
+ * methodRegistryPlugin(),
17
+ * aggregateHelpersPlugin(),
18
+ * ]);
19
+ *
20
+ * const groups = await repo.groupBy('category');
21
+ * const total = await repo.sum('amount', { status: 'completed' });
22
+ */
23
+ function aggregateHelpersPlugin() {
24
+ return {
25
+ name: "aggregate-helpers",
26
+ apply(repo) {
27
+ if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
28
+ /**
29
+ * Group by field
30
+ */
31
+ repo.registerMethod("groupBy", async function(field, options = {}) {
32
+ const pipeline = [{ $group: {
33
+ _id: `$${field}`,
34
+ count: { $sum: 1 }
35
+ } }, { $sort: { count: -1 } }];
36
+ if (options.limit) pipeline.push({ $limit: options.limit });
37
+ return this.aggregatePipeline(pipeline, options);
38
+ });
39
+ const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
40
+ const pipeline = [{ $match: query }, { $group: {
41
+ _id: null,
42
+ [resultKey]: { [operator]: `$${field}` }
43
+ } }];
44
+ return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
45
+ };
46
+ /**
47
+ * Sum field values
48
+ */
49
+ repo.registerMethod("sum", async function(field, query = {}, options = {}) {
50
+ return aggregateOperation.call(this, field, "$sum", "total", query, options);
51
+ });
52
+ /**
53
+ * Average field values
54
+ */
55
+ repo.registerMethod("average", async function(field, query = {}, options = {}) {
56
+ return aggregateOperation.call(this, field, "$avg", "avg", query, options);
57
+ });
58
+ /**
59
+ * Get minimum value
60
+ */
61
+ repo.registerMethod("min", async function(field, query = {}, options = {}) {
62
+ return aggregateOperation.call(this, field, "$min", "min", query, options);
63
+ });
64
+ /**
65
+ * Get maximum value
66
+ */
67
+ repo.registerMethod("max", async function(field, query = {}, options = {}) {
68
+ return aggregateOperation.call(this, field, "$max", "max", query, options);
69
+ });
70
+ }
71
+ };
72
+ }
73
+ //#endregion
74
+ //#region src/plugins/audit-log.plugin.ts
75
+ /**
76
+ * Audit log plugin that logs all repository operations
77
+ *
78
+ * @example
79
+ * const repo = new Repository(Model, [auditLogPlugin(console)]);
80
+ *
81
+ * Hot-path opt-out — skip per call by passing `skipPlugins: ['auditLog']`
82
+ * in the operation options. The plugin honors the flag for every
83
+ * listener it registers; security/correctness plugins (multi-tenant,
84
+ * cache invalidation, soft-delete) deliberately do NOT honor it.
85
+ */
86
+ const PLUGIN_NAME = "auditLog";
87
+ /** True when the caller passed `skipPlugins: ['auditLog', ...]`. */
88
+ function isSkipped(context) {
89
+ const list = context.skipPlugins;
90
+ return Array.isArray(list) && list.includes(PLUGIN_NAME);
91
+ }
92
+ function auditLogPlugin(logger) {
93
+ return {
94
+ name: PLUGIN_NAME,
95
+ apply(repo) {
96
+ repo.on("after:create", ({ context, result }) => {
97
+ if (isSkipped(context)) return;
98
+ const idKey = repo.idField || "_id";
99
+ logger?.info?.("Document created", {
100
+ model: context.model || repo.model,
101
+ id: result?.[idKey],
102
+ userId: context.user?._id || context.user?.id,
103
+ organizationId: context.organizationId
104
+ });
105
+ });
106
+ repo.on("after:update", ({ context, result }) => {
107
+ if (isSkipped(context)) return;
108
+ logger?.info?.("Document updated", {
109
+ model: context.model || repo.model,
110
+ id: context.id || result?.[repo.idField || "_id"],
111
+ userId: context.user?._id || context.user?.id,
112
+ organizationId: context.organizationId
113
+ });
114
+ });
115
+ repo.on("after:findOneAndUpdate", ({ context, result }) => {
116
+ if (isSkipped(context)) return;
117
+ if (!result) return;
118
+ logger?.info?.("Document upserted/updated (findOneAndUpdate)", {
119
+ model: context.model || repo.model,
120
+ id: result?.[repo.idField || "_id"],
121
+ userId: context.user?._id || context.user?.id,
122
+ organizationId: context.organizationId
123
+ });
124
+ });
125
+ repo.on("error:findOneAndUpdate", ({ context, error }) => {
126
+ if (isSkipped(context)) return;
127
+ logger?.error?.("findOneAndUpdate failed", {
128
+ model: context.model || repo.model,
129
+ error: error.message,
130
+ userId: context.user?._id || context.user?.id
131
+ });
132
+ });
133
+ repo.on("after:delete", ({ context }) => {
134
+ if (isSkipped(context)) return;
135
+ logger?.info?.("Document deleted", {
136
+ model: context.model || repo.model,
137
+ id: context.id,
138
+ userId: context.user?._id || context.user?.id,
139
+ organizationId: context.organizationId
140
+ });
141
+ });
142
+ repo.on("error:create", ({ context, error }) => {
143
+ if (isSkipped(context)) return;
144
+ logger?.error?.("Create failed", {
145
+ model: context.model || repo.model,
146
+ error: error.message,
147
+ userId: context.user?._id || context.user?.id
148
+ });
149
+ });
150
+ repo.on("error:update", ({ context, error }) => {
151
+ if (isSkipped(context)) return;
152
+ logger?.error?.("Update failed", {
153
+ model: context.model || repo.model,
154
+ id: context.id,
155
+ error: error.message,
156
+ userId: context.user?._id || context.user?.id
157
+ });
158
+ });
159
+ repo.on("error:delete", ({ context, error }) => {
160
+ if (isSkipped(context)) return;
161
+ logger?.error?.("Delete failed", {
162
+ model: context.model || repo.model,
163
+ id: context.id,
164
+ error: error.message,
165
+ userId: context.user?._id || context.user?.id
166
+ });
167
+ });
168
+ }
169
+ };
170
+ }
171
+ //#endregion
172
+ //#region src/plugins/audit-trail.plugin.ts
173
+ /**
174
+ * Audit Trail Plugin
175
+ *
176
+ * Persists operation audit entries to a MongoDB collection.
177
+ * Fire-and-forget: writes happen async and never block or fail the main operation.
178
+ *
179
+ * Features:
180
+ * - Tracks create, update, delete operations
181
+ * - Field-level change tracking (before/after diff on updates)
182
+ * - TTL auto-cleanup via MongoDB TTL index
183
+ * - Custom metadata per entry (IP, user-agent, etc.)
184
+ * - Shared `audit_trails` collection across all models
185
+ *
186
+ * @example
187
+ * ```typescript
188
+ * const repo = new Repository(Job, [
189
+ * auditTrailPlugin({
190
+ * operations: ['create', 'update', 'delete'],
191
+ * trackChanges: true,
192
+ * ttlDays: 90,
193
+ * metadata: (context) => ({
194
+ * ip: context.req?.ip,
195
+ * }),
196
+ * }),
197
+ * ]);
198
+ * ```
199
+ */
200
+ const modelCache = /* @__PURE__ */ new Map();
201
+ function getAuditModel(collectionName, ttlDays) {
202
+ const existing = modelCache.get(collectionName);
203
+ if (existing) return existing;
204
+ const schema = new mongoose.Schema({
205
+ model: {
206
+ type: String,
207
+ required: true,
208
+ index: true
209
+ },
210
+ operation: {
211
+ type: String,
212
+ required: true,
213
+ enum: [
214
+ "create",
215
+ "update",
216
+ "findOneAndUpdate",
217
+ "delete"
218
+ ]
219
+ },
220
+ documentId: {
221
+ type: mongoose.Schema.Types.Mixed,
222
+ required: true,
223
+ index: true
224
+ },
225
+ userId: {
226
+ type: mongoose.Schema.Types.Mixed,
227
+ index: true
228
+ },
229
+ orgId: {
230
+ type: mongoose.Schema.Types.Mixed,
231
+ index: true
232
+ },
233
+ changes: { type: mongoose.Schema.Types.Mixed },
234
+ document: { type: mongoose.Schema.Types.Mixed },
235
+ metadata: { type: mongoose.Schema.Types.Mixed },
236
+ timestamp: {
237
+ type: Date,
238
+ default: Date.now,
239
+ index: true
240
+ }
241
+ }, {
242
+ collection: collectionName,
243
+ versionKey: false
244
+ });
245
+ schema.index({
246
+ model: 1,
247
+ documentId: 1,
248
+ timestamp: -1
249
+ });
250
+ schema.index({
251
+ orgId: 1,
252
+ userId: 1,
253
+ timestamp: -1
254
+ });
255
+ if (ttlDays !== void 0 && ttlDays > 0) {
256
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
257
+ schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
258
+ }
259
+ const modelName = `AuditTrail_${collectionName}`;
260
+ const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
261
+ modelCache.set(collectionName, model);
262
+ return model;
263
+ }
264
+ /** Compute field-level diff between previous and updated document */
265
+ function computeChanges(prev, next, excludeFields) {
266
+ const changes = {};
267
+ const exclude = new Set(excludeFields);
268
+ for (const key of Object.keys(next)) {
269
+ if (exclude.has(key)) continue;
270
+ if (key === "_id" || key === "__v" || key === "updatedAt") continue;
271
+ const prevVal = prev[key];
272
+ const nextVal = next[key];
273
+ if (!deepEqual(prevVal, nextVal)) changes[key] = {
274
+ from: prevVal,
275
+ to: nextVal
276
+ };
277
+ }
278
+ return Object.keys(changes).length > 0 ? changes : void 0;
279
+ }
280
+ /** Simple deep equality check for audit diffing */
281
+ function deepEqual(a, b) {
282
+ if (a === b) return true;
283
+ if (a == null && b == null) return true;
284
+ if (a == null || b == null) return false;
285
+ if (typeof a === "object" && typeof b === "object") {
286
+ const aStr = a.toString?.();
287
+ const bStr = b.toString?.();
288
+ if (aStr && bStr && aStr === bStr) return true;
289
+ }
290
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
291
+ try {
292
+ return JSON.stringify(a) === JSON.stringify(b);
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+ /** Extract user ID from context */
298
+ function getUserId(context) {
299
+ return context.user?._id || context.user?.id;
300
+ }
301
+ /**
302
+ * Fire-and-forget audit write. Never throws into the caller's promise
303
+ * chain (audit failures must not break the primary operation), but
304
+ * routes errors to `onWriteError` for compliance hosts that need
305
+ * surface visibility. Default behavior: `console.warn`-only.
306
+ */
307
+ function writeAudit(AuditModel, entry, onWriteError) {
308
+ Promise.resolve().then(() => {
309
+ AuditModel.create({
310
+ ...entry,
311
+ timestamp: /* @__PURE__ */ new Date()
312
+ }).catch((err) => {
313
+ if (onWriteError) try {
314
+ onWriteError(err, entry);
315
+ } catch (cbErr) {
316
+ warn(`[auditTrailPlugin] onWriteError callback itself threw: ${cbErr.message}`);
317
+ }
318
+ else warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
319
+ });
320
+ });
321
+ }
322
+ const snapshots = /* @__PURE__ */ new WeakMap();
323
+ function auditTrailPlugin(options = {}) {
324
+ const { operations = [
325
+ "create",
326
+ "update",
327
+ "delete"
328
+ ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [], onWriteError } = options;
329
+ const opsSet = new Set(operations);
330
+ const isSkipped = (context) => {
331
+ const list = context.skipPlugins;
332
+ return Array.isArray(list) && list.includes("auditTrail");
333
+ };
334
+ return {
335
+ name: "auditTrail",
336
+ apply(repo) {
337
+ const AuditModel = getAuditModel(collectionName, ttlDays);
338
+ if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
339
+ if (isSkipped(context)) return;
340
+ const doc = toPlainObject(result);
341
+ const idKey = repo.idField || "_id";
342
+ writeAudit(AuditModel, {
343
+ model: context.model || repo.model,
344
+ operation: "create",
345
+ documentId: doc?.[idKey],
346
+ userId: getUserId(context),
347
+ orgId: context.organizationId,
348
+ document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
349
+ metadata: metadata?.(context)
350
+ }, onWriteError);
351
+ });
352
+ if (opsSet.has("update")) {
353
+ if (trackChanges) repo.on("before:update", async (context) => {
354
+ if (isSkipped(context)) return;
355
+ if (!context.id) return;
356
+ try {
357
+ const prev = await repo.Model.findById(context.id).lean();
358
+ if (prev) snapshots.set(context, prev);
359
+ } catch (err) {
360
+ warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
361
+ }
362
+ });
363
+ repo.on("after:update", ({ context, result }) => {
364
+ if (isSkipped(context)) return;
365
+ const doc = result;
366
+ let changes;
367
+ if (trackChanges) {
368
+ const prev = snapshots.get(context);
369
+ if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
370
+ snapshots.delete(context);
371
+ }
372
+ writeAudit(AuditModel, {
373
+ model: context.model || repo.model,
374
+ operation: "update",
375
+ documentId: context.id || doc?.[repo.idField || "_id"],
376
+ userId: getUserId(context),
377
+ orgId: context.organizationId,
378
+ changes,
379
+ metadata: metadata?.(context)
380
+ }, onWriteError);
381
+ });
382
+ }
383
+ if (opsSet.has("findOneAndUpdate")) repo.on("after:findOneAndUpdate", ({ context, result }) => {
384
+ if (isSkipped(context)) return;
385
+ if (!result) return;
386
+ const doc = result;
387
+ const idKey = repo.idField || "_id";
388
+ writeAudit(AuditModel, {
389
+ model: context.model || repo.model,
390
+ operation: "findOneAndUpdate",
391
+ documentId: doc?.[idKey],
392
+ userId: getUserId(context),
393
+ orgId: context.organizationId,
394
+ metadata: metadata?.(context)
395
+ }, onWriteError);
396
+ });
397
+ if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
398
+ if (isSkipped(context)) return;
399
+ writeAudit(AuditModel, {
400
+ model: context.model || repo.model,
401
+ operation: "delete",
402
+ documentId: context.id,
403
+ userId: getUserId(context),
404
+ orgId: context.organizationId,
405
+ metadata: metadata?.(context)
406
+ }, onWriteError);
407
+ });
408
+ if (typeof repo.registerMethod === "function")
409
+ /**
410
+ * Get audit trail for a specific document
411
+ */
412
+ repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
413
+ const { page = 1, limit = 20, operation } = queryOptions;
414
+ const skip = (page - 1) * limit;
415
+ const filter = {
416
+ model: this.model,
417
+ documentId
418
+ };
419
+ if (operation) filter.operation = operation;
420
+ const [data, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
421
+ return {
422
+ data,
423
+ page,
424
+ limit,
425
+ total,
426
+ pages: Math.ceil(total / limit),
427
+ hasNext: page < Math.ceil(total / limit),
428
+ hasPrev: page > 1
429
+ };
430
+ });
431
+ }
432
+ };
433
+ }
434
+ /** Convert Mongoose document to plain object */
435
+ function toPlainObject(doc) {
436
+ if (!doc) return {};
437
+ if (typeof doc.toObject === "function") return doc.toObject();
438
+ return doc;
439
+ }
440
+ /** Remove excluded fields from a document snapshot */
441
+ function sanitizeDoc(doc, excludeFields) {
442
+ if (excludeFields.length === 0) return doc;
443
+ const result = { ...doc };
444
+ for (const field of excludeFields) delete result[field];
445
+ return result;
446
+ }
447
+ /**
448
+ * Standalone audit trail query utility.
449
+ * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
450
+ *
451
+ * @example
452
+ * ```typescript
453
+ * import { AuditTrailQuery } from '@classytic/mongokit';
454
+ *
455
+ * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
456
+ *
457
+ * // All audits for an org
458
+ * const orgAudits = await auditQuery.query({ orgId: '...' });
459
+ *
460
+ * // All updates by a user
461
+ * const userUpdates = await auditQuery.query({
462
+ * userId: '...',
463
+ * operation: 'update',
464
+ * });
465
+ *
466
+ * // All audits for a specific document
467
+ * const docHistory = await auditQuery.query({
468
+ * model: 'Job',
469
+ * documentId: '...',
470
+ * });
471
+ *
472
+ * // Date range
473
+ * const recent = await auditQuery.query({
474
+ * from: new Date('2025-01-01'),
475
+ * to: new Date(),
476
+ * page: 1,
477
+ * limit: 50,
478
+ * });
479
+ *
480
+ * // Direct model access for custom queries
481
+ * const model = auditQuery.getModel();
482
+ * const count = await model.countDocuments({ operation: 'delete' });
483
+ * ```
484
+ */
485
+ var AuditTrailQuery = class {
486
+ model;
487
+ constructor(collectionName = "audit_trails", ttlDays) {
488
+ this.model = getAuditModel(collectionName, ttlDays);
489
+ }
490
+ /**
491
+ * Get the underlying Mongoose model for custom queries
492
+ */
493
+ getModel() {
494
+ return this.model;
495
+ }
496
+ /**
497
+ * Query audit entries with filters and pagination
498
+ */
499
+ async query(options = {}) {
500
+ const { page = 1, limit = 20 } = options;
501
+ const skip = (page - 1) * limit;
502
+ const filter = {};
503
+ if (options.model) filter.model = options.model;
504
+ if (options.documentId) filter.documentId = options.documentId;
505
+ if (options.userId) filter.userId = options.userId;
506
+ if (options.orgId) filter.orgId = options.orgId;
507
+ if (options.operation) filter.operation = options.operation;
508
+ if (options.from || options.to) {
509
+ const dateFilter = {};
510
+ if (options.from) dateFilter.$gte = options.from;
511
+ if (options.to) dateFilter.$lte = options.to;
512
+ filter.timestamp = dateFilter;
513
+ }
514
+ const [data, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
515
+ const pages = Math.ceil(total / limit);
516
+ return {
517
+ data,
518
+ page,
519
+ limit,
520
+ total,
521
+ pages,
522
+ hasNext: page < pages,
523
+ hasPrev: page > 1
524
+ };
525
+ }
526
+ /**
527
+ * Get audit trail for a specific document
528
+ */
529
+ async getDocumentTrail(model, documentId, options = {}) {
530
+ return this.query({
531
+ model,
532
+ documentId,
533
+ ...options
534
+ });
535
+ }
536
+ /**
537
+ * Get all audits for a user
538
+ */
539
+ async getUserTrail(userId, options = {}) {
540
+ return this.query({
541
+ userId,
542
+ ...options
543
+ });
544
+ }
545
+ /**
546
+ * Get all audits for an organization
547
+ */
548
+ async getOrgTrail(orgId, options = {}) {
549
+ return this.query({
550
+ orgId,
551
+ ...options
552
+ });
553
+ }
554
+ };
555
+ //#endregion
556
+ //#region src/plugins/batch-operations.plugin.ts
557
+ /**
558
+ * Batch operations plugin.
559
+ *
560
+ * @example
561
+ * const repo = new Repository(Model, [
562
+ * methodRegistryPlugin(),
563
+ * batchOperationsPlugin(),
564
+ * ]);
565
+ *
566
+ * // updateMany + deleteMany are primitives on Repository — no plugin needed.
567
+ * await repo.updateMany({ status: 'pending' }, { status: 'active' });
568
+ * await repo.deleteMany({ status: 'deleted' });
569
+ *
570
+ * // bulkWrite is what this plugin contributes.
571
+ * await repo.bulkWrite([...ops]);
572
+ */
573
+ function batchOperationsPlugin() {
574
+ return {
575
+ name: "batch-operations",
576
+ apply(repo) {
577
+ if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
578
+ /**
579
+ * Execute heterogeneous bulk write operations in a single database call.
580
+ *
581
+ * Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
582
+ * operations mixed together for maximum efficiency.
583
+ *
584
+ * @example
585
+ * await repo.bulkWrite([
586
+ * { insertOne: { document: { name: 'New Item', price: 10 } } },
587
+ * { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
588
+ * { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
589
+ * { deleteOne: { filter: { _id: id2 } } },
590
+ * ]);
591
+ */
592
+ repo.registerMethod("bulkWrite", async function(operations, options = {}) {
593
+ const context = await this._buildContext("bulkWrite", {
594
+ operations,
595
+ ...options
596
+ });
597
+ try {
598
+ const finalOps = context.operations || operations;
599
+ if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
600
+ const result = await this.Model.bulkWrite(finalOps, {
601
+ ordered: options.ordered ?? true,
602
+ session: options.session
603
+ });
604
+ const bulkResult = {
605
+ ok: result.ok,
606
+ insertedCount: result.insertedCount,
607
+ upsertedCount: result.upsertedCount,
608
+ matchedCount: result.matchedCount,
609
+ modifiedCount: result.modifiedCount,
610
+ deletedCount: result.deletedCount,
611
+ insertedIds: result.insertedIds,
612
+ upsertedIds: result.upsertedIds
613
+ };
614
+ await this.emitAsync("after:bulkWrite", {
615
+ context,
616
+ result: bulkResult
617
+ });
618
+ return bulkResult;
619
+ } catch (error) {
620
+ this.emit("error:bulkWrite", {
621
+ context,
622
+ error
623
+ });
624
+ throw this._handleError(error);
625
+ }
626
+ });
627
+ }
628
+ };
629
+ }
630
+ //#endregion
631
+ //#region src/plugins/cascade.plugin.ts
632
+ /**
633
+ * Cascade Delete Plugin
634
+ *
635
+ * Automatically deletes related documents when a parent document is deleted.
636
+ *
637
+ * Two routing modes per relation:
638
+ *
639
+ * 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
640
+ * Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
641
+ * query, { mode })`, so the target's `before:delete` / `before:deleteMany`
642
+ * hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
643
+ * and the target's own `softDeletePlugin` (with its configured
644
+ * `deletedField`) all run correctly.
645
+ *
646
+ * 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
647
+ * writes directly via `mongoose.models[name].updateMany / deleteMany`,
648
+ * **bypassing** the target's hooks. Safe only for trivial targets with
649
+ * no policy plugins. Retained for backwards compatibility — prefer the
650
+ * repo-routed form for new code.
651
+ *
652
+ * The parent's delete mode propagates: a hard-deleted parent cascades hard,
653
+ * a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
654
+ * the decision per-relation.
655
+ *
656
+ * @example Repo-routed (new)
657
+ * ```ts
658
+ * const productRepo = new Repository(Product, [
659
+ * methodRegistryPlugin(),
660
+ * cascadePlugin({
661
+ * relations: [
662
+ * { repo: stockEntryRepo, foreignKey: 'product' },
663
+ * { repo: stockMovementRepo, foreignKey: 'product' },
664
+ * ],
665
+ * }),
666
+ * ]);
667
+ * ```
668
+ *
669
+ * @example Model-routed (legacy)
670
+ * ```ts
671
+ * cascadePlugin({
672
+ * relations: [
673
+ * { model: 'StockEntry', foreignKey: 'product' },
674
+ * ],
675
+ * });
676
+ * ```
677
+ */
678
+ /**
679
+ * Collect top-level parent-context fields that a target plugin (multi-tenant,
680
+ * audit) will likely need. We can't know the target's contextKey, so we
681
+ * forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
682
+ * These are the conventions enforced by the built-in plugins.
683
+ */
684
+ function collectScopeForward(context) {
685
+ const out = {};
686
+ if (context.organizationId !== void 0) out.organizationId = context.organizationId;
687
+ if (context.tenantId !== void 0) out.tenantId = context.tenantId;
688
+ if (context.user !== void 0) out.user = context.user;
689
+ return out;
690
+ }
691
+ /**
692
+ * Cascade delete plugin.
693
+ */
694
+ function cascadePlugin(options) {
695
+ const { relations, parallel = true, logger } = options;
696
+ if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
697
+ for (const rel of relations) {
698
+ if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
699
+ if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
700
+ }
701
+ return {
702
+ name: "cascade",
703
+ apply(repo) {
704
+ repo.on("after:delete", async (payload) => {
705
+ const { context } = payload;
706
+ const deletedId = context.id;
707
+ if (!deletedId) {
708
+ logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
709
+ return;
710
+ }
711
+ const isSoftParent = context.softDeleted === true;
712
+ const scopeForward = collectScopeForward(context);
713
+ const cascadeOne = async (relation) => {
714
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
715
+ const cascadeCtx = {
716
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
717
+ session: context.session,
718
+ shouldSoftDelete,
719
+ user: context.user,
720
+ scopeForward
721
+ };
722
+ try {
723
+ if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
724
+ else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
725
+ } catch (error) {
726
+ logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
727
+ parentModel: context.model,
728
+ parentId: String(deletedId),
729
+ relatedModel: cascadeCtx.targetModelName,
730
+ foreignKey: relation.foreignKey,
731
+ error: error.message
732
+ });
733
+ throw error;
734
+ }
735
+ };
736
+ await runCascades(relations, cascadeOne, parallel);
737
+ });
738
+ repo.on("before:deleteMany", async (context) => {
739
+ const query = context.query;
740
+ if (!query || Object.keys(query).length === 0) return;
741
+ const idField = repo.idField || "_id";
742
+ context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
743
+ });
744
+ repo.on("after:deleteMany", async (payload) => {
745
+ const { context } = payload;
746
+ const ids = context._cascadeIds;
747
+ if (!ids || ids.length === 0) return;
748
+ const isSoftParent = context.softDeleted === true;
749
+ const scopeForward = collectScopeForward(context);
750
+ const cascadeBulk = async (relation) => {
751
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
752
+ const cascadeCtx = {
753
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
754
+ session: context.session,
755
+ shouldSoftDelete,
756
+ user: context.user,
757
+ scopeForward
758
+ };
759
+ try {
760
+ if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
761
+ else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
762
+ } catch (error) {
763
+ logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
764
+ parentModel: context.model,
765
+ relatedModel: cascadeCtx.targetModelName,
766
+ foreignKey: relation.foreignKey,
767
+ error: error.message
768
+ });
769
+ throw error;
770
+ }
771
+ };
772
+ await runCascades(relations, cascadeBulk, parallel);
773
+ });
774
+ }
775
+ };
776
+ }
777
+ /**
778
+ * Repo-routed cascade for a single parent delete.
779
+ * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
780
+ * hooks (multi-tenant, audit, target's own soft-delete plugin).
781
+ */
782
+ async function cascadeViaRepoBulk(relation, parentId, ctx) {
783
+ const targetRepo = relation.repo;
784
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
785
+ await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
786
+ ...ctx.scopeForward,
787
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
788
+ session: ctx.session
789
+ });
790
+ }
791
+ /**
792
+ * Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
793
+ */
794
+ async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
795
+ const targetRepo = relation.repo;
796
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
797
+ await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
798
+ ...ctx.scopeForward,
799
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
800
+ session: ctx.session
801
+ });
802
+ }
803
+ /**
804
+ * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
805
+ * Retained for backwards compatibility with existing cascadePlugin consumers.
806
+ */
807
+ async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
808
+ const RelatedModel = mongoose.models[relation.model];
809
+ if (!RelatedModel) {
810
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
811
+ return;
812
+ }
813
+ const query = { [relation.foreignKey]: parentId };
814
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
815
+ deletedAt: /* @__PURE__ */ new Date(),
816
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
817
+ }, { session: ctx.session });
818
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
819
+ }
820
+ async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
821
+ const RelatedModel = mongoose.models[relation.model];
822
+ if (!RelatedModel) {
823
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
824
+ return;
825
+ }
826
+ const query = { [relation.foreignKey]: { $in: parentIds } };
827
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
828
+ deletedAt: /* @__PURE__ */ new Date(),
829
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
830
+ }, { session: ctx.session });
831
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
832
+ }
833
+ /**
834
+ * Execute a list of cascade operations, honoring the `parallel` flag.
835
+ * Uses `allSettled` so one failure doesn't abort siblings; throws the first
836
+ * rejection (with a composite message if several failed) after all complete.
837
+ */
838
+ async function runCascades(relations, fn, parallel) {
839
+ if (parallel) {
840
+ const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
841
+ if (failures.length) {
842
+ const err = failures[0].reason;
843
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
844
+ throw err;
845
+ }
846
+ } else for (const relation of relations) await fn(relation);
847
+ }
848
+ //#endregion
849
+ //#region src/plugins/custom-id.plugin.ts
850
+ /**
851
+ * Custom ID Plugin
852
+ *
853
+ * Generates custom document IDs using pluggable generators.
854
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
855
+ * date-partitioned sequences, and fully custom generators.
856
+ *
857
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
858
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
859
+ *
860
+ * @example Basic sequential counter
861
+ * ```typescript
862
+ * const invoiceRepo = new Repository(InvoiceModel, [
863
+ * customIdPlugin({
864
+ * field: 'invoiceNumber',
865
+ * generator: sequentialId({
866
+ * prefix: 'INV',
867
+ * model: InvoiceModel,
868
+ * }),
869
+ * }),
870
+ * ]);
871
+ *
872
+ * const inv = await invoiceRepo.create({ amount: 100 });
873
+ * // inv.invoiceNumber → "INV-0001"
874
+ * ```
875
+ *
876
+ * @example Date-partitioned counter (resets monthly)
877
+ * ```typescript
878
+ * const billRepo = new Repository(BillModel, [
879
+ * customIdPlugin({
880
+ * field: 'billNumber',
881
+ * generator: dateSequentialId({
882
+ * prefix: 'BILL',
883
+ * model: BillModel,
884
+ * partition: 'monthly',
885
+ * separator: '-',
886
+ * padding: 4,
887
+ * }),
888
+ * }),
889
+ * ]);
890
+ *
891
+ * const bill = await billRepo.create({ total: 250 });
892
+ * // bill.billNumber → "BILL-2026-02-0001"
893
+ * ```
894
+ *
895
+ * @example Custom generator function
896
+ * ```typescript
897
+ * const orderRepo = new Repository(OrderModel, [
898
+ * customIdPlugin({
899
+ * field: 'orderRef',
900
+ * generator: async (context) => {
901
+ * const region = context.data?.region || 'US';
902
+ * const seq = await getNextSequence('orders');
903
+ * return `ORD-${region}-${seq}`;
904
+ * },
905
+ * }),
906
+ * ]);
907
+ * ```
908
+ */
909
+ /** Schema for the internal counters collection */
910
+ const counterSchema = new mongoose.Schema({
911
+ _id: {
912
+ type: String,
913
+ required: true
914
+ },
915
+ seq: {
916
+ type: Number,
917
+ default: 0
918
+ }
919
+ }, {
920
+ collection: "_mongokit_counters",
921
+ versionKey: false
922
+ });
923
+ /**
924
+ * Get or create the Counter model on the given connection.
925
+ * Falls back to the default mongoose connection if none is provided.
926
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
927
+ */
928
+ function getCounterModel(connection) {
929
+ const conn = connection ?? mongoose.connection;
930
+ if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
931
+ return conn.model("_MongoKitCounter", counterSchema);
932
+ }
933
+ /**
934
+ * Atomically increment and return the next sequence value for a given key.
935
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
936
+ * heavy concurrency.
937
+ *
938
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
939
+ * @param increment - Value to increment by (default: 1)
940
+ * @param connection - Mongoose connection to use (defaults to the default connection)
941
+ * @param session - Optional client session. When provided, the counter bump
942
+ * participates in the caller's transaction — if the surrounding transaction
943
+ * aborts, the counter does NOT advance. Without a session, the bump commits
944
+ * immediately and is not rolled back if a sibling write fails.
945
+ * @returns The next sequence number (after increment)
946
+ *
947
+ * @example
948
+ * const seq = await getNextSequence('invoices');
949
+ * // First call → 1, second → 2, ...
950
+ *
951
+ * @example Batch increment for createMany
952
+ * const startSeq = await getNextSequence('invoices', 5);
953
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
954
+ *
955
+ * @example Transactional counter bump
956
+ * await withTransaction(conn, async (session) => {
957
+ * const seq = await getNextSequence('invoices', 1, conn, session);
958
+ * await invoiceRepo.create({ invoiceNumber: `INV-${seq}` }, { session });
959
+ * // if this throws, the counter is rolled back with the insert
960
+ * });
961
+ */
962
+ async function getNextSequence(counterKey, increment = 1, connection, session) {
963
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
964
+ upsert: true,
965
+ returnDocument: "after",
966
+ session
967
+ });
968
+ if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
969
+ return result.seq;
970
+ }
971
+ /**
972
+ * Generator: Simple sequential counter.
973
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
974
+ *
975
+ * Uses atomic MongoDB counters — safe under concurrency.
976
+ *
977
+ * @example
978
+ * ```typescript
979
+ * customIdPlugin({
980
+ * field: 'invoiceNumber',
981
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
982
+ * })
983
+ * ```
984
+ */
985
+ function sequentialId(options) {
986
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
987
+ const key = counterKey || model.modelName;
988
+ return async (context) => {
989
+ const seq = await getNextSequence(key, 1, context._counterConnection, context.session);
990
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
991
+ };
992
+ }
993
+ /**
994
+ * Generator: Date-partitioned sequential counter.
995
+ * Counter resets per period — great for invoice/bill numbering.
996
+ *
997
+ * Produces IDs like:
998
+ * - yearly: `BILL-2026-0001`
999
+ * - monthly: `BILL-2026-02-0001`
1000
+ * - daily: `BILL-2026-02-20-0001`
1001
+ *
1002
+ * @example
1003
+ * ```typescript
1004
+ * customIdPlugin({
1005
+ * field: 'billNumber',
1006
+ * generator: dateSequentialId({
1007
+ * prefix: 'BILL',
1008
+ * model: BillModel,
1009
+ * partition: 'monthly',
1010
+ * }),
1011
+ * })
1012
+ * ```
1013
+ */
1014
+ function dateSequentialId(options) {
1015
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
1016
+ return async (context) => {
1017
+ const now = /* @__PURE__ */ new Date();
1018
+ const year = String(now.getFullYear());
1019
+ const month = String(now.getMonth() + 1).padStart(2, "0");
1020
+ const day = String(now.getDate()).padStart(2, "0");
1021
+ let datePart;
1022
+ let counterKey;
1023
+ switch (partition) {
1024
+ case "yearly":
1025
+ datePart = year;
1026
+ counterKey = `${model.modelName}:${year}`;
1027
+ break;
1028
+ case "daily":
1029
+ datePart = `${year}${separator}${month}${separator}${day}`;
1030
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
1031
+ break;
1032
+ default:
1033
+ datePart = `${year}${separator}${month}`;
1034
+ counterKey = `${model.modelName}:${year}-${month}`;
1035
+ break;
1036
+ }
1037
+ const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
1038
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
1039
+ };
1040
+ }
1041
+ /**
1042
+ * Generator: Prefix + random alphanumeric suffix.
1043
+ * Does NOT require a database round-trip — purely in-memory.
1044
+ *
1045
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
1046
+ *
1047
+ * Good for: user-facing IDs where ordering doesn't matter.
1048
+ * Not suitable for sequential numbering.
1049
+ *
1050
+ * @example
1051
+ * ```typescript
1052
+ * customIdPlugin({
1053
+ * field: 'publicId',
1054
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
1055
+ * })
1056
+ * ```
1057
+ */
1058
+ function prefixedId(options) {
1059
+ const { prefix, separator = "_", length = 12 } = options;
1060
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
1061
+ return (_context) => {
1062
+ let result = "";
1063
+ const bytes = new Uint8Array(length);
1064
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
1065
+ globalThis.crypto.getRandomValues(bytes);
1066
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
1067
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
1068
+ return `${prefix}${separator}${result}`;
1069
+ };
1070
+ }
1071
+ /**
1072
+ * Custom ID plugin — injects generated IDs into documents before creation.
1073
+ *
1074
+ * @param options - Configuration for ID generation
1075
+ * @returns Plugin instance
1076
+ *
1077
+ * @example
1078
+ * ```typescript
1079
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
1080
+ *
1081
+ * const invoiceRepo = new Repository(InvoiceModel, [
1082
+ * customIdPlugin({
1083
+ * field: 'invoiceNumber',
1084
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
1085
+ * }),
1086
+ * ]);
1087
+ *
1088
+ * const inv = await invoiceRepo.create({ amount: 100 });
1089
+ * console.log(inv.invoiceNumber); // "INV-0001"
1090
+ * ```
1091
+ */
1092
+ function customIdPlugin(options) {
1093
+ const fieldName = options.field || "customId";
1094
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
1095
+ return {
1096
+ name: "custom-id",
1097
+ apply(repo) {
1098
+ const repoConnection = repo.Model.db;
1099
+ repo.on("before:create", async (context) => {
1100
+ if (!context.data) return;
1101
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
1102
+ context._counterConnection = repoConnection;
1103
+ context.data[fieldName] = await options.generator(context);
1104
+ });
1105
+ repo.on("before:createMany", async (context) => {
1106
+ if (!context.dataArray) return;
1107
+ context._counterConnection = repoConnection;
1108
+ const docsNeedingIds = [];
1109
+ for (const doc of context.dataArray) {
1110
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
1111
+ docsNeedingIds.push(doc);
1112
+ }
1113
+ if (docsNeedingIds.length === 0) return;
1114
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
1115
+ ...context,
1116
+ data: doc
1117
+ });
1118
+ });
1119
+ }
1120
+ };
1121
+ }
1122
+ //#endregion
1123
+ //#region src/plugins/elastic.plugin.ts
1124
+ function elasticSearchPlugin(options) {
1125
+ return {
1126
+ name: "elastic-search",
1127
+ apply(repo) {
1128
+ if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
1129
+ repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
1130
+ const { client, index, idField = "_id" } = options;
1131
+ const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
1132
+ const from = Math.max(searchOptions.from || 0, 0);
1133
+ const esResponse = await client.search({
1134
+ index,
1135
+ body: {
1136
+ query: searchQuery,
1137
+ size: limit,
1138
+ from
1139
+ }
1140
+ });
1141
+ const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
1142
+ if (hits.length === 0) return {
1143
+ data: [],
1144
+ total: 0,
1145
+ limit,
1146
+ from
1147
+ };
1148
+ const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
1149
+ const total = typeof totalValue === "number" ? totalValue : 0;
1150
+ const docsOrder = /* @__PURE__ */ new Map();
1151
+ const scores = /* @__PURE__ */ new Map();
1152
+ const ids = [];
1153
+ hits.forEach((hit, idx) => {
1154
+ const docId = hit._source?.[idField] || hit[idField] || hit._id;
1155
+ if (docId) {
1156
+ const strId = String(docId);
1157
+ docsOrder.set(strId, idx);
1158
+ if (hit._score !== void 0) scores.set(strId, hit._score);
1159
+ ids.push(strId);
1160
+ }
1161
+ });
1162
+ if (ids.length === 0) return {
1163
+ data: [],
1164
+ total,
1165
+ limit,
1166
+ from
1167
+ };
1168
+ const mongoIdField = repo.idField || "_id";
1169
+ const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
1170
+ if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
1171
+ if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
1172
+ if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
1173
+ return {
1174
+ data: (await mongoQuery.exec()).sort((a, b) => {
1175
+ const aId = String(a[mongoIdField]);
1176
+ const bId = String(b[mongoIdField]);
1177
+ return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
1178
+ }).map((doc) => {
1179
+ const strId = String(doc[mongoIdField]);
1180
+ if (searchOptions.mongoOptions?.lean !== false) return {
1181
+ ...doc,
1182
+ _score: scores.get(strId)
1183
+ };
1184
+ return doc;
1185
+ }),
1186
+ total,
1187
+ limit,
1188
+ from
1189
+ };
1190
+ });
1191
+ }
1192
+ };
1193
+ }
1194
+ //#endregion
1195
+ //#region src/plugins/field-filter.plugin.ts
1196
+ /**
1197
+ * Field filter plugin that restricts fields based on user context
1198
+ *
1199
+ * @example
1200
+ * const fieldPreset = {
1201
+ * public: ['id', 'name'],
1202
+ * authenticated: ['email'],
1203
+ * admin: ['createdAt', 'internalNotes']
1204
+ * };
1205
+ *
1206
+ * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
1207
+ */
1208
+ function fieldFilterPlugin(fieldPreset) {
1209
+ return {
1210
+ name: "fieldFilter",
1211
+ apply(repo) {
1212
+ const applyFieldFiltering = (context) => {
1213
+ if (!fieldPreset) return;
1214
+ const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
1215
+ if (context.select) context.select = `${presetSelect} ${context.select}`;
1216
+ else context.select = presetSelect;
1217
+ };
1218
+ repo.on("before:getAll", applyFieldFiltering);
1219
+ repo.on("before:findAll", applyFieldFiltering);
1220
+ repo.on("before:getById", applyFieldFiltering);
1221
+ repo.on("before:getByQuery", applyFieldFiltering);
1222
+ repo.on("before:getOne", applyFieldFiltering);
1223
+ }
1224
+ };
1225
+ }
1226
+ //#endregion
1227
+ //#region src/plugins/lease.plugin.ts
1228
+ function leasePlugin(options = {}) {
1229
+ const statusField = options.statusField ?? "status";
1230
+ const leasedByField = options.leasedByField ?? "leasedBy";
1231
+ const leaseExpiresAtField = options.leaseExpiresAtField ?? "leaseExpiresAt";
1232
+ const pendingStatus = options.pendingStatus ?? "pending";
1233
+ const processingStatus = options.processingStatus ?? "processing";
1234
+ const doneStatus = options.doneStatus ?? "done";
1235
+ const sort = options.sort ?? { createdAt: 1 };
1236
+ return {
1237
+ name: "lease",
1238
+ apply(repo) {
1239
+ if (!repo.registerMethod) throw new Error("leasePlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before leasePlugin().");
1240
+ /**
1241
+ * Atomic FIFO lease claim — the canonical
1242
+ * findOneAndUpdate({ status: pending, OR leaseExpiresAt < now },
1243
+ * { $set: { status: processing, leasedBy, leaseExpiresAt } },
1244
+ * { sort })
1245
+ * pattern, distilled.
1246
+ */
1247
+ repo.registerMethod("lease", async function(opts) {
1248
+ if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.lease: leaseFor must be a positive number (ms)");
1249
+ if (typeof opts.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.lease: leasedBy must be a non-empty string");
1250
+ const now = /* @__PURE__ */ new Date();
1251
+ const leaseUntil = new Date(now.getTime() + opts.leaseFor);
1252
+ const claimFilter = {
1253
+ ...opts.filter ?? {},
1254
+ $or: [{ [statusField]: pendingStatus }, { [leaseExpiresAtField]: { $lt: now } }]
1255
+ };
1256
+ return this.findOneAndUpdate(claimFilter, { $set: {
1257
+ [statusField]: processingStatus,
1258
+ [leasedByField]: opts.leasedBy,
1259
+ [leaseExpiresAtField]: leaseUntil
1260
+ } }, {
1261
+ ...opts.options ?? {},
1262
+ sort,
1263
+ returnDocument: "after"
1264
+ });
1265
+ });
1266
+ repo.registerMethod("extend", async function(id, opts) {
1267
+ if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.extend: leaseFor must be a positive number (ms)");
1268
+ const now = /* @__PURE__ */ new Date();
1269
+ const leaseUntil = new Date(now.getTime() + opts.leaseFor);
1270
+ const idField = this.idField || "_id";
1271
+ return this.findOneAndUpdate({
1272
+ [idField]: id,
1273
+ [leasedByField]: opts.leasedBy,
1274
+ [leaseExpiresAtField]: { $gt: now }
1275
+ }, { $set: { [leaseExpiresAtField]: leaseUntil } }, {
1276
+ ...opts.options ?? {},
1277
+ returnDocument: "after"
1278
+ });
1279
+ });
1280
+ repo.registerMethod("release", async function(id, opts) {
1281
+ if (typeof opts?.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.release: leasedBy must be a non-empty string (the holder finalising the lease)");
1282
+ const finalStatus = opts.finalStatus ?? doneStatus;
1283
+ const now = /* @__PURE__ */ new Date();
1284
+ const idField = this.idField || "_id";
1285
+ return this.findOneAndUpdate({
1286
+ [idField]: id,
1287
+ [leasedByField]: opts.leasedBy,
1288
+ [leaseExpiresAtField]: { $gt: now }
1289
+ }, {
1290
+ $set: { [statusField]: finalStatus },
1291
+ $unset: {
1292
+ [leasedByField]: "",
1293
+ [leaseExpiresAtField]: ""
1294
+ }
1295
+ }, {
1296
+ ...opts.options ?? {},
1297
+ returnDocument: "after"
1298
+ });
1299
+ });
1300
+ }
1301
+ };
1302
+ }
1303
+ //#endregion
1304
+ //#region src/plugins/method-registry.plugin.ts
1305
+ /**
1306
+ * Method registry plugin that enables dynamic method registration
1307
+ */
1308
+ function methodRegistryPlugin() {
1309
+ return {
1310
+ name: "method-registry",
1311
+ apply(repo) {
1312
+ const registeredMethods = [];
1313
+ /**
1314
+ * Register a new method on the repository instance
1315
+ */
1316
+ repo.registerMethod = (name, fn) => {
1317
+ 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.`);
1318
+ if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
1319
+ if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
1320
+ repo[name] = fn.bind(repo);
1321
+ registeredMethods.push(name);
1322
+ repo.emit("method:registered", {
1323
+ name,
1324
+ fn
1325
+ });
1326
+ };
1327
+ /**
1328
+ * Check if a method is registered
1329
+ */
1330
+ repo.hasMethod = (name) => typeof repo[name] === "function";
1331
+ /**
1332
+ * Get list of all dynamically registered methods
1333
+ */
1334
+ repo.getRegisteredMethods = () => [...registeredMethods];
1335
+ }
1336
+ };
1337
+ }
1338
+ //#endregion
1339
+ //#region src/plugins/mongo-operations.plugin.ts
1340
+ /**
1341
+ * MongoDB Operations Plugin
1342
+ *
1343
+ * Adds MongoDB-specific operations to repositories.
1344
+ * Requires method-registry.plugin.js to be loaded first.
1345
+ */
1346
+ /**
1347
+ * MongoDB operations plugin
1348
+ *
1349
+ * Adds MongoDB-specific atomic operations to repositories:
1350
+ * - upsert: Create or update document
1351
+ * - increment/decrement: Atomic numeric operations
1352
+ * - pushToArray/pullFromArray/addToSet: Array operations
1353
+ * - setField/unsetField/renameField: Field operations
1354
+ * - multiplyField: Multiply numeric field
1355
+ * - setMin/setMax: Conditional min/max updates
1356
+ *
1357
+ * @example Basic usage (no TypeScript autocomplete)
1358
+ * ```typescript
1359
+ * const repo = new Repository(ProductModel, [
1360
+ * methodRegistryPlugin(),
1361
+ * mongoOperationsPlugin(),
1362
+ * ]);
1363
+ *
1364
+ * // Works at runtime but TypeScript doesn't know about these methods
1365
+ * await (repo as any).increment(productId, 'views', 1);
1366
+ * await (repo as any).pushToArray(productId, 'tags', 'featured');
1367
+ * ```
1368
+ *
1369
+ * @example With TypeScript type safety (recommended)
1370
+ * ```typescript
1371
+ * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
1372
+ * import type { MongoOperationsMethods } from '@classytic/mongokit';
1373
+ *
1374
+ * class ProductRepo extends Repository<IProduct> {
1375
+ * // Add your custom methods here
1376
+ * }
1377
+ *
1378
+ * // Create with type assertion to get autocomplete for plugin methods
1379
+ * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
1380
+ *
1381
+ * const repo = new ProductRepo(ProductModel, [
1382
+ * methodRegistryPlugin(),
1383
+ * mongoOperationsPlugin(),
1384
+ * ]) as ProductRepoWithPlugins;
1385
+ *
1386
+ * // Now TypeScript provides autocomplete and type checking!
1387
+ * await repo.increment(productId, 'views', 1);
1388
+ * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
1389
+ * await repo.pushToArray(productId, 'tags', 'featured');
1390
+ * ```
1391
+ */
1392
+ function mongoOperationsPlugin() {
1393
+ return {
1394
+ name: "mongo-operations",
1395
+ apply(repo) {
1396
+ if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
1397
+ /**
1398
+ * Update existing document or insert new one
1399
+ */
1400
+ repo.registerMethod("upsert", async function(query, data, options = {}) {
1401
+ return upsert(this.Model, query, data, options);
1402
+ });
1403
+ const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
1404
+ if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
1405
+ return this.update(id, { [operator]: { [field]: value } }, options);
1406
+ };
1407
+ /**
1408
+ * Atomically increment numeric field
1409
+ */
1410
+ repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
1411
+ return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
1412
+ });
1413
+ /**
1414
+ * Atomically decrement numeric field
1415
+ */
1416
+ repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
1417
+ return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
1418
+ });
1419
+ const applyOperator = function(id, field, value, operator, options) {
1420
+ return this.update(id, { [operator]: { [field]: value } }, options);
1421
+ };
1422
+ /**
1423
+ * Push value to array field
1424
+ */
1425
+ repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
1426
+ return applyOperator.call(this, id, field, value, "$push", options);
1427
+ });
1428
+ /**
1429
+ * Remove value from array field
1430
+ */
1431
+ repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
1432
+ return applyOperator.call(this, id, field, value, "$pull", options);
1433
+ });
1434
+ /**
1435
+ * Add value to array only if not already present (unique)
1436
+ */
1437
+ repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
1438
+ return applyOperator.call(this, id, field, value, "$addToSet", options);
1439
+ });
1440
+ /**
1441
+ * Set field value (alias for update with $set)
1442
+ */
1443
+ repo.registerMethod("setField", async function(id, field, value, options = {}) {
1444
+ return applyOperator.call(this, id, field, value, "$set", options);
1445
+ });
1446
+ /**
1447
+ * Unset (remove) field from document
1448
+ */
1449
+ repo.registerMethod("unsetField", async function(id, fields, options = {}) {
1450
+ const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
1451
+ acc[field] = "";
1452
+ return acc;
1453
+ }, {});
1454
+ return this.update(id, { $unset: unsetObj }, options);
1455
+ });
1456
+ /**
1457
+ * Rename field in document
1458
+ */
1459
+ repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
1460
+ return this.update(id, { $rename: { [oldName]: newName } }, options);
1461
+ });
1462
+ /**
1463
+ * Multiply numeric field by value
1464
+ */
1465
+ repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
1466
+ return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
1467
+ });
1468
+ /**
1469
+ * Set field to minimum value (only if current value is greater)
1470
+ */
1471
+ repo.registerMethod("setMin", async function(id, field, value, options = {}) {
1472
+ return applyOperator.call(this, id, field, value, "$min", options);
1473
+ });
1474
+ /**
1475
+ * Set field to maximum value (only if current value is less)
1476
+ */
1477
+ repo.registerMethod("setMax", async function(id, field, value, options = {}) {
1478
+ return applyOperator.call(this, id, field, value, "$max", options);
1479
+ });
1480
+ /**
1481
+ * Atomic conditional increment — only increments when the current
1482
+ * value is strictly below `ceiling`. Returns the updated document
1483
+ * on success, or `null` when the row is already at/above the
1484
+ * ceiling (or the id doesn't match).
1485
+ *
1486
+ * Single round-trip via `findOneAndUpdate({ _id, [field]: { $lt:
1487
+ * ceiling } }, { $inc: { [field]: by } })` — race-free across
1488
+ * concurrent callers. The standard pattern for per-document
1489
+ * concurrency limits / quotas / capacity caps that would
1490
+ * otherwise need a 150-line lease loop.
1491
+ *
1492
+ * @example streamline strict-concurrency: claim a worker slot
1493
+ * ```ts
1494
+ * const claimed = await repo.incrementIfBelow(
1495
+ * tenantId,
1496
+ * 'activeRuns',
1497
+ * maxConcurrent,
1498
+ * );
1499
+ * if (!claimed) return; // already at limit; another caller won
1500
+ * ```
1501
+ */
1502
+ repo.registerMethod("incrementIfBelow", async function(id, field, ceiling, by = 1, options = {}) {
1503
+ if (typeof ceiling !== "number") throw createError(400, "incrementIfBelow ceiling must be a number");
1504
+ if (typeof by !== "number") throw createError(400, "incrementIfBelow `by` must be a number");
1505
+ if (typeof field !== "string" || field.length === 0) throw createError(400, "incrementIfBelow field must be a non-empty string");
1506
+ for (const segment of field.split(".")) {
1507
+ if (segment.length === 0) throw createError(400, `incrementIfBelow field has an empty segment ("${field}")`);
1508
+ if (segment.startsWith("$")) throw createError(400, `incrementIfBelow field segment cannot start with '$' ("${segment}") — Mongo would treat it as an operator`);
1509
+ if (segment === "__proto__" || segment === "constructor" || segment === "prototype") throw createError(400, `incrementIfBelow field segment "${segment}" is reserved (prototype-pollution guard)`);
1510
+ }
1511
+ const idField = this.idField || "_id";
1512
+ return this.findOneAndUpdate({
1513
+ [idField]: id,
1514
+ [field]: { $lt: ceiling }
1515
+ }, { $inc: { [field]: by } }, {
1516
+ ...options,
1517
+ returnDocument: "after"
1518
+ });
1519
+ });
1520
+ /**
1521
+ * Atomic update with multiple MongoDB operators in a single call
1522
+ *
1523
+ * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
1524
+ * into one atomic database operation.
1525
+ *
1526
+ * @example
1527
+ * // Combine $inc + $set in one atomic call
1528
+ * await repo.atomicUpdate(id, {
1529
+ * $inc: { views: 1, commentCount: 1 },
1530
+ * $set: { lastActiveAt: new Date() }
1531
+ * });
1532
+ *
1533
+ * // Multiple operators: $inc + $set + $push
1534
+ * await repo.atomicUpdate(id, {
1535
+ * $inc: { 'metrics.total': 1 },
1536
+ * $set: { updatedAt: new Date() },
1537
+ * $push: { history: { action: 'update', at: new Date() } }
1538
+ * });
1539
+ *
1540
+ * // $push with $each modifier
1541
+ * await repo.atomicUpdate(id, {
1542
+ * $push: { tags: { $each: ['featured', 'popular'] } },
1543
+ * $inc: { tagCount: 2 }
1544
+ * });
1545
+ *
1546
+ * // With arrayFilters for positional updates
1547
+ * await repo.atomicUpdate(id, {
1548
+ * $set: { 'items.$[elem].quantity': 5 }
1549
+ * }, { arrayFilters: [{ 'elem._id': itemId }] });
1550
+ */
1551
+ repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
1552
+ const validOperators = new Set([
1553
+ "$inc",
1554
+ "$set",
1555
+ "$unset",
1556
+ "$push",
1557
+ "$pull",
1558
+ "$addToSet",
1559
+ "$pop",
1560
+ "$rename",
1561
+ "$min",
1562
+ "$max",
1563
+ "$mul",
1564
+ "$setOnInsert",
1565
+ "$bit",
1566
+ "$currentDate"
1567
+ ]);
1568
+ const keys = Object.keys(operators);
1569
+ if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
1570
+ for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
1571
+ return this.update(id, operators, options);
1572
+ });
1573
+ }
1574
+ };
1575
+ }
1576
+ //#endregion
1577
+ //#region src/plugins/multi-tenant.plugin.ts
1578
+ /**
1579
+ * Multi-Tenant Plugin
1580
+ *
1581
+ * Automatically injects tenant isolation filters into all queries.
1582
+ * Ensures data isolation by adding organizationId (or custom tenant field)
1583
+ * to every read and write operation.
1584
+ *
1585
+ * Uses HOOK_PRIORITY.POLICY (100) to ensure tenant filters are injected
1586
+ * BEFORE cache keys are computed (HOOK_PRIORITY.CACHE = 200).
1587
+ *
1588
+ * @example
1589
+ * ```typescript
1590
+ * // Basic — scopes every operation by organizationId from context
1591
+ * const repo = new Repository(Invoice, [
1592
+ * multiTenantPlugin({ tenantField: 'organizationId' }),
1593
+ * ]);
1594
+ *
1595
+ * const invoices = await repo.getAll(
1596
+ * { filters: { status: 'paid' } },
1597
+ * { organizationId: 'org_123' }
1598
+ * );
1599
+ * // Actual query: { status: 'paid', organizationId: 'org_123' }
1600
+ *
1601
+ * // Super admin bypass — skip scoping based on context
1602
+ * const repo = new Repository(Invoice, [
1603
+ * multiTenantPlugin({
1604
+ * tenantField: 'organizationId',
1605
+ * skipWhen: (context) => context.role === 'superadmin',
1606
+ * }),
1607
+ * ]);
1608
+ *
1609
+ * // Admin sees all orgs
1610
+ * await repo.getAll({ page: 1, limit: 10 }, { role: 'superadmin' });
1611
+ *
1612
+ * // Automatic context — resolve tenant from AsyncLocalStorage
1613
+ * const repo = new Repository(Invoice, [
1614
+ * multiTenantPlugin({
1615
+ * tenantField: 'organizationId',
1616
+ * resolveContext: () => asyncLocalStorage.getStore()?.tenantId,
1617
+ * }),
1618
+ * ]);
1619
+ * ```
1620
+ */
1621
+ function multiTenantPlugin(options = {}) {
1622
+ const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string", allowDataInjection = false } = options;
1623
+ const extraOps = [{
1624
+ op: "getDeleted",
1625
+ policyKey: "filters"
1626
+ }];
1627
+ return {
1628
+ name: "multi-tenant",
1629
+ apply(repo) {
1630
+ const allOps = [...ALL_OPERATIONS.map((op) => ({
1631
+ op,
1632
+ policyKey: OP_REGISTRY[op].policyKey
1633
+ })), ...extraOps];
1634
+ for (const { op, policyKey } of allOps) {
1635
+ if (skipOperations.includes(op)) continue;
1636
+ if (policyKey === "none") continue;
1637
+ repo.on(`before:${op}`, (context) => {
1638
+ const perCallBypass = context.bypassTenant === true;
1639
+ const callbackBypass = !perCallBypass && skipWhen?.(context, op) === true;
1640
+ if (perCallBypass || callbackBypass) {
1641
+ repo.emit("after:tenant-bypass", {
1642
+ context,
1643
+ operation: op,
1644
+ reason: perCallBypass ? "option" : "callback"
1645
+ });
1646
+ return;
1647
+ }
1648
+ let tenantId = context[contextKey];
1649
+ if (!tenantId && resolveContext) {
1650
+ tenantId = resolveContext();
1651
+ if (tenantId) context[contextKey] = tenantId;
1652
+ }
1653
+ if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
1654
+ if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false. For deliberate cross-tenant access (admin / migration / support), pass \`bypassTenant: true\` in the options bag for this call, or wire \`skipWhen: adminBypass({...})\` at plugin construction.`);
1655
+ if (!tenantId) return;
1656
+ const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1657
+ switch (policyKey) {
1658
+ case "filters":
1659
+ context.filters = {
1660
+ ...context.filters,
1661
+ [tenantField]: castId
1662
+ };
1663
+ break;
1664
+ case "query":
1665
+ context.query = {
1666
+ ...context.query,
1667
+ [tenantField]: castId
1668
+ };
1669
+ break;
1670
+ case "data":
1671
+ if (context.data) context.data[tenantField] = castId;
1672
+ break;
1673
+ case "dataArray":
1674
+ if (context.dataArray) {
1675
+ for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
1676
+ }
1677
+ break;
1678
+ case "operations":
1679
+ if (context.operations) {
1680
+ const ops = context.operations;
1681
+ for (const subOp of ops) {
1682
+ for (const key of [
1683
+ "updateOne",
1684
+ "updateMany",
1685
+ "deleteOne",
1686
+ "deleteMany",
1687
+ "replaceOne"
1688
+ ]) {
1689
+ const opBody = subOp[key];
1690
+ if (opBody?.filter) opBody.filter = {
1691
+ ...opBody.filter,
1692
+ [tenantField]: castId
1693
+ };
1694
+ }
1695
+ const insertBody = subOp.insertOne;
1696
+ if (insertBody?.document) insertBody.document[tenantField] = castId;
1697
+ }
1698
+ }
1699
+ break;
1700
+ }
1701
+ }, { priority: HOOK_PRIORITY.POLICY });
1702
+ }
1703
+ }
1704
+ };
1705
+ }
1706
+ /**
1707
+ * Build the canonical `skipWhen` callback for role-based tenant
1708
+ * bypass — replaces the hand-rolled
1709
+ * `(ctx) => ctx.role === 'superadmin'` callback that ~6 packages
1710
+ * across the classytic codebase carry as boilerplate. Eliminates the
1711
+ * "I forgot to include 'platform_admin'" drift class — the role
1712
+ * vocabulary lives in one place.
1713
+ *
1714
+ * **Composes with `bypassTenant: true` per-call**, doesn't replace
1715
+ * it. `bypassTenant` is the deliberate per-call form for migration
1716
+ * scripts and one-off admin queries; `adminBypass` is the
1717
+ * always-on role-based bypass for users whose JWT / session carries
1718
+ * a privileged role.
1719
+ *
1720
+ * **NOT a substitute for authentication or RBAC.** This factory only
1721
+ * answers "should the tenant scope hook fire for this call?" — it
1722
+ * does NOT validate that the caller is authenticated, that their
1723
+ * role claim is genuine, or that the requested operation is
1724
+ * permitted. Always run auth + RBAC before the call reaches the
1725
+ * repository layer; this is the LAST gate, not the only one.
1726
+ *
1727
+ * @example Single role
1728
+ * ```ts
1729
+ * import { adminBypass, multiTenantPlugin } from '@classytic/mongokit';
1730
+ *
1731
+ * const repo = new Repository(InvoiceModel, [
1732
+ * multiTenantPlugin({
1733
+ * tenantField: 'organizationId',
1734
+ * skipWhen: adminBypass({ adminRoles: ['superadmin'] }),
1735
+ * }),
1736
+ * ]);
1737
+ *
1738
+ * await repo.getAll({}, { role: 'superadmin' }); // sees all orgs
1739
+ * await repo.getAll({}, { role: 'user', organizationId: 'org-1' }); // scoped
1740
+ * ```
1741
+ *
1742
+ * @example Multiple admin roles + custom field name
1743
+ * ```ts
1744
+ * skipWhen: adminBypass({
1745
+ * roleField: 'principalRole', // ctx.principalRole, not ctx.role
1746
+ * adminRoles: ['superadmin', 'platform_admin', 'support'],
1747
+ * }),
1748
+ * ```
1749
+ *
1750
+ * @example Composing with custom logic
1751
+ * ```ts
1752
+ * // adminBypass for the role check, then your own additional logic:
1753
+ * const adminCheck = adminBypass({ adminRoles: ['superadmin'] });
1754
+ * skipWhen: (ctx, op) => adminCheck(ctx, op) || ctx.internalSystemFlag === true,
1755
+ * ```
1756
+ *
1757
+ * @param options.roleField - Path on the context to read the role
1758
+ * from. Defaults to `'role'`. Top-level keys only — for nested
1759
+ * shapes (e.g. `ctx.user.role`), wrap your own `(ctx) => …` callback
1760
+ * instead.
1761
+ * @param options.adminRoles - Role values that grant the bypass. The
1762
+ * factory does an exact-match `includes` check — case-sensitive,
1763
+ * no fuzzy matching. Lowercase your role vocabulary upstream.
1764
+ * @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
1765
+ */
1766
+ function adminBypass$1(options) {
1767
+ return adminBypass(options);
1768
+ }
1769
+ //#endregion
1770
+ //#region src/plugins/observability.plugin.ts
1771
+ /**
1772
+ * Observability Plugin
1773
+ *
1774
+ * Adds operation timing, structured metrics, and APM hook points.
1775
+ * Works with any monitoring system — just provide an onMetric callback.
1776
+ *
1777
+ * @example
1778
+ * ```typescript
1779
+ * const repo = new Repository(User, [
1780
+ * observabilityPlugin({
1781
+ * onMetric: (metric) => {
1782
+ * // Send to DataDog, New Relic, OpenTelemetry, or console
1783
+ * console.log(`${metric.operation} took ${metric.durationMs}ms`);
1784
+ * statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
1785
+ * },
1786
+ * }),
1787
+ * ]);
1788
+ * ```
1789
+ */
1790
+ const DEFAULT_OPS = ALL_OPERATIONS;
1791
+ const timers = /* @__PURE__ */ new WeakMap();
1792
+ function observabilityPlugin(options) {
1793
+ const { onMetric, slowThresholdMs } = options;
1794
+ const ops = options.operations ?? DEFAULT_OPS;
1795
+ const isSkipped = (context) => {
1796
+ const list = context.skipPlugins;
1797
+ return Array.isArray(list) && list.includes("observability");
1798
+ };
1799
+ return {
1800
+ name: "observability",
1801
+ apply(repo) {
1802
+ for (const op of ops) {
1803
+ repo.on(`before:${op}`, (context) => {
1804
+ if (isSkipped(context)) return;
1805
+ timers.set(context, performance.now());
1806
+ }, { priority: 300 });
1807
+ repo.on(`after:${op}`, ({ context }) => {
1808
+ if (isSkipped(context)) return;
1809
+ const start = timers.get(context);
1810
+ if (start == null) return;
1811
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
1812
+ timers.delete(context);
1813
+ if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
1814
+ onMetric({
1815
+ operation: op,
1816
+ model: context.model || repo.model,
1817
+ durationMs,
1818
+ success: true,
1819
+ startedAt: new Date(Date.now() - durationMs),
1820
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
1821
+ organizationId: context.organizationId?.toString()
1822
+ });
1823
+ });
1824
+ repo.on(`error:${op}`, ({ context, error }) => {
1825
+ if (isSkipped(context)) return;
1826
+ const start = timers.get(context);
1827
+ if (start == null) return;
1828
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
1829
+ timers.delete(context);
1830
+ onMetric({
1831
+ operation: op,
1832
+ model: context.model || repo.model,
1833
+ durationMs,
1834
+ success: false,
1835
+ error: error.message,
1836
+ startedAt: new Date(Date.now() - durationMs),
1837
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
1838
+ organizationId: context.organizationId?.toString()
1839
+ });
1840
+ });
1841
+ }
1842
+ }
1843
+ };
1844
+ }
1845
+ //#endregion
1846
+ //#region src/plugins/soft-delete.plugin.ts
1847
+ /**
1848
+ * Build filter condition based on filter mode
1849
+ */
1850
+ function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
1851
+ if (includeDeleted) return {};
1852
+ if (filterMode === "exists") return { [deletedField]: { $exists: false } };
1853
+ return { [deletedField]: null };
1854
+ }
1855
+ /**
1856
+ * Build filter condition for finding deleted documents
1857
+ */
1858
+ function buildGetDeletedFilter(deletedField, filterMode) {
1859
+ if (filterMode === "exists") return { [deletedField]: {
1860
+ $exists: true,
1861
+ $ne: null
1862
+ } };
1863
+ return { [deletedField]: { $ne: null } };
1864
+ }
1865
+ /**
1866
+ * Soft delete plugin
1867
+ *
1868
+ * @example Basic usage
1869
+ * ```typescript
1870
+ * const repo = new Repository(Model, [
1871
+ * softDeletePlugin({ deletedField: 'deletedAt' })
1872
+ * ]);
1873
+ *
1874
+ * // Delete (soft)
1875
+ * await repo.delete(id);
1876
+ *
1877
+ * // Restore
1878
+ * await repo.restore(id);
1879
+ *
1880
+ * // Get deleted documents
1881
+ * await repo.getDeleted({ page: 1, limit: 20 });
1882
+ * ```
1883
+ *
1884
+ * @example With null filter mode (for schemas with default: null)
1885
+ * ```typescript
1886
+ * // Schema: { deletedAt: { type: Date, default: null } }
1887
+ * const repo = new Repository(Model, [
1888
+ * softDeletePlugin({
1889
+ * deletedField: 'deletedAt',
1890
+ * filterMode: 'null', // default - works with default: null
1891
+ * })
1892
+ * ]);
1893
+ * ```
1894
+ *
1895
+ * @example With TTL for auto-cleanup
1896
+ * ```typescript
1897
+ * const repo = new Repository(Model, [
1898
+ * softDeletePlugin({
1899
+ * deletedField: 'deletedAt',
1900
+ * ttlDays: 30, // Auto-delete after 30 days
1901
+ * })
1902
+ * ]);
1903
+ * ```
1904
+ */
1905
+ function softDeletePlugin(options = {}) {
1906
+ const deletedField = options.deletedField || "deletedAt";
1907
+ const deletedByField = options.deletedByField || "deletedBy";
1908
+ const filterMode = options.filterMode || "null";
1909
+ const addRestoreMethod = options.addRestoreMethod !== false;
1910
+ const addGetDeletedMethod = options.addGetDeletedMethod !== false;
1911
+ const ttlDays = options.ttlDays;
1912
+ return {
1913
+ name: "softDelete",
1914
+ apply(repo) {
1915
+ try {
1916
+ const schemaPaths = repo.Model.schema.paths;
1917
+ for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
1918
+ if (pathName === "_id" || pathName === deletedField) continue;
1919
+ 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 } }`);
1920
+ }
1921
+ } catch (err) {
1922
+ warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
1923
+ }
1924
+ if (ttlDays !== void 0 && ttlDays > 0) {
1925
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
1926
+ repo.Model.collection.createIndex({ [deletedField]: 1 }, {
1927
+ expireAfterSeconds: ttlSeconds,
1928
+ partialFilterExpression: { [deletedField]: { $type: "date" } }
1929
+ }).catch((err) => {
1930
+ if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1931
+ });
1932
+ }
1933
+ repo.on("before:delete", async (context) => {
1934
+ if (context.deleteMode === "hard") return;
1935
+ if (options.soft !== false) {
1936
+ const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
1937
+ if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
1938
+ const deleteQuery = {
1939
+ [repo.idField || "_id"]: context.id,
1940
+ ...context.query || {}
1941
+ };
1942
+ if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
1943
+ const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
1944
+ error.status = 404;
1945
+ throw error;
1946
+ }
1947
+ context.softDeleted = true;
1948
+ }
1949
+ }, { priority: HOOK_PRIORITY.POLICY });
1950
+ const injectDeleteFilterToFilters = (context) => {
1951
+ if (options.soft !== false) {
1952
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1953
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
1954
+ ...context.filters || {},
1955
+ ...deleteFilter
1956
+ };
1957
+ }
1958
+ };
1959
+ const injectDeleteFilterToQuery = (context) => {
1960
+ if (options.soft !== false) {
1961
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1962
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1963
+ ...context.query || {},
1964
+ ...deleteFilter
1965
+ };
1966
+ }
1967
+ };
1968
+ const SOFT_DELETE_SPECIAL = new Set([
1969
+ "updateMany",
1970
+ "deleteMany",
1971
+ "bulkWrite"
1972
+ ]);
1973
+ for (const op of ALL_OPERATIONS) {
1974
+ if (SOFT_DELETE_SPECIAL.has(op)) continue;
1975
+ const desc = OP_REGISTRY[op];
1976
+ const isFilterableMutation = op === "findOneAndUpdate";
1977
+ if (desc.mutates && !isFilterableMutation) continue;
1978
+ if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
1979
+ else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
1980
+ }
1981
+ repo.on("before:updateMany", (context) => {
1982
+ if (options.soft !== false) {
1983
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1984
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1985
+ ...context.query || {},
1986
+ ...deleteFilter
1987
+ };
1988
+ }
1989
+ }, { priority: HOOK_PRIORITY.POLICY });
1990
+ repo.on("before:deleteMany", async (context) => {
1991
+ if (context.deleteMode === "hard") return;
1992
+ if (options.soft !== false) {
1993
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
1994
+ const finalQuery = {
1995
+ ...context.query || {},
1996
+ ...deleteFilter
1997
+ };
1998
+ const updateRes = await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
1999
+ context.softDeleted = true;
2000
+ context.softDeletedCount = updateRes?.modifiedCount ?? 0;
2001
+ }
2002
+ }, { priority: HOOK_PRIORITY.POLICY });
2003
+ if (addRestoreMethod) {
2004
+ const restoreMethod = async function(id, restoreOptions = {}) {
2005
+ const context = await this._buildContext("restore", {
2006
+ id,
2007
+ ...restoreOptions
2008
+ });
2009
+ const updateData = {
2010
+ [deletedField]: null,
2011
+ [deletedByField]: null
2012
+ };
2013
+ const restoreQuery = {
2014
+ _id: id,
2015
+ ...context.query || {}
2016
+ };
2017
+ const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
2018
+ returnDocument: "after",
2019
+ session: restoreOptions.session
2020
+ });
2021
+ if (!result) {
2022
+ const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
2023
+ error.status = 404;
2024
+ throw error;
2025
+ }
2026
+ await this.emitAsync("after:restore", {
2027
+ context,
2028
+ result
2029
+ });
2030
+ return result;
2031
+ };
2032
+ if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
2033
+ else repo.restore = restoreMethod.bind(repo);
2034
+ }
2035
+ if (addGetDeletedMethod) {
2036
+ const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
2037
+ const context = await this._buildContext("getDeleted", {
2038
+ ...params,
2039
+ ...getDeletedOptions
2040
+ });
2041
+ const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
2042
+ const combinedFilters = {
2043
+ ...params.filters || {},
2044
+ ...deletedFilter,
2045
+ ...context.filters || {},
2046
+ ...context.query || {}
2047
+ };
2048
+ const page = params.page || 1;
2049
+ const limit = params.limit || 20;
2050
+ const skip = (page - 1) * limit;
2051
+ let sortSpec = { [deletedField]: -1 };
2052
+ if (params.sort) if (typeof params.sort === "string") {
2053
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
2054
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
2055
+ } else sortSpec = params.sort;
2056
+ let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
2057
+ if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
2058
+ if (getDeletedOptions.select) {
2059
+ const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
2060
+ query = query.select(selectValue);
2061
+ }
2062
+ if (getDeletedOptions.populate) {
2063
+ const populateSpec = getDeletedOptions.populate;
2064
+ if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
2065
+ else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
2066
+ else query = query.populate(populateSpec);
2067
+ }
2068
+ if (getDeletedOptions.lean !== false) query = query.lean();
2069
+ const [data, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
2070
+ const pages = Math.ceil(total / limit);
2071
+ return {
2072
+ method: "offset",
2073
+ data,
2074
+ page,
2075
+ limit,
2076
+ total,
2077
+ pages,
2078
+ hasNext: page < pages,
2079
+ hasPrev: page > 1
2080
+ };
2081
+ };
2082
+ if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
2083
+ else repo.getDeleted = getDeletedMethod.bind(repo);
2084
+ }
2085
+ }
2086
+ };
2087
+ }
2088
+ //#endregion
2089
+ //#region src/plugins/subdocument.plugin.ts
2090
+ /**
2091
+ * Subdocument plugin for managing nested arrays
2092
+ *
2093
+ * @example
2094
+ * const repo = new Repository(Model, [
2095
+ * methodRegistryPlugin(),
2096
+ * subdocumentPlugin(),
2097
+ * ]);
2098
+ *
2099
+ * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
2100
+ * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
2101
+ */
2102
+ function subdocumentPlugin() {
2103
+ return {
2104
+ name: "subdocument",
2105
+ apply(repo) {
2106
+ if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
2107
+ /**
2108
+ * Add subdocument to array
2109
+ */
2110
+ repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
2111
+ return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
2112
+ });
2113
+ /**
2114
+ * Get subdocument from array
2115
+ */
2116
+ repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
2117
+ return this._executeQuery(async (Model) => {
2118
+ const parent = await Model.findById(parentId).session(options.session).exec();
2119
+ if (!parent) throw createError(404, "Parent not found");
2120
+ const arrayField = parent[arrayPath];
2121
+ if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
2122
+ const sub = arrayField.id(subId);
2123
+ if (!sub) throw createError(404, "Subdocument not found");
2124
+ return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
2125
+ });
2126
+ });
2127
+ /**
2128
+ * Update subdocument in array
2129
+ */
2130
+ repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
2131
+ return this._executeQuery(async (Model) => {
2132
+ const query = {
2133
+ _id: parentId,
2134
+ [`${arrayPath}._id`]: subId
2135
+ };
2136
+ const update = { $set: { [`${arrayPath}.$`]: {
2137
+ ...updateData,
2138
+ _id: subId
2139
+ } } };
2140
+ const result = await Model.findOneAndUpdate(query, update, {
2141
+ returnDocument: "after",
2142
+ runValidators: true,
2143
+ session: options.session
2144
+ }).exec();
2145
+ if (!result) throw createError(404, "Parent or subdocument not found");
2146
+ return result;
2147
+ });
2148
+ });
2149
+ /**
2150
+ * Delete subdocument from array
2151
+ */
2152
+ repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
2153
+ return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
2154
+ });
2155
+ }
2156
+ };
2157
+ }
2158
+ //#endregion
2159
+ //#region src/plugins/tenant-context.ts
2160
+ /**
2161
+ * Request-scoped tenant context via AsyncLocalStorage.
2162
+ *
2163
+ * The multi-tenant plugin already accepts a `resolveContext` callback for
2164
+ * reading tenant ID from an external store. This helper wraps Node's
2165
+ * `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
2166
+ * to wire the plumbing by hand — a common source of cross-tenant leaks.
2167
+ *
2168
+ * @example
2169
+ * import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
2170
+ *
2171
+ * const tenantContext = createTenantContext();
2172
+ *
2173
+ * // In your HTTP middleware (Express / Fastify / NestJS):
2174
+ * app.use((req, _res, next) => {
2175
+ * tenantContext.run({ tenantId: req.auth.organizationId }, next);
2176
+ * });
2177
+ *
2178
+ * // In your repository definition:
2179
+ * const repo = new Repository(Invoice, [
2180
+ * multiTenantPlugin({
2181
+ * tenantField: 'organizationId',
2182
+ * resolveContext: () => tenantContext.getTenantId(),
2183
+ * }),
2184
+ * ]);
2185
+ *
2186
+ * // In your handler — no need to pass organizationId manually:
2187
+ * await repo.getAll({ filters: { status: 'paid' } });
2188
+ */
2189
+ function createTenantContext() {
2190
+ const storage = new AsyncLocalStorage();
2191
+ return {
2192
+ storage,
2193
+ run(store, fn) {
2194
+ return storage.run(store, fn);
2195
+ },
2196
+ getStore() {
2197
+ return storage.getStore();
2198
+ },
2199
+ getTenantId() {
2200
+ return storage.getStore()?.tenantId;
2201
+ },
2202
+ requireTenantId() {
2203
+ const id = storage.getStore()?.tenantId;
2204
+ if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
2205
+ return id;
2206
+ }
2207
+ };
2208
+ }
2209
+ //#endregion
2210
+ //#region src/plugins/timestamp.plugin.ts
2211
+ /**
2212
+ * Timestamp plugin that auto-injects timestamps
2213
+ *
2214
+ * @example
2215
+ * const repo = new Repository(Model, [timestampPlugin()]);
2216
+ */
2217
+ function timestampPlugin() {
2218
+ return {
2219
+ name: "timestamp",
2220
+ apply(repo) {
2221
+ repo.on("before:create", (context) => {
2222
+ if (!context.data) return;
2223
+ const now = /* @__PURE__ */ new Date();
2224
+ if (!context.data.createdAt) context.data.createdAt = now;
2225
+ if (!context.data.updatedAt) context.data.updatedAt = now;
2226
+ });
2227
+ repo.on("before:update", (context) => {
2228
+ if (!context.data) return;
2229
+ context.data.updatedAt = /* @__PURE__ */ new Date();
2230
+ });
2231
+ repo.on("before:findOneAndUpdate", (context) => {
2232
+ if (!context.data) return;
2233
+ const now = /* @__PURE__ */ new Date();
2234
+ if (Array.isArray(context.data)) return;
2235
+ const update = context.data;
2236
+ if (Object.keys(update).some((k) => k.startsWith("$"))) {
2237
+ const set = update.$set || {};
2238
+ set.updatedAt = now;
2239
+ update.$set = set;
2240
+ const setOnInsert = update.$setOnInsert || {};
2241
+ if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
2242
+ update.$setOnInsert = setOnInsert;
2243
+ } else update.updatedAt = now;
2244
+ });
2245
+ }
2246
+ };
2247
+ }
2248
+ //#endregion
2249
+ //#region src/plugins/validation-chain.plugin.ts
2250
+ /**
2251
+ * Validation chain plugin
2252
+ *
2253
+ * @example
2254
+ * const repo = new Repository(Model, [
2255
+ * validationChainPlugin([
2256
+ * requireField('email'),
2257
+ * uniqueField('email', 'Email already exists'),
2258
+ * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
2259
+ * ])
2260
+ * ]);
2261
+ */
2262
+ function validationChainPlugin(validators = [], options = {}) {
2263
+ const { stopOnFirstError = true } = options;
2264
+ validators.forEach((v, idx) => {
2265
+ if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
2266
+ if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
2267
+ });
2268
+ const validatorsByOperation = {
2269
+ create: [],
2270
+ update: [],
2271
+ findOneAndUpdate: [],
2272
+ delete: [],
2273
+ createMany: []
2274
+ };
2275
+ const allOperationsValidators = [];
2276
+ validators.forEach((v) => {
2277
+ if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
2278
+ else v.operations.forEach((op) => {
2279
+ if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
2280
+ });
2281
+ });
2282
+ return {
2283
+ name: "validation-chain",
2284
+ apply(repo) {
2285
+ const getValidatorsForOperation = (operation) => {
2286
+ const specific = validatorsByOperation[operation] || [];
2287
+ return [...allOperationsValidators, ...specific];
2288
+ };
2289
+ const runValidators = async (operation, context) => {
2290
+ const operationValidators = getValidatorsForOperation(operation);
2291
+ const errors = [];
2292
+ for (const validator of operationValidators) try {
2293
+ await validator.validate(context, repo);
2294
+ } catch (error) {
2295
+ if (stopOnFirstError) throw error;
2296
+ errors.push({
2297
+ validator: validator.name,
2298
+ error: error.message || String(error)
2299
+ });
2300
+ }
2301
+ if (errors.length > 0) {
2302
+ const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
2303
+ err.validationErrors = errors;
2304
+ throw err;
2305
+ }
2306
+ };
2307
+ repo.on("before:create", async (context) => runValidators("create", context));
2308
+ repo.on("before:createMany", async (context) => runValidators("createMany", context));
2309
+ repo.on("before:update", async (context) => runValidators("update", context));
2310
+ repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
2311
+ repo.on("before:delete", async (context) => runValidators("delete", context));
2312
+ }
2313
+ };
2314
+ }
2315
+ /**
2316
+ * Block operation if condition is true
2317
+ *
2318
+ * @example
2319
+ * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
2320
+ */
2321
+ function blockIf(name, operations, condition, errorMessage) {
2322
+ return {
2323
+ name,
2324
+ operations,
2325
+ validate: (context) => {
2326
+ if (condition(context)) throw createError(403, errorMessage);
2327
+ }
2328
+ };
2329
+ }
2330
+ /**
2331
+ * Require a field to be present
2332
+ */
2333
+ function requireField(field, operations = ["create"]) {
2334
+ return {
2335
+ name: `require-${field}`,
2336
+ operations,
2337
+ validate: (context) => {
2338
+ if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
2339
+ }
2340
+ };
2341
+ }
2342
+ /**
2343
+ * Auto-inject a value if not present
2344
+ */
2345
+ function autoInject(field, getter, operations = ["create"]) {
2346
+ return {
2347
+ name: `auto-inject-${field}`,
2348
+ operations,
2349
+ validate: (context) => {
2350
+ if (context.data && !(field in context.data)) {
2351
+ const value = getter(context);
2352
+ if (value !== null && value !== void 0) context.data[field] = value;
2353
+ }
2354
+ }
2355
+ };
2356
+ }
2357
+ /**
2358
+ * Make a field immutable (cannot be updated)
2359
+ */
2360
+ function immutableField(field) {
2361
+ return {
2362
+ name: `immutable-${field}`,
2363
+ operations: ["update"],
2364
+ validate: (context) => {
2365
+ if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
2366
+ }
2367
+ };
2368
+ }
2369
+ /**
2370
+ * Ensure field value is unique
2371
+ */
2372
+ function uniqueField(field, errorMessage) {
2373
+ return {
2374
+ name: `unique-${field}`,
2375
+ operations: ["create", "update"],
2376
+ validate: async (context, repo) => {
2377
+ if (!context.data?.[field]) return;
2378
+ if (!repo) {
2379
+ warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
2380
+ return;
2381
+ }
2382
+ const query = { [field]: context.data[field] };
2383
+ const getByQuery = repo.getByQuery;
2384
+ if (typeof getByQuery !== "function") {
2385
+ warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
2386
+ return;
2387
+ }
2388
+ const idKey = repo.idField || "_id";
2389
+ const existing = await getByQuery.call(repo, query, {
2390
+ select: idKey,
2391
+ lean: true,
2392
+ throwOnNotFound: false
2393
+ });
2394
+ if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
2395
+ }
2396
+ };
2397
+ }
2398
+ //#endregion
2399
+ export { aggregateHelpersPlugin as A, sequentialId as C, AuditTrailQuery as D, batchOperationsPlugin as E, auditTrailPlugin as O, prefixedId as S, cachePlugin as T, fieldFilterPlugin as _, uniqueField as a, dateSequentialId as b, createTenantContext as c, observabilityPlugin as d, adminBypass$1 as f, leasePlugin as g, methodRegistryPlugin as h, requireField as i, auditLogPlugin as k, subdocumentPlugin as l, mongoOperationsPlugin as m, blockIf as n, validationChainPlugin as o, multiTenantPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, elasticSearchPlugin as v, cascadePlugin as w, getNextSequence as x, customIdPlugin as y };