@classytic/mongokit 3.12.0 → 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 +74 -4
  2. package/dist/{PaginationEngine-BDaAsyPn.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 +1 -1
  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-D9cORR-o.d.mts → index-BbHaKwi5.d.mts} +33 -13
  15. package/dist/index.d.mts +667 -30
  16. package/dist/index.mjs +240 -17
  17. package/dist/{logger-8-VS1za6.mjs → logger-DprijP5h.mjs} +1 -5
  18. package/dist/{mongooseToJsonSchema-B187ufOP.mjs → mongooseToJsonSchema-BYbG894y.mjs} +1 -1
  19. package/dist/{mongooseToJsonSchema-rtkdbBhf.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +2 -1
  20. package/dist/pagination/PaginationEngine.d.mts +2 -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-DXscZnLu.d.mts → types-BbaPZleN.d.mts} +371 -112
  28. package/dist/{update-CDxPtpsk.mjs → update-DLcSSa5b.mjs} +41 -27
  29. package/dist/utils/index.d.mts +2 -91
  30. package/dist/utils/index.mjs +5 -5
  31. package/dist/{validation-chain.plugin-CH-xPoKM.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +212 -80
  32. package/dist/validation-chain.plugin-_-WuNml2.mjs +2399 -0
  33. package/package.json +20 -3
  34. package/dist/field-selection-dI0m4RBs.mjs +0 -221
  35. package/dist/validation-chain.plugin-BicIgzzt.mjs +0 -4970
  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
@@ -1,4970 +0,0 @@
1
- import { _ as upsert, a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-CDxPtpsk.mjs";
2
- import { n as debug, r as warn } from "./logger-8-VS1za6.mjs";
3
- import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
4
- import { t as PaginationEngine } from "./PaginationEngine-BDaAsyPn.mjs";
5
- import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
6
- import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-e4BT9zAV.mjs";
7
- import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-dI0m4RBs.mjs";
8
- import { isFilter } from "@classytic/repo-core/filter";
9
- import mongoose from "mongoose";
10
- import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
11
- import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
12
- import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
13
- import { AsyncLocalStorage } from "node:async_hooks";
14
- //#region src/operations.ts
15
- /**
16
- * The registry. Order intentionally groups by category for readability.
17
- * Adding a new RepositoryOperation requires exactly one entry here, after
18
- * which every bundled plugin auto-classifies it correctly.
19
- */
20
- const OP_REGISTRY = {
21
- create: {
22
- policyKey: "data",
23
- mutates: true,
24
- hasIdContext: false
25
- },
26
- update: {
27
- policyKey: "query",
28
- mutates: true,
29
- hasIdContext: true
30
- },
31
- findOneAndUpdate: {
32
- policyKey: "query",
33
- mutates: true,
34
- hasIdContext: false
35
- },
36
- delete: {
37
- policyKey: "query",
38
- mutates: true,
39
- hasIdContext: true
40
- },
41
- restore: {
42
- policyKey: "query",
43
- mutates: true,
44
- hasIdContext: true
45
- },
46
- createMany: {
47
- policyKey: "dataArray",
48
- mutates: true,
49
- hasIdContext: false
50
- },
51
- updateMany: {
52
- policyKey: "query",
53
- mutates: true,
54
- hasIdContext: false
55
- },
56
- deleteMany: {
57
- policyKey: "query",
58
- mutates: true,
59
- hasIdContext: false
60
- },
61
- bulkWrite: {
62
- policyKey: "operations",
63
- mutates: true,
64
- hasIdContext: false
65
- },
66
- getById: {
67
- policyKey: "query",
68
- mutates: false,
69
- hasIdContext: true
70
- },
71
- getByQuery: {
72
- policyKey: "query",
73
- mutates: false,
74
- hasIdContext: false
75
- },
76
- getOne: {
77
- policyKey: "query",
78
- mutates: false,
79
- hasIdContext: false
80
- },
81
- findAll: {
82
- policyKey: "query",
83
- mutates: false,
84
- hasIdContext: false
85
- },
86
- getOrCreate: {
87
- policyKey: "query",
88
- mutates: false,
89
- hasIdContext: false
90
- },
91
- count: {
92
- policyKey: "query",
93
- mutates: false,
94
- hasIdContext: false
95
- },
96
- exists: {
97
- policyKey: "query",
98
- mutates: false,
99
- hasIdContext: false
100
- },
101
- distinct: {
102
- policyKey: "query",
103
- mutates: false,
104
- hasIdContext: false
105
- },
106
- aggregate: {
107
- policyKey: "query",
108
- mutates: false,
109
- hasIdContext: false
110
- },
111
- aggregatePipeline: {
112
- policyKey: "query",
113
- mutates: false,
114
- hasIdContext: false
115
- },
116
- getAll: {
117
- policyKey: "filters",
118
- mutates: false,
119
- hasIdContext: false
120
- },
121
- aggregatePaginate: {
122
- policyKey: "filters",
123
- mutates: false,
124
- hasIdContext: false
125
- },
126
- aggregatePipelinePaginate: {
127
- policyKey: "filters",
128
- mutates: false,
129
- hasIdContext: false
130
- },
131
- lookupPopulate: {
132
- policyKey: "filters",
133
- mutates: false,
134
- hasIdContext: false
135
- }
136
- };
137
- /** All known repository operations, in registry order. */
138
- const ALL_OPERATIONS = Object.keys(OP_REGISTRY);
139
- /** Operations that mutate the database. Drives audit + cache invalidation lists. */
140
- const MUTATING_OPERATIONS = ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].mutates);
141
- /** Operations that don't mutate. Drives default cacheable ops. */
142
- const READ_OPERATIONS = ALL_OPERATIONS.filter((op) => !OP_REGISTRY[op].mutates);
143
- /** Filter ops by their policy injection key. */
144
- function operationsByPolicyKey(key) {
145
- return ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].policyKey === key);
146
- }
147
- //#endregion
148
- //#region src/plugins/aggregate-helpers.plugin.ts
149
- /**
150
- * Aggregate helpers plugin
151
- *
152
- * @example
153
- * const repo = new Repository(Model, [
154
- * methodRegistryPlugin(),
155
- * aggregateHelpersPlugin(),
156
- * ]);
157
- *
158
- * const groups = await repo.groupBy('category');
159
- * const total = await repo.sum('amount', { status: 'completed' });
160
- */
161
- function aggregateHelpersPlugin() {
162
- return {
163
- name: "aggregate-helpers",
164
- apply(repo) {
165
- if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
166
- /**
167
- * Group by field
168
- */
169
- repo.registerMethod("groupBy", async function(field, options = {}) {
170
- const pipeline = [{ $group: {
171
- _id: `$${field}`,
172
- count: { $sum: 1 }
173
- } }, { $sort: { count: -1 } }];
174
- if (options.limit) pipeline.push({ $limit: options.limit });
175
- return this.aggregatePipeline(pipeline, options);
176
- });
177
- const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
178
- const pipeline = [{ $match: query }, { $group: {
179
- _id: null,
180
- [resultKey]: { [operator]: `$${field}` }
181
- } }];
182
- return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
183
- };
184
- /**
185
- * Sum field values
186
- */
187
- repo.registerMethod("sum", async function(field, query = {}, options = {}) {
188
- return aggregateOperation.call(this, field, "$sum", "total", query, options);
189
- });
190
- /**
191
- * Average field values
192
- */
193
- repo.registerMethod("average", async function(field, query = {}, options = {}) {
194
- return aggregateOperation.call(this, field, "$avg", "avg", query, options);
195
- });
196
- /**
197
- * Get minimum value
198
- */
199
- repo.registerMethod("min", async function(field, query = {}, options = {}) {
200
- return aggregateOperation.call(this, field, "$min", "min", query, options);
201
- });
202
- /**
203
- * Get maximum value
204
- */
205
- repo.registerMethod("max", async function(field, query = {}, options = {}) {
206
- return aggregateOperation.call(this, field, "$max", "max", query, options);
207
- });
208
- }
209
- };
210
- }
211
- //#endregion
212
- //#region src/plugins/audit-log.plugin.ts
213
- /**
214
- * Audit log plugin that logs all repository operations
215
- *
216
- * @example
217
- * const repo = new Repository(Model, [auditLogPlugin(console)]);
218
- */
219
- function auditLogPlugin(logger) {
220
- return {
221
- name: "auditLog",
222
- apply(repo) {
223
- repo.on("after:create", ({ context, result }) => {
224
- const idKey = repo.idField || "_id";
225
- logger?.info?.("Document created", {
226
- model: context.model || repo.model,
227
- id: result?.[idKey],
228
- userId: context.user?._id || context.user?.id,
229
- organizationId: context.organizationId
230
- });
231
- });
232
- repo.on("after:update", ({ context, result }) => {
233
- logger?.info?.("Document updated", {
234
- model: context.model || repo.model,
235
- id: context.id || result?.[repo.idField || "_id"],
236
- userId: context.user?._id || context.user?.id,
237
- organizationId: context.organizationId
238
- });
239
- });
240
- repo.on("after:findOneAndUpdate", ({ context, result }) => {
241
- if (!result) return;
242
- logger?.info?.("Document upserted/updated (findOneAndUpdate)", {
243
- model: context.model || repo.model,
244
- id: result?.[repo.idField || "_id"],
245
- userId: context.user?._id || context.user?.id,
246
- organizationId: context.organizationId
247
- });
248
- });
249
- repo.on("error:findOneAndUpdate", ({ context, error }) => {
250
- logger?.error?.("findOneAndUpdate failed", {
251
- model: context.model || repo.model,
252
- error: error.message,
253
- userId: context.user?._id || context.user?.id
254
- });
255
- });
256
- repo.on("after:delete", ({ context }) => {
257
- logger?.info?.("Document deleted", {
258
- model: context.model || repo.model,
259
- id: context.id,
260
- userId: context.user?._id || context.user?.id,
261
- organizationId: context.organizationId
262
- });
263
- });
264
- repo.on("error:create", ({ context, error }) => {
265
- logger?.error?.("Create failed", {
266
- model: context.model || repo.model,
267
- error: error.message,
268
- userId: context.user?._id || context.user?.id
269
- });
270
- });
271
- repo.on("error:update", ({ context, error }) => {
272
- logger?.error?.("Update failed", {
273
- model: context.model || repo.model,
274
- id: context.id,
275
- error: error.message,
276
- userId: context.user?._id || context.user?.id
277
- });
278
- });
279
- repo.on("error:delete", ({ context, error }) => {
280
- logger?.error?.("Delete failed", {
281
- model: context.model || repo.model,
282
- id: context.id,
283
- error: error.message,
284
- userId: context.user?._id || context.user?.id
285
- });
286
- });
287
- }
288
- };
289
- }
290
- //#endregion
291
- //#region src/plugins/audit-trail.plugin.ts
292
- /**
293
- * Audit Trail Plugin
294
- *
295
- * Persists operation audit entries to a MongoDB collection.
296
- * Fire-and-forget: writes happen async and never block or fail the main operation.
297
- *
298
- * Features:
299
- * - Tracks create, update, delete operations
300
- * - Field-level change tracking (before/after diff on updates)
301
- * - TTL auto-cleanup via MongoDB TTL index
302
- * - Custom metadata per entry (IP, user-agent, etc.)
303
- * - Shared `audit_trails` collection across all models
304
- *
305
- * @example
306
- * ```typescript
307
- * const repo = new Repository(Job, [
308
- * auditTrailPlugin({
309
- * operations: ['create', 'update', 'delete'],
310
- * trackChanges: true,
311
- * ttlDays: 90,
312
- * metadata: (context) => ({
313
- * ip: context.req?.ip,
314
- * }),
315
- * }),
316
- * ]);
317
- * ```
318
- */
319
- const modelCache = /* @__PURE__ */ new Map();
320
- function getAuditModel(collectionName, ttlDays) {
321
- const existing = modelCache.get(collectionName);
322
- if (existing) return existing;
323
- const schema = new mongoose.Schema({
324
- model: {
325
- type: String,
326
- required: true,
327
- index: true
328
- },
329
- operation: {
330
- type: String,
331
- required: true,
332
- enum: [
333
- "create",
334
- "update",
335
- "findOneAndUpdate",
336
- "delete"
337
- ]
338
- },
339
- documentId: {
340
- type: mongoose.Schema.Types.Mixed,
341
- required: true,
342
- index: true
343
- },
344
- userId: {
345
- type: mongoose.Schema.Types.Mixed,
346
- index: true
347
- },
348
- orgId: {
349
- type: mongoose.Schema.Types.Mixed,
350
- index: true
351
- },
352
- changes: { type: mongoose.Schema.Types.Mixed },
353
- document: { type: mongoose.Schema.Types.Mixed },
354
- metadata: { type: mongoose.Schema.Types.Mixed },
355
- timestamp: {
356
- type: Date,
357
- default: Date.now,
358
- index: true
359
- }
360
- }, {
361
- collection: collectionName,
362
- versionKey: false
363
- });
364
- schema.index({
365
- model: 1,
366
- documentId: 1,
367
- timestamp: -1
368
- });
369
- schema.index({
370
- orgId: 1,
371
- userId: 1,
372
- timestamp: -1
373
- });
374
- if (ttlDays !== void 0 && ttlDays > 0) {
375
- const ttlSeconds = ttlDays * 24 * 60 * 60;
376
- schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
377
- }
378
- const modelName = `AuditTrail_${collectionName}`;
379
- const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
380
- modelCache.set(collectionName, model);
381
- return model;
382
- }
383
- /** Compute field-level diff between previous and updated document */
384
- function computeChanges(prev, next, excludeFields) {
385
- const changes = {};
386
- const exclude = new Set(excludeFields);
387
- for (const key of Object.keys(next)) {
388
- if (exclude.has(key)) continue;
389
- if (key === "_id" || key === "__v" || key === "updatedAt") continue;
390
- const prevVal = prev[key];
391
- const nextVal = next[key];
392
- if (!deepEqual(prevVal, nextVal)) changes[key] = {
393
- from: prevVal,
394
- to: nextVal
395
- };
396
- }
397
- return Object.keys(changes).length > 0 ? changes : void 0;
398
- }
399
- /** Simple deep equality check for audit diffing */
400
- function deepEqual(a, b) {
401
- if (a === b) return true;
402
- if (a == null && b == null) return true;
403
- if (a == null || b == null) return false;
404
- if (typeof a === "object" && typeof b === "object") {
405
- const aStr = a.toString?.();
406
- const bStr = b.toString?.();
407
- if (aStr && bStr && aStr === bStr) return true;
408
- }
409
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
410
- try {
411
- return JSON.stringify(a) === JSON.stringify(b);
412
- } catch {
413
- return false;
414
- }
415
- }
416
- /** Extract user ID from context */
417
- function getUserId(context) {
418
- return context.user?._id || context.user?.id;
419
- }
420
- /** Fire-and-forget: write audit entry, never throw */
421
- function writeAudit(AuditModel, entry) {
422
- Promise.resolve().then(() => {
423
- AuditModel.create({
424
- ...entry,
425
- timestamp: /* @__PURE__ */ new Date()
426
- }).catch((err) => {
427
- warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
428
- });
429
- });
430
- }
431
- const snapshots = /* @__PURE__ */ new WeakMap();
432
- function auditTrailPlugin(options = {}) {
433
- const { operations = [
434
- "create",
435
- "update",
436
- "delete"
437
- ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
438
- const opsSet = new Set(operations);
439
- return {
440
- name: "auditTrail",
441
- apply(repo) {
442
- const AuditModel = getAuditModel(collectionName, ttlDays);
443
- if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
444
- const doc = toPlainObject(result);
445
- const idKey = repo.idField || "_id";
446
- writeAudit(AuditModel, {
447
- model: context.model || repo.model,
448
- operation: "create",
449
- documentId: doc?.[idKey],
450
- userId: getUserId(context),
451
- orgId: context.organizationId,
452
- document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
453
- metadata: metadata?.(context)
454
- });
455
- });
456
- if (opsSet.has("update")) {
457
- if (trackChanges) repo.on("before:update", async (context) => {
458
- if (!context.id) return;
459
- try {
460
- const prev = await repo.Model.findById(context.id).lean();
461
- if (prev) snapshots.set(context, prev);
462
- } catch (err) {
463
- warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
464
- }
465
- });
466
- repo.on("after:update", ({ context, result }) => {
467
- const doc = result;
468
- let changes;
469
- if (trackChanges) {
470
- const prev = snapshots.get(context);
471
- if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
472
- snapshots.delete(context);
473
- }
474
- writeAudit(AuditModel, {
475
- model: context.model || repo.model,
476
- operation: "update",
477
- documentId: context.id || doc?.[repo.idField || "_id"],
478
- userId: getUserId(context),
479
- orgId: context.organizationId,
480
- changes,
481
- metadata: metadata?.(context)
482
- });
483
- });
484
- }
485
- if (opsSet.has("findOneAndUpdate")) repo.on("after:findOneAndUpdate", ({ context, result }) => {
486
- if (!result) return;
487
- const doc = result;
488
- const idKey = repo.idField || "_id";
489
- writeAudit(AuditModel, {
490
- model: context.model || repo.model,
491
- operation: "findOneAndUpdate",
492
- documentId: doc?.[idKey],
493
- userId: getUserId(context),
494
- orgId: context.organizationId,
495
- metadata: metadata?.(context)
496
- });
497
- });
498
- if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
499
- writeAudit(AuditModel, {
500
- model: context.model || repo.model,
501
- operation: "delete",
502
- documentId: context.id,
503
- userId: getUserId(context),
504
- orgId: context.organizationId,
505
- metadata: metadata?.(context)
506
- });
507
- });
508
- if (typeof repo.registerMethod === "function")
509
- /**
510
- * Get audit trail for a specific document
511
- */
512
- repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
513
- const { page = 1, limit = 20, operation } = queryOptions;
514
- const skip = (page - 1) * limit;
515
- const filter = {
516
- model: this.model,
517
- documentId
518
- };
519
- if (operation) filter.operation = operation;
520
- const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
521
- return {
522
- docs,
523
- page,
524
- limit,
525
- total,
526
- pages: Math.ceil(total / limit),
527
- hasNext: page < Math.ceil(total / limit),
528
- hasPrev: page > 1
529
- };
530
- });
531
- }
532
- };
533
- }
534
- /** Convert Mongoose document to plain object */
535
- function toPlainObject(doc) {
536
- if (!doc) return {};
537
- if (typeof doc.toObject === "function") return doc.toObject();
538
- return doc;
539
- }
540
- /** Remove excluded fields from a document snapshot */
541
- function sanitizeDoc(doc, excludeFields) {
542
- if (excludeFields.length === 0) return doc;
543
- const result = { ...doc };
544
- for (const field of excludeFields) delete result[field];
545
- return result;
546
- }
547
- /**
548
- * Standalone audit trail query utility.
549
- * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
550
- *
551
- * @example
552
- * ```typescript
553
- * import { AuditTrailQuery } from '@classytic/mongokit';
554
- *
555
- * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
556
- *
557
- * // All audits for an org
558
- * const orgAudits = await auditQuery.query({ orgId: '...' });
559
- *
560
- * // All updates by a user
561
- * const userUpdates = await auditQuery.query({
562
- * userId: '...',
563
- * operation: 'update',
564
- * });
565
- *
566
- * // All audits for a specific document
567
- * const docHistory = await auditQuery.query({
568
- * model: 'Job',
569
- * documentId: '...',
570
- * });
571
- *
572
- * // Date range
573
- * const recent = await auditQuery.query({
574
- * from: new Date('2025-01-01'),
575
- * to: new Date(),
576
- * page: 1,
577
- * limit: 50,
578
- * });
579
- *
580
- * // Direct model access for custom queries
581
- * const model = auditQuery.getModel();
582
- * const count = await model.countDocuments({ operation: 'delete' });
583
- * ```
584
- */
585
- var AuditTrailQuery = class {
586
- model;
587
- constructor(collectionName = "audit_trails", ttlDays) {
588
- this.model = getAuditModel(collectionName, ttlDays);
589
- }
590
- /**
591
- * Get the underlying Mongoose model for custom queries
592
- */
593
- getModel() {
594
- return this.model;
595
- }
596
- /**
597
- * Query audit entries with filters and pagination
598
- */
599
- async query(options = {}) {
600
- const { page = 1, limit = 20 } = options;
601
- const skip = (page - 1) * limit;
602
- const filter = {};
603
- if (options.model) filter.model = options.model;
604
- if (options.documentId) filter.documentId = options.documentId;
605
- if (options.userId) filter.userId = options.userId;
606
- if (options.orgId) filter.orgId = options.orgId;
607
- if (options.operation) filter.operation = options.operation;
608
- if (options.from || options.to) {
609
- const dateFilter = {};
610
- if (options.from) dateFilter.$gte = options.from;
611
- if (options.to) dateFilter.$lte = options.to;
612
- filter.timestamp = dateFilter;
613
- }
614
- const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
615
- const pages = Math.ceil(total / limit);
616
- return {
617
- docs,
618
- page,
619
- limit,
620
- total,
621
- pages,
622
- hasNext: page < pages,
623
- hasPrev: page > 1
624
- };
625
- }
626
- /**
627
- * Get audit trail for a specific document
628
- */
629
- async getDocumentTrail(model, documentId, options = {}) {
630
- return this.query({
631
- model,
632
- documentId,
633
- ...options
634
- });
635
- }
636
- /**
637
- * Get all audits for a user
638
- */
639
- async getUserTrail(userId, options = {}) {
640
- return this.query({
641
- userId,
642
- ...options
643
- });
644
- }
645
- /**
646
- * Get all audits for an organization
647
- */
648
- async getOrgTrail(orgId, options = {}) {
649
- return this.query({
650
- orgId,
651
- ...options
652
- });
653
- }
654
- };
655
- //#endregion
656
- //#region src/plugins/batch-operations.plugin.ts
657
- /**
658
- * Batch operations plugin.
659
- *
660
- * @example
661
- * const repo = new Repository(Model, [
662
- * methodRegistryPlugin(),
663
- * batchOperationsPlugin(),
664
- * ]);
665
- *
666
- * // updateMany + deleteMany are primitives on Repository — no plugin needed.
667
- * await repo.updateMany({ status: 'pending' }, { status: 'active' });
668
- * await repo.deleteMany({ status: 'deleted' });
669
- *
670
- * // bulkWrite is what this plugin contributes.
671
- * await repo.bulkWrite([...ops]);
672
- */
673
- function batchOperationsPlugin() {
674
- return {
675
- name: "batch-operations",
676
- apply(repo) {
677
- if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
678
- /**
679
- * Execute heterogeneous bulk write operations in a single database call.
680
- *
681
- * Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
682
- * operations mixed together for maximum efficiency.
683
- *
684
- * @example
685
- * await repo.bulkWrite([
686
- * { insertOne: { document: { name: 'New Item', price: 10 } } },
687
- * { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
688
- * { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
689
- * { deleteOne: { filter: { _id: id2 } } },
690
- * ]);
691
- */
692
- repo.registerMethod("bulkWrite", async function(operations, options = {}) {
693
- const context = await this._buildContext("bulkWrite", {
694
- operations,
695
- ...options
696
- });
697
- try {
698
- const finalOps = context.operations || operations;
699
- if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
700
- const result = await this.Model.bulkWrite(finalOps, {
701
- ordered: options.ordered ?? true,
702
- session: options.session
703
- });
704
- const bulkResult = {
705
- ok: result.ok,
706
- insertedCount: result.insertedCount,
707
- upsertedCount: result.upsertedCount,
708
- matchedCount: result.matchedCount,
709
- modifiedCount: result.modifiedCount,
710
- deletedCount: result.deletedCount,
711
- insertedIds: result.insertedIds,
712
- upsertedIds: result.upsertedIds
713
- };
714
- await this.emitAsync("after:bulkWrite", {
715
- context,
716
- result: bulkResult
717
- });
718
- return bulkResult;
719
- } catch (error) {
720
- this.emit("error:bulkWrite", {
721
- context,
722
- error
723
- });
724
- throw this._handleError(error);
725
- }
726
- });
727
- }
728
- };
729
- }
730
- //#endregion
731
- //#region src/actions/aggregate-ir/normalize.ts
732
- /**
733
- * Normalize `AggRequest['groupBy']` into a readonly string array.
734
- * Returns `[]` for scalar aggregation (no groupBy). Downstream
735
- * compilers treat `[]` uniformly — one `$group: { _id: null, ... }`
736
- * stage, no per-key projection.
737
- */
738
- function normalizeGroupBy(groupBy) {
739
- if (!groupBy) return [];
740
- if (typeof groupBy === "string") return [groupBy];
741
- return groupBy;
742
- }
743
- /**
744
- * Fail loud on an empty measures bag — there's nothing to compute and
745
- * the caller's code path is almost certainly a wiring bug (conditional
746
- * collapsed, key renamed, etc.). Silently returning `{ rows: [] }`
747
- * would mask it.
748
- */
749
- function validateMeasures(measures) {
750
- if (!measures || Object.keys(measures).length === 0) throw new Error("mongokit/aggregate: AggRequest requires at least one measure — empty measures bag is a wiring bug");
751
- }
752
- //#endregion
753
- //#region src/actions/aggregate-ir/measure.ts
754
- function compileMeasure(measure) {
755
- switch (measure.op) {
756
- case "count":
757
- if (!measure.field || measure.field === "*") return { groupExpr: { $sum: 1 } };
758
- return { groupExpr: { $sum: { $cond: [
759
- { $ne: [`$${measure.field}`, null] },
760
- 1,
761
- 0
762
- ] } } };
763
- case "countDistinct": return {
764
- groupExpr: { $addToSet: `$${measure.field}` },
765
- projectExpr: { $size: { $ifNull: ["$$CURRENT", []] } }
766
- };
767
- case "sum": return { groupExpr: { $sum: `$${measure.field}` } };
768
- case "avg": return { groupExpr: { $avg: `$${measure.field}` } };
769
- case "min": return { groupExpr: { $min: `$${measure.field}` } };
770
- case "max": return { groupExpr: { $max: `$${measure.field}` } };
771
- }
772
- }
773
- //#endregion
774
- //#region src/actions/aggregate-ir/pipeline.ts
775
- function buildAggPipeline(req) {
776
- validateMeasures(req.measures);
777
- const groupCols = normalizeGroupBy(req.groupBy);
778
- const stages = [];
779
- if (req.filter) {
780
- const match = compileFilterToMongo(req.filter);
781
- if (Object.keys(match).length > 0) stages.push({ $match: match });
782
- }
783
- const groupStage = { _id: groupCols.length === 0 ? null : groupCols.reduce((acc, field) => {
784
- acc[field] = `$${field}`;
785
- return acc;
786
- }, {}) };
787
- const distinctSetAliases = [];
788
- for (const [alias, measure] of Object.entries(req.measures)) {
789
- groupStage[alias] = compileMeasure(measure).groupExpr;
790
- if (measure.op === "countDistinct") distinctSetAliases.push(alias);
791
- }
792
- stages.push({ $group: groupStage });
793
- if (distinctSetAliases.length > 0) {
794
- const addFields = {};
795
- for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
796
- stages.push({ $addFields: addFields });
797
- }
798
- const projection = { _id: 0 };
799
- for (const field of groupCols) projection[field] = `$_id.${field}`;
800
- for (const alias of Object.keys(req.measures)) projection[alias] = 1;
801
- stages.push({ $project: projection });
802
- if (req.having) {
803
- const match = compileFilterToMongo(req.having);
804
- if (Object.keys(match).length > 0) stages.push({ $match: match });
805
- }
806
- const prePaginationIndex = stages.length;
807
- if (req.sort) stages.push({ $sort: req.sort });
808
- if (typeof req.offset === "number" && req.offset > 0) stages.push({ $skip: req.offset });
809
- if (typeof req.limit === "number") stages.push({ $limit: req.limit });
810
- return {
811
- pipeline: stages,
812
- prePaginationIndex
813
- };
814
- }
815
- //#endregion
816
- //#region src/actions/aggregate-ir/count.ts
817
- async function countAggGroups(Model, req, options = {}) {
818
- const session = options.session;
819
- const groupCols = normalizeGroupBy(req.groupBy);
820
- if (req.having) {
821
- const { pipeline, prePaginationIndex } = buildAggPipeline(req);
822
- const finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
823
- const aggregation = Model.aggregate(finalPipeline);
824
- if (session) aggregation.session(session);
825
- const [row] = await aggregation.exec();
826
- return row?.n ?? 0;
827
- }
828
- const match = req.filter ? compileFilterToMongo(req.filter) : {};
829
- if (groupCols.length === 0) {
830
- const pipeline = [];
831
- if (Object.keys(match).length > 0) pipeline.push({ $match: match });
832
- pipeline.push({ $limit: 1 });
833
- pipeline.push({ $count: "n" });
834
- const aggregation = Model.aggregate(pipeline);
835
- if (session) aggregation.session(session);
836
- const [row] = await aggregation.exec();
837
- return row?.n ?? 0;
838
- }
839
- const groupId = groupCols.reduce((acc, field) => {
840
- acc[field] = `$${field}`;
841
- return acc;
842
- }, {});
843
- const pipeline = [];
844
- if (Object.keys(match).length > 0) pipeline.push({ $match: match });
845
- pipeline.push({ $group: { _id: groupId } });
846
- pipeline.push({ $count: "n" });
847
- const aggregation = Model.aggregate(pipeline);
848
- if (session) aggregation.session(session);
849
- const [row] = await aggregation.exec();
850
- return row?.n ?? 0;
851
- }
852
- //#endregion
853
- //#region src/actions/aggregate-ir/execute.ts
854
- async function executeAgg(Model, req, options = {}) {
855
- const session = options.session;
856
- const { pipeline } = buildAggPipeline(req);
857
- const aggregation = Model.aggregate(pipeline);
858
- if (session) aggregation.session(session);
859
- return await aggregation.exec();
860
- }
861
- //#endregion
862
- //#region src/query/AggregationBuilder.ts
863
- /**
864
- * Normalize SortSpec to MongoDB's strict format (1 | -1)
865
- * Converts 'asc' -> 1, 'desc' -> -1
866
- */
867
- function normalizeSortSpec(sortSpec) {
868
- const normalized = {};
869
- for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
870
- else if (order === "desc") normalized[field] = -1;
871
- else normalized[field] = order;
872
- return normalized;
873
- }
874
- /**
875
- * Fluent builder for MongoDB aggregation pipelines
876
- * Optimized for complex queries at scale
877
- */
878
- var AggregationBuilder = class AggregationBuilder {
879
- pipeline = [];
880
- _diskUse = false;
881
- /**
882
- * Get the current pipeline
883
- */
884
- get() {
885
- return [...this.pipeline];
886
- }
887
- /**
888
- * Build and return the final pipeline
889
- */
890
- build() {
891
- return this.get();
892
- }
893
- /**
894
- * Build pipeline with execution options (allowDiskUse, etc.)
895
- */
896
- plan() {
897
- return {
898
- pipeline: this.get(),
899
- allowDiskUse: this._diskUse
900
- };
901
- }
902
- /**
903
- * Build and execute the pipeline against a model
904
- *
905
- * @example
906
- * ```typescript
907
- * const results = await new AggregationBuilder()
908
- * .match({ status: 'active' })
909
- * .allowDiskUse()
910
- * .exec(MyModel);
911
- * ```
912
- */
913
- async exec(model, session) {
914
- const agg = model.aggregate(this.build());
915
- if (this._diskUse) agg.allowDiskUse(true);
916
- if (session) agg.session(session);
917
- return agg.exec();
918
- }
919
- /**
920
- * Reset the pipeline
921
- */
922
- reset() {
923
- this.pipeline = [];
924
- this._diskUse = false;
925
- return this;
926
- }
927
- /**
928
- * Add a raw pipeline stage
929
- */
930
- addStage(stage) {
931
- this.pipeline.push(stage);
932
- return this;
933
- }
934
- /**
935
- * Add multiple raw pipeline stages
936
- */
937
- addStages(stages) {
938
- this.pipeline.push(...stages);
939
- return this;
940
- }
941
- /**
942
- * $match - Filter documents
943
- * IMPORTANT: Place $match as early as possible for performance
944
- */
945
- match(query) {
946
- this.pipeline.push({ $match: query });
947
- return this;
948
- }
949
- /**
950
- * $project - Include/exclude fields or compute new fields
951
- */
952
- project(projection) {
953
- this.pipeline.push({ $project: projection });
954
- return this;
955
- }
956
- /**
957
- * $group - Group documents and compute aggregations
958
- *
959
- * @example
960
- * ```typescript
961
- * .group({
962
- * _id: '$department',
963
- * count: { $sum: 1 },
964
- * avgSalary: { $avg: '$salary' }
965
- * })
966
- * ```
967
- */
968
- group(groupSpec) {
969
- this.pipeline.push({ $group: groupSpec });
970
- return this;
971
- }
972
- /**
973
- * $sort - Sort documents
974
- */
975
- sort(sortSpec) {
976
- if (typeof sortSpec === "string") {
977
- const order = sortSpec.startsWith("-") ? -1 : 1;
978
- const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
979
- this.pipeline.push({ $sort: { [field]: order } });
980
- } else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
981
- return this;
982
- }
983
- /**
984
- * $limit - Limit number of documents
985
- */
986
- limit(count) {
987
- this.pipeline.push({ $limit: count });
988
- return this;
989
- }
990
- /**
991
- * $skip - Skip documents
992
- */
993
- skip(count) {
994
- this.pipeline.push({ $skip: count });
995
- return this;
996
- }
997
- /**
998
- * $unwind - Deconstruct array field
999
- */
1000
- unwind(path, preserveNullAndEmptyArrays = false) {
1001
- this.pipeline.push({ $unwind: {
1002
- path: path.startsWith("$") ? path : `$${path}`,
1003
- preserveNullAndEmptyArrays
1004
- } });
1005
- return this;
1006
- }
1007
- /**
1008
- * $addFields - Add new fields or replace existing fields
1009
- */
1010
- addFields(fields) {
1011
- this.pipeline.push({ $addFields: fields });
1012
- return this;
1013
- }
1014
- /**
1015
- * $set - Alias for $addFields
1016
- */
1017
- set(fields) {
1018
- return this.addFields(fields);
1019
- }
1020
- /**
1021
- * $unset - Remove fields
1022
- */
1023
- unset(fields) {
1024
- this.pipeline.push({ $unset: fields });
1025
- return this;
1026
- }
1027
- /**
1028
- * $replaceRoot - Replace the root document
1029
- */
1030
- replaceRoot(newRoot) {
1031
- this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
1032
- return this;
1033
- }
1034
- /**
1035
- * $lookup - Join with another collection (simple form)
1036
- *
1037
- * @param from - Collection to join with
1038
- * @param localField - Field from source collection
1039
- * @param foreignField - Field from target collection
1040
- * @param as - Output field name
1041
- * @param single - Unwrap array to single object
1042
- *
1043
- * @example
1044
- * ```typescript
1045
- * // Join employees with departments by slug
1046
- * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1047
- * ```
1048
- */
1049
- lookup(from, localField, foreignField, as, single) {
1050
- const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
1051
- this.pipeline.push(...stages);
1052
- return this;
1053
- }
1054
- /**
1055
- * $lookup - Join with another collection (advanced form with pipeline)
1056
- *
1057
- * @example
1058
- * ```typescript
1059
- * .lookupWithPipeline({
1060
- * from: 'products',
1061
- * localField: 'productIds',
1062
- * foreignField: 'sku',
1063
- * as: 'products',
1064
- * pipeline: [
1065
- * { $match: { status: 'active' } },
1066
- * { $project: { name: 1, price: 1 } }
1067
- * ]
1068
- * })
1069
- * ```
1070
- */
1071
- lookupWithPipeline(options) {
1072
- const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
1073
- if (options.as) builder.as(options.as);
1074
- if (options.single) builder.single(options.single);
1075
- if (options.pipeline) builder.pipeline(options.pipeline);
1076
- if (options.let) builder.let(options.let);
1077
- this.pipeline.push(...builder.build());
1078
- return this;
1079
- }
1080
- /**
1081
- * Multiple lookups at once
1082
- *
1083
- * @example
1084
- * ```typescript
1085
- * .multiLookup([
1086
- * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
1087
- * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
1088
- * ])
1089
- * ```
1090
- */
1091
- multiLookup(lookups) {
1092
- const stages = LookupBuilder.multiple(lookups);
1093
- this.pipeline.push(...stages);
1094
- return this;
1095
- }
1096
- /**
1097
- * $facet - Process multiple aggregation pipelines in a single stage
1098
- * Useful for computing multiple aggregations in parallel
1099
- *
1100
- * @example
1101
- * ```typescript
1102
- * .facet({
1103
- * totalCount: [{ $count: 'count' }],
1104
- * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
1105
- * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
1106
- * })
1107
- * ```
1108
- */
1109
- facet(facets) {
1110
- this.pipeline.push({ $facet: facets });
1111
- return this;
1112
- }
1113
- /**
1114
- * $bucket - Categorize documents into buckets
1115
- *
1116
- * @example
1117
- * ```typescript
1118
- * .bucket({
1119
- * groupBy: '$price',
1120
- * boundaries: [0, 50, 100, 200],
1121
- * default: 'Other',
1122
- * output: {
1123
- * count: { $sum: 1 },
1124
- * products: { $push: '$name' }
1125
- * }
1126
- * })
1127
- * ```
1128
- */
1129
- bucket(options) {
1130
- this.pipeline.push({ $bucket: options });
1131
- return this;
1132
- }
1133
- /**
1134
- * $bucketAuto - Automatically determine bucket boundaries
1135
- */
1136
- bucketAuto(options) {
1137
- this.pipeline.push({ $bucketAuto: options });
1138
- return this;
1139
- }
1140
- /**
1141
- * $setWindowFields - Perform window functions (MongoDB 5.0+)
1142
- * Useful for rankings, running totals, moving averages
1143
- *
1144
- * @example
1145
- * ```typescript
1146
- * .setWindowFields({
1147
- * partitionBy: '$department',
1148
- * sortBy: { salary: -1 },
1149
- * output: {
1150
- * rank: { $rank: {} },
1151
- * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
1152
- * }
1153
- * })
1154
- * ```
1155
- */
1156
- setWindowFields(options) {
1157
- const normalizedOptions = {
1158
- ...options,
1159
- sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
1160
- };
1161
- this.pipeline.push({ $setWindowFields: normalizedOptions });
1162
- return this;
1163
- }
1164
- /**
1165
- * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
1166
- *
1167
- * @example
1168
- * ```typescript
1169
- * .unionWith({
1170
- * coll: 'archivedOrders',
1171
- * pipeline: [{ $match: { year: 2024 } }]
1172
- * })
1173
- * ```
1174
- */
1175
- unionWith(options) {
1176
- this.pipeline.push({ $unionWith: options });
1177
- return this;
1178
- }
1179
- /**
1180
- * $densify - Fill gaps in data (MongoDB 5.1+)
1181
- * Useful for time series data with missing points
1182
- */
1183
- densify(options) {
1184
- this.pipeline.push({ $densify: options });
1185
- return this;
1186
- }
1187
- /**
1188
- * $fill - Fill null or missing field values (MongoDB 5.3+)
1189
- */
1190
- fill(options) {
1191
- const normalizedOptions = {
1192
- ...options,
1193
- sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
1194
- };
1195
- this.pipeline.push({ $fill: normalizedOptions });
1196
- return this;
1197
- }
1198
- /**
1199
- * Enable allowDiskUse for large aggregations that exceed 100MB memory limit
1200
- *
1201
- * @example
1202
- * ```typescript
1203
- * const results = await new AggregationBuilder()
1204
- * .match({ status: 'active' })
1205
- * .group({ _id: '$category', total: { $sum: '$amount' } })
1206
- * .allowDiskUse()
1207
- * .exec(Model);
1208
- * ```
1209
- */
1210
- allowDiskUse(enable = true) {
1211
- this._diskUse = enable;
1212
- return this;
1213
- }
1214
- /**
1215
- * Paginate - Add skip and limit for offset-based pagination
1216
- */
1217
- paginate(page, limit) {
1218
- const skip = (page - 1) * limit;
1219
- return this.skip(skip).limit(limit);
1220
- }
1221
- /**
1222
- * Count total documents (useful with $facet for pagination metadata)
1223
- */
1224
- count(outputField = "count") {
1225
- this.pipeline.push({ $count: outputField });
1226
- return this;
1227
- }
1228
- /**
1229
- * Sample - Randomly select N documents
1230
- */
1231
- sample(size) {
1232
- this.pipeline.push({ $sample: { size } });
1233
- return this;
1234
- }
1235
- /**
1236
- * Out - Write results to a collection
1237
- */
1238
- out(collection) {
1239
- this.pipeline.push({ $out: collection });
1240
- return this;
1241
- }
1242
- /**
1243
- * Merge - Merge results into a collection
1244
- */
1245
- merge(options) {
1246
- this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
1247
- return this;
1248
- }
1249
- /**
1250
- * GeoNear - Perform geospatial queries
1251
- */
1252
- geoNear(options) {
1253
- this.pipeline.push({ $geoNear: options });
1254
- return this;
1255
- }
1256
- /**
1257
- * GraphLookup - Perform recursive search (graph traversal)
1258
- */
1259
- graphLookup(options) {
1260
- this.pipeline.push({ $graphLookup: options });
1261
- return this;
1262
- }
1263
- /**
1264
- * $search - Atlas Search full-text search (Atlas only)
1265
- *
1266
- * @example
1267
- * ```typescript
1268
- * .search({
1269
- * index: 'default',
1270
- * text: {
1271
- * query: 'laptop computer',
1272
- * path: ['title', 'description'],
1273
- * fuzzy: { maxEdits: 2 }
1274
- * }
1275
- * })
1276
- * ```
1277
- */
1278
- search(options) {
1279
- this.pipeline.push({ $search: options });
1280
- return this;
1281
- }
1282
- /**
1283
- * $searchMeta - Get Atlas Search metadata (Atlas only)
1284
- */
1285
- searchMeta(options) {
1286
- this.pipeline.push({ $searchMeta: options });
1287
- return this;
1288
- }
1289
- /**
1290
- * $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
1291
- *
1292
- * Requires an Atlas Vector Search index on the target field.
1293
- * Must be the first stage in the pipeline.
1294
- *
1295
- * @example
1296
- * ```typescript
1297
- * const results = await new AggregationBuilder()
1298
- * .vectorSearch({
1299
- * index: 'vector_index',
1300
- * path: 'embedding',
1301
- * queryVector: await getEmbedding('running shoes'),
1302
- * limit: 10,
1303
- * numCandidates: 100,
1304
- * filter: { category: 'footwear' }
1305
- * })
1306
- * .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
1307
- * .exec(ProductModel);
1308
- * ```
1309
- */
1310
- vectorSearch(options) {
1311
- if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
1312
- const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
1313
- const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
1314
- this.pipeline.push({ $vectorSearch: {
1315
- index: options.index,
1316
- path: options.path,
1317
- queryVector: options.queryVector,
1318
- numCandidates,
1319
- limit: options.limit,
1320
- ...options.filter && { filter: options.filter },
1321
- ...options.exact && { exact: options.exact }
1322
- } });
1323
- return this;
1324
- }
1325
- /**
1326
- * Add vectorSearchScore as a field after $vectorSearch
1327
- * Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
1328
- */
1329
- withVectorScore(fieldName = "score") {
1330
- return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
1331
- }
1332
- /**
1333
- * Create a builder from an existing pipeline
1334
- */
1335
- static from(pipeline) {
1336
- const builder = new AggregationBuilder();
1337
- builder.pipeline = [...pipeline];
1338
- return builder;
1339
- }
1340
- /**
1341
- * Create a builder with initial match stage
1342
- */
1343
- static startWith(query) {
1344
- return new AggregationBuilder().match(query);
1345
- }
1346
- };
1347
- /**
1348
- * Optimized Aggregation Patterns for Scale
1349
- *
1350
- * 1. **Early Filtering** - Always place $match as early as possible:
1351
- * ```typescript
1352
- * new AggregationBuilder()
1353
- * .match({ status: 'active' }) // ✅ Filter first
1354
- * .lookup(...) // Then join
1355
- * .sort(...)
1356
- * ```
1357
- *
1358
- * 2. **Index Usage** - Ensure indexes on:
1359
- * - Fields in $match
1360
- * - Fields in $sort (especially with $limit)
1361
- * - Fields in $lookup (both local and foreign)
1362
- *
1363
- * 3. **Projection** - Remove unnecessary fields early:
1364
- * ```typescript
1365
- * .project({ password: 0, internalNotes: 0 }) // Remove before joins
1366
- * .lookup(...)
1367
- * ```
1368
- *
1369
- * 4. **Faceted Pagination** - Get count and data in one query:
1370
- * ```typescript
1371
- * .facet({
1372
- * metadata: [{ $count: 'total' }],
1373
- * data: [{ $skip: skip }, { $limit: limit }]
1374
- * })
1375
- * ```
1376
- *
1377
- * 5. **allowDiskUse** - For large datasets:
1378
- * ```typescript
1379
- * new AggregationBuilder()
1380
- * .match({ status: 'active' })
1381
- * .allowDiskUse()
1382
- * .exec(Model)
1383
- * ```
1384
- *
1385
- * 6. **Vector Search** - Semantic similarity (Atlas only):
1386
- * ```typescript
1387
- * new AggregationBuilder()
1388
- * .vectorSearch({ index: 'vec_idx', path: 'embedding', queryVector: vec, limit: 10 })
1389
- * .withVectorScore()
1390
- * .exec(Model)
1391
- * ```
1392
- */
1393
- //#endregion
1394
- //#region src/transaction.ts
1395
- /**
1396
- * Run a callback inside a MongoDB transaction on the given connection.
1397
- *
1398
- * - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
1399
- * on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
1400
- * always ends the session in `finally`.
1401
- * - When `allowFallback` is true and the deployment doesn't support transactions
1402
- * (e.g. standalone MongoDB in dev), the callback runs once without a
1403
- * transaction on the same session. `onFallback` is invoked with the original
1404
- * error so hosts can log the degradation.
1405
- *
1406
- * @example
1407
- * ```ts
1408
- * import mongoose from 'mongoose';
1409
- * import { withTransaction } from '@classytic/mongokit';
1410
- *
1411
- * await withTransaction(mongoose.connection, async (session) => {
1412
- * const order = await orderRepo.create(data, { session });
1413
- * await inventoryRepo.decrement(order.items, { session });
1414
- * return order;
1415
- * });
1416
- * ```
1417
- */
1418
- async function withTransaction(connection, callback, options = {}) {
1419
- const session = await connection.startSession();
1420
- try {
1421
- return await session.withTransaction(() => callback(session), options.transactionOptions);
1422
- } catch (error) {
1423
- const err = error;
1424
- if (options.allowFallback && isTransactionUnsupported(err)) {
1425
- options.onFallback?.(err);
1426
- return await callback(session);
1427
- }
1428
- throw err;
1429
- } finally {
1430
- await session.endSession();
1431
- }
1432
- }
1433
- /**
1434
- * Detect whether an error indicates the MongoDB deployment does not support
1435
- * multi-document transactions (standalone server, older topology, etc.).
1436
- *
1437
- * Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
1438
- * topology) — with a message-matching fallback for edge cases surfaced by
1439
- * older driver versions.
1440
- */
1441
- function isTransactionUnsupported(error) {
1442
- const code = error.code;
1443
- if (code === 263 || code === 20) return true;
1444
- const message = (error.message || "").toLowerCase();
1445
- return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
1446
- }
1447
- //#endregion
1448
- //#region src/tx-bound.ts
1449
- /**
1450
- * Map of session-aware method name → positional index of the `{ session }`
1451
- * options bag. Keep this list in sync with the method signatures in
1452
- * `Repository.ts` and the plugin method definitions. Unknown plugin
1453
- * methods pass through unwrapped (callers use the standalone helper).
1454
- */
1455
- const SESSION_OPTIONS_INDEX = Object.freeze({
1456
- create: 1,
1457
- update: 2,
1458
- delete: 1,
1459
- getById: 1,
1460
- getAll: 1,
1461
- createMany: 1,
1462
- findAll: 1,
1463
- getOrCreate: 2,
1464
- count: 1,
1465
- exists: 1,
1466
- getByQuery: 1,
1467
- getOne: 1,
1468
- findOneAndUpdate: 2,
1469
- distinct: 2,
1470
- aggregate: 1,
1471
- aggregatePaginate: 0,
1472
- lookupPopulate: 0,
1473
- upsert: 2,
1474
- increment: 3,
1475
- decrement: 3,
1476
- multiplyField: 3,
1477
- setMin: 3,
1478
- setMax: 3,
1479
- pushToArray: 3,
1480
- pullFromArray: 3,
1481
- addToSet: 3,
1482
- setField: 3,
1483
- unsetField: 2,
1484
- renameField: 3,
1485
- atomicUpdate: 2,
1486
- updateMany: 2,
1487
- deleteMany: 1,
1488
- bulkWrite: 1,
1489
- restore: 1,
1490
- getDeleted: 1,
1491
- addSubdocument: 3,
1492
- getSubdocument: 3,
1493
- updateSubdocument: 4,
1494
- deleteSubdocument: 3,
1495
- groupBy: 1,
1496
- sum: 2,
1497
- average: 2,
1498
- min: 2,
1499
- max: 2
1500
- });
1501
- /**
1502
- * Build a session-threaded proxy over `outer`. The returned object has the
1503
- * same method signatures, but every CRUD call auto-injects the supplied
1504
- * session into the options bag. Non-CRUD properties (Model, modelName,
1505
- * hook API, utility helpers) pass through.
1506
- */
1507
- function createTxBoundRepo(outer, session) {
1508
- return new Proxy(outer, { get(target, prop, receiver) {
1509
- if (prop === "withTransaction") return () => {
1510
- throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
1511
- };
1512
- const value = Reflect.get(target, prop, receiver);
1513
- if (typeof value !== "function") return value;
1514
- if (typeof prop === "symbol") return value.bind(target);
1515
- if (prop.startsWith("_")) return value.bind(target);
1516
- const optionsIndex = SESSION_OPTIONS_INDEX[prop];
1517
- if (optionsIndex === void 0) return value.bind(target);
1518
- return function txBoundMethod(...args) {
1519
- while (args.length <= optionsIndex) args.push(void 0);
1520
- const current = args[optionsIndex];
1521
- if (current === void 0) args[optionsIndex] = { session };
1522
- else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
1523
- ...current,
1524
- session
1525
- };
1526
- else return value.apply(target, args);
1527
- return value.apply(target, args);
1528
- };
1529
- } });
1530
- }
1531
- //#endregion
1532
- //#region src/Repository.ts
1533
- function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1534
- if (!projection || !sort) return projection;
1535
- if (!Object.values(projection).some((value) => value === 1)) return projection;
1536
- const nextProjection = { ...projection };
1537
- for (const field of [...Object.keys(sort), "_id"]) nextProjection[field] = 1;
1538
- return nextProjection;
1539
- }
1540
- /**
1541
- * Plugin phase priorities (lower = runs first).
1542
- *
1543
- * Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
1544
- * the same constant whether they import from mongokit or directly from
1545
- * repo-core — priorities are identical (POLICY=100, CACHE=200,
1546
- * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
1547
- */
1548
- const HOOK_PRIORITY$1 = HOOK_PRIORITY;
1549
- /**
1550
- * Production-grade repository for MongoDB.
1551
- *
1552
- * Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
1553
- * driver-agnostic hook + plugin plumbing (context builder, priority-sorted
1554
- * event engine, plugin-order validator) while layering every Mongo-specific
1555
- * concern — populate, aggregate, lookup, keyset + cursor pagination, $near
1556
- * rewriting — on top.
1557
- *
1558
- * Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
1559
- * preserved as a live read-through getter; all CRUD, event, and plugin method
1560
- * signatures unchanged). 3.10 is an internal re-plumbing release; consumers
1561
- * upgrading from 3.9 need no code changes.
1562
- */
1563
- var Repository = class extends RepositoryBase {
1564
- Model;
1565
- /**
1566
- * Mongoose model name. Duplicated on the instance for BC — arc / catalog /
1567
- * user code read `repo.model`. `RepositoryBase.modelName` points to the
1568
- * same string; prefer `modelName` in new code.
1569
- */
1570
- model;
1571
- _pagination;
1572
- idField;
1573
- searchMode;
1574
- searchFields;
1575
- _hasTextIndex = null;
1576
- constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
1577
- super({
1578
- name: Model.modelName,
1579
- hooks: options.hooks ?? "async",
1580
- pluginOrderChecks: "off"
1581
- });
1582
- validatePluginOrder(plugins, Model.modelName, options.pluginOrderChecks ?? "warn", (m) => {
1583
- warn(m);
1584
- });
1585
- this.Model = Model;
1586
- this.model = Model.modelName;
1587
- this._pagination = new PaginationEngine(Model, paginationConfig);
1588
- this.idField = options.idField ?? "_id";
1589
- this.searchMode = options.searchMode ?? "text";
1590
- this.searchFields = options.searchFields;
1591
- if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
1592
- for (const plugin of plugins) this.use(plugin);
1593
- }
1594
- /**
1595
- * Live read-through view of the hook engine's listener registry.
1596
- *
1597
- * Preserved as a public field on the Repository for back-compat with
1598
- * mongokit ≤3.9 — existing tests and observability code pattern-match
1599
- * `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
1600
- * returns a fresh snapshot of the underlying HookEngine state.
1601
- *
1602
- * Read-only. To register listeners use `repo.on(event, listener, { priority })`.
1603
- */
1604
- get _hooks() {
1605
- return this.hooks.listeners();
1606
- }
1607
- /**
1608
- * Fire-and-forget emit aliased through RepositoryBase's async path so
1609
- * error hooks converted to/from sync mode still route uniformly. Mongokit
1610
- * ≤3.9 made this a private method on its subclass; the base class exposes
1611
- * it publicly and wraps it in `_emitHook` below for internal use.
1612
- */
1613
- async _emitHook(event, data) {
1614
- await this.hooks.emitAccordingToMode(event, data);
1615
- }
1616
- async _emitErrorHook(event, data) {
1617
- try {
1618
- await this._emitHook(event, data);
1619
- } catch (hookError) {
1620
- warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
1621
- }
1622
- }
1623
- /**
1624
- * Run a Repository operation under the standard envelope:
1625
- * - invoke `fn`, emit `after:<op>` with `{ context, result }` on success
1626
- * - emit `error:<op>` and rethrow via `_handleError` on failure
1627
- *
1628
- * Methods with branched in-try logic that emit `after:*` from multiple
1629
- * paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
1630
- * `lookupPopulate`) intentionally keep their inline try/catch rather than
1631
- * being restructured to return a single result through this helper.
1632
- */
1633
- async _runOp(op, context, fn) {
1634
- try {
1635
- const result = await fn();
1636
- await this._emitHook(`after:${op}`, {
1637
- context,
1638
- result
1639
- });
1640
- return result;
1641
- } catch (error) {
1642
- await this._emitErrorHook(`error:${op}`, {
1643
- context,
1644
- error
1645
- });
1646
- throw this._handleError(error);
1647
- }
1648
- }
1649
- /**
1650
- * Create single document
1651
- */
1652
- async create(data, options = {}) {
1653
- const context = await this._buildContext("create", {
1654
- data,
1655
- ...options
1656
- });
1657
- return this._runOp("create", context, () => create(this.Model, context.data || data, options));
1658
- }
1659
- /**
1660
- * Create multiple documents
1661
- */
1662
- async createMany(dataArray, options = {}) {
1663
- const context = await this._buildContext("createMany", {
1664
- dataArray,
1665
- ...options
1666
- });
1667
- return this._runOp("createMany", context, () => createMany(this.Model, context.dataArray || dataArray, options));
1668
- }
1669
- /**
1670
- * Get document by ID
1671
- */
1672
- async getById(id, options = {}) {
1673
- const populateSpec = options.populateOptions || options.populate;
1674
- const context = await this._buildContext("getById", {
1675
- id,
1676
- ...options,
1677
- populate: populateSpec
1678
- });
1679
- if (context._cacheHit) {
1680
- const cachedResult = context._cachedResult;
1681
- await this._emitHook("after:getById", {
1682
- context,
1683
- result: cachedResult,
1684
- fromCache: true
1685
- });
1686
- return cachedResult;
1687
- }
1688
- try {
1689
- const effectiveIdField = options.idField ?? this.idField;
1690
- if (effectiveIdField !== "_id") {
1691
- const result = await getByQuery(this.Model, {
1692
- [effectiveIdField]: id,
1693
- ...context.query || {}
1694
- }, context);
1695
- await this._emitHook("after:getById", {
1696
- context,
1697
- result
1698
- });
1699
- return result;
1700
- }
1701
- const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
1702
- if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
1703
- if (wantsThrow) throw createError(404, "Document not found");
1704
- return null;
1705
- }
1706
- const result = await getById(this.Model, id, context);
1707
- if (!result && wantsThrow) throw createError(404, "Document not found");
1708
- await this._emitHook("after:getById", {
1709
- context,
1710
- result
1711
- });
1712
- return result;
1713
- } catch (error) {
1714
- await this._emitErrorHook("error:getById", {
1715
- context,
1716
- error
1717
- });
1718
- throw this._handleError(error);
1719
- }
1720
- }
1721
- /**
1722
- * Get single document by query
1723
- */
1724
- async getByQuery(query, options = {}) {
1725
- const populateSpec = options.populateOptions || options.populate;
1726
- const context = await this._buildContext("getByQuery", {
1727
- query,
1728
- ...options,
1729
- populate: populateSpec
1730
- });
1731
- if (context._cacheHit) {
1732
- const cachedResult = context._cachedResult;
1733
- await this._emitHook("after:getByQuery", {
1734
- context,
1735
- result: cachedResult,
1736
- fromCache: true
1737
- });
1738
- return cachedResult;
1739
- }
1740
- const finalQuery = context.query || query;
1741
- return this._runOp("getByQuery", context, () => getByQuery(this.Model, finalQuery, context));
1742
- }
1743
- /**
1744
- * Get single document by arbitrary filter.
1745
- * Unlike getByQuery, this method is designed for Arc/controller use where
1746
- * compound filters (org scope + policy + id) are pre-built.
1747
- *
1748
- * @example
1749
- * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
1750
- */
1751
- async getOne(query, options = {}) {
1752
- const populateSpec = options.populateOptions || options.populate;
1753
- const context = await this._buildContext("getOne", {
1754
- query,
1755
- ...options,
1756
- populate: populateSpec
1757
- });
1758
- if (context._cacheHit) {
1759
- const cachedResult = context._cachedResult;
1760
- await this._emitHook("after:getOne", {
1761
- context,
1762
- result: cachedResult,
1763
- fromCache: true
1764
- });
1765
- return cachedResult;
1766
- }
1767
- const finalQuery = context.query || query;
1768
- return this._runOp("getOne", context, () => getByQuery(this.Model, finalQuery, context));
1769
- }
1770
- /**
1771
- * Fetch ALL documents matching filters without pagination.
1772
- * Use for background jobs, exports, batch processing where you need every doc.
1773
- *
1774
- * @example
1775
- * const all = await repo.findAll({ status: 'active' });
1776
- * const allLean = await repo.findAll({}, { select: 'name email', lean: true });
1777
- */
1778
- async findAll(filters = {}, options = {}) {
1779
- const context = await this._buildContext("findAll", {
1780
- query: filters,
1781
- ...options
1782
- });
1783
- const resolvedFilters = context.query ?? filters;
1784
- return this._runOp("findAll", context, async () => {
1785
- const query = this.Model.find(resolvedFilters);
1786
- const sortSpec = context.sort || options.sort;
1787
- if (sortSpec) query.sort(this._parseSort(sortSpec));
1788
- const selectSpec = context.select || options.select;
1789
- if (selectSpec) query.select(selectSpec);
1790
- if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
1791
- if (context.lean ?? options.lean ?? true) query.lean();
1792
- if (options.session) query.session(options.session);
1793
- if (options.readPreference) query.read(options.readPreference);
1794
- return await query.exec();
1795
- });
1796
- }
1797
- /**
1798
- * Unified pagination - auto-detects offset vs keyset based on params
1799
- *
1800
- * Auto-detection logic:
1801
- * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
1802
- * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
1803
- * - Else → defaults to offset pagination with page=1
1804
- *
1805
- * @example
1806
- * // Offset pagination (page-based)
1807
- * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
1808
- * await repo.getAll({ pagination: { page: 2, limit: 20 } });
1809
- *
1810
- * // Keyset pagination (cursor-based)
1811
- * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
1812
- * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
1813
- *
1814
- * // Simple query (defaults to page 1)
1815
- * await repo.getAll({ filters: { status: 'active' } });
1816
- *
1817
- * // Skip cache for fresh data
1818
- * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
1819
- */
1820
- async getAll(params = {}, options = {}) {
1821
- const normalizedParams = {
1822
- ...params,
1823
- page: params.page ?? params.pagination?.page,
1824
- limit: params.limit ?? params.pagination?.limit
1825
- };
1826
- const context = await this._buildContext("getAll", {
1827
- ...normalizedParams,
1828
- ...options
1829
- });
1830
- if (context._cacheHit) {
1831
- const cachedResult = context._cachedResult;
1832
- await this._emitHook("after:getAll", {
1833
- context,
1834
- result: cachedResult,
1835
- fromCache: true
1836
- });
1837
- return cachedResult;
1838
- }
1839
- if (params.noPagination) return this.findAll(context.filters ?? params.filters ?? {}, {
1840
- ...options,
1841
- sort: context.sort ?? params.sort
1842
- });
1843
- const filters = context.filters ?? params.filters ?? {};
1844
- const search = context.search;
1845
- const isNearQuery = hasNearOperator(filters);
1846
- const sort = this._resolveSort(context, params, isNearQuery);
1847
- const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
1848
- const page = context.page ?? params.pagination?.page ?? params.page;
1849
- const after = context.after ?? params.cursor ?? params.after;
1850
- const mode = context.mode ?? params.mode;
1851
- const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
1852
- const query = this._buildSearchQuery(filters, search);
1853
- const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
1854
- const paginationOptions = {
1855
- filters: query,
1856
- sort: isNearQuery ? void 0 : this._parseSort(sort),
1857
- limit,
1858
- populate: this._parsePopulate(populateSpec),
1859
- select: context.select || options.select,
1860
- lean: context.lean ?? options.lean ?? true,
1861
- session: options.session,
1862
- hint: context.hint ?? params.hint,
1863
- maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
1864
- readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
1865
- collation: context.collation ?? params.collation
1866
- };
1867
- const lookups = context.lookups ?? params.lookups;
1868
- if (lookups && lookups.length > 0) return this._runOp("getAll", context, async () => {
1869
- return await this.lookupPopulate({
1870
- filters: query,
1871
- lookups,
1872
- sort: paginationOptions.sort,
1873
- page: useKeyset ? void 0 : page || 1,
1874
- after: useKeyset ? after : void 0,
1875
- limit,
1876
- select: paginationOptions.select,
1877
- session: options.session,
1878
- readPreference: paginationOptions.readPreference,
1879
- collation: paginationOptions.collation,
1880
- countStrategy: context.countStrategy ?? params.countStrategy
1881
- });
1882
- });
1883
- return this._runOp("getAll", context, async () => {
1884
- let result;
1885
- if (useKeyset) result = await this._pagination.stream({
1886
- ...paginationOptions,
1887
- sort: paginationOptions.sort,
1888
- after
1889
- });
1890
- else {
1891
- let countFilters;
1892
- let forcedCountStrategy;
1893
- if (isNearQuery) {
1894
- const rewritten = rewriteNearForCount(query);
1895
- if (rewritten) countFilters = rewritten;
1896
- else forcedCountStrategy = "none";
1897
- }
1898
- result = await this._pagination.paginate({
1899
- ...paginationOptions,
1900
- page: page || 1,
1901
- countFilters,
1902
- countStrategy: context.countStrategy ?? params.countStrategy ?? forcedCountStrategy
1903
- });
1904
- }
1905
- return result;
1906
- });
1907
- }
1908
- /**
1909
- * Get or create document
1910
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1911
- */
1912
- async getOrCreate(query, createData, options = {}) {
1913
- const context = await this._buildContext("getOrCreate", {
1914
- query,
1915
- data: createData,
1916
- ...options
1917
- });
1918
- return this._runOp("getOrCreate", context, () => {
1919
- const finalQuery = context.query || query;
1920
- const finalData = context.data || createData;
1921
- return getOrCreate(this.Model, finalQuery, finalData, options);
1922
- });
1923
- }
1924
- /**
1925
- * Count documents
1926
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1927
- */
1928
- async count(query = {}, options = {}) {
1929
- const context = await this._buildContext("count", {
1930
- query,
1931
- ...options
1932
- });
1933
- return this._runOp("count", context, () => count(this.Model, context.query || query, options));
1934
- }
1935
- /**
1936
- * Check if document exists
1937
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1938
- */
1939
- async exists(query, options = {}) {
1940
- const context = await this._buildContext("exists", {
1941
- query,
1942
- ...options
1943
- });
1944
- return this._runOp("exists", context, () => exists(this.Model, context.query || query, options));
1945
- }
1946
- /**
1947
- * Update document by ID
1948
- */
1949
- async update(id, data, options = {}) {
1950
- const context = await this._buildContext("update", {
1951
- id,
1952
- data,
1953
- ...options
1954
- });
1955
- try {
1956
- const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
1957
- const effectiveIdField = options.idField ?? this.idField;
1958
- if (effectiveIdField === "_id") {
1959
- if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
1960
- if (wantsThrow) throw createError(404, "Document not found");
1961
- await this._emitHook("after:update", {
1962
- context,
1963
- result: null
1964
- });
1965
- return null;
1966
- }
1967
- }
1968
- let result;
1969
- if (effectiveIdField !== "_id") result = await updateByQuery(this.Model, {
1970
- [effectiveIdField]: id,
1971
- ...context.query || {}
1972
- }, context.data || data, context);
1973
- else result = await update(this.Model, id, context.data || data, context);
1974
- if (!result && wantsThrow) throw createError(404, "Document not found");
1975
- await this._emitHook("after:update", {
1976
- context,
1977
- result
1978
- });
1979
- return result;
1980
- } catch (error) {
1981
- await this._emitErrorHook("error:update", {
1982
- context,
1983
- error
1984
- });
1985
- throw this._handleError(error);
1986
- }
1987
- }
1988
- /**
1989
- * Atomic findOneAndUpdate (compare-and-set primitive).
1990
- *
1991
- * Single round-trip match-and-mutate for outbox relays, distributed locks,
1992
- * and workflow semaphores. Goes through the full hook pipeline so
1993
- * multi-tenant scope, soft-delete, audit, and cache plugins all apply.
1994
- *
1995
- * Returns the matched document (post-update by default) or `null` when no
1996
- * document matches and `upsert` is false.
1997
- *
1998
- * @example FIFO claim-lease for an outbox worker
1999
- * ```ts
2000
- * const claimed = await outboxRepo.findOneAndUpdate(
2001
- * { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
2002
- * { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
2003
- * { sort: { createdAt: 1 }, returnDocument: 'after' },
2004
- * );
2005
- * if (!claimed) return; // queue empty
2006
- * ```
2007
- *
2008
- * @example CAS upsert
2009
- * ```ts
2010
- * const lock = await locksRepo.findOneAndUpdate(
2011
- * { _id: lockKey, ownerId: { $in: [null, workerId] } },
2012
- * { $set: { ownerId: workerId, expiresAt: leaseUntil } },
2013
- * { upsert: true },
2014
- * );
2015
- * ```
2016
- */
2017
- async findOneAndUpdate(filter, update, options = {}) {
2018
- const normalizedUpdate = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
2019
- const context = await this._buildContext("findOneAndUpdate", {
2020
- query: filter,
2021
- data: normalizedUpdate,
2022
- ...options
2023
- });
2024
- return this._runOp("findOneAndUpdate", context, async () => {
2025
- const finalQuery = context.query || filter;
2026
- const finalUpdate = context.data || normalizedUpdate;
2027
- return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
2028
- ...options,
2029
- sort: options.sort,
2030
- returnDocument: options.returnDocument ?? "after",
2031
- upsert: options.upsert ?? false,
2032
- session: options.session
2033
- });
2034
- });
2035
- }
2036
- /**
2037
- * Delete a document by ID.
2038
- *
2039
- * By default the behavior is decided by the plugin stack:
2040
- * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
2041
- * - Without the plugin → physical delete
2042
- *
2043
- * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
2044
- * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
2045
- * cascade) still fire — only the soft-delete interception is skipped. This
2046
- * is the GDPR / admin-cleanup path.
2047
- *
2048
- * @example
2049
- * ```ts
2050
- * // Respects softDeletePlugin — soft if wired
2051
- * await userRepo.delete(userId);
2052
- *
2053
- * // GDPR erasure — physical delete, audit hooks still fire
2054
- * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
2055
- * ```
2056
- */
2057
- async delete(id, options = {}) {
2058
- const context = await this._buildContext("delete", {
2059
- id,
2060
- ...options,
2061
- ...options.mode ? { deleteMode: options.mode } : {}
2062
- });
2063
- try {
2064
- if (context.softDeleted) {
2065
- const result = {
2066
- success: true,
2067
- message: "Soft deleted successfully",
2068
- id: String(id),
2069
- soft: true
2070
- };
2071
- await this._emitHook("after:delete", {
2072
- context,
2073
- result
2074
- });
2075
- return result;
2076
- }
2077
- const effectiveIdField = options.idField ?? this.idField;
2078
- const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
2079
- if (effectiveIdField === "_id") {
2080
- if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
2081
- if (wantsThrow) throw createError(404, "Document not found");
2082
- const result = {
2083
- success: false,
2084
- message: "Document not found",
2085
- id: String(id)
2086
- };
2087
- await this._emitHook("after:delete", {
2088
- context,
2089
- result
2090
- });
2091
- return result;
2092
- }
2093
- }
2094
- const deleteQuery = effectiveIdField !== "_id" ? {
2095
- [effectiveIdField]: id,
2096
- ...context.query || {}
2097
- } : void 0;
2098
- const result = deleteQuery ? await deleteByQuery(this.Model, deleteQuery, { session: options.session }) : await deleteById(this.Model, id, {
2099
- session: options.session,
2100
- query: context.query
2101
- });
2102
- if (!result.success && wantsThrow) throw createError(404, "Document not found");
2103
- await this._emitHook("after:delete", {
2104
- context,
2105
- result
2106
- });
2107
- return result;
2108
- } catch (error) {
2109
- await this._emitErrorHook("error:delete", {
2110
- context,
2111
- error
2112
- });
2113
- throw this._handleError(error);
2114
- }
2115
- }
2116
- /**
2117
- * Update every document matching the filter.
2118
- *
2119
- * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
2120
- * the surface matches sqlitekit's always-available `updateMany`. Consumers
2121
- * who forget to wire `batchOperationsPlugin` no longer get a runtime
2122
- * `TypeError: repo.updateMany is not a function` — the method is always
2123
- * here.
2124
- *
2125
- * Accepts three `update` forms via `UpdateInput`:
2126
- * - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
2127
- * `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
2128
- * - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
2129
- * - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
2130
- * to guard against accidental pipeline updates.
2131
- *
2132
- * Refuses empty filters (defense-in-depth against mass-update accidents)
2133
- * both before and after policy hooks inject tenant scope.
2134
- */
2135
- async updateMany(filter, update, options = {}) {
2136
- const normalizedData = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
2137
- const context = await this._buildContext("updateMany", {
2138
- query: filter,
2139
- data: normalizedData,
2140
- ...options
2141
- });
2142
- return this._runOp("updateMany", context, async () => {
2143
- const finalQuery = context.query || filter;
2144
- if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
2145
- if (Array.isArray(normalizedData) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
2146
- const finalData = context.data || normalizedData;
2147
- return await this.Model.updateMany(finalQuery, finalData, {
2148
- runValidators: true,
2149
- session: options.session,
2150
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
2151
- }).exec();
2152
- });
2153
- }
2154
- /**
2155
- * Delete every document matching the filter.
2156
- *
2157
- * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
2158
- * the same reason as `updateMany` — sqlitekit parity and no silent "method
2159
- * is undefined" footgun when the batch plugin isn't wired.
2160
- *
2161
- * Behavior mirrors `delete()`: defaults to soft delete when
2162
- * `softDeletePlugin` is wired, physical delete otherwise. Pass
2163
- * `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
2164
- * and cache hooks still fire.
2165
- *
2166
- * Rejects empty filters up front AND after policy hooks, so a policy-plugin
2167
- * bug that zeroes out the query can't be exploited to wipe a collection.
2168
- */
2169
- async deleteMany(filter, options = {}) {
2170
- if (!filter || Object.keys(filter).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
2171
- const mode = options.mode;
2172
- const context = await this._buildContext("deleteMany", {
2173
- query: filter,
2174
- ...options,
2175
- ...mode ? { deleteMode: mode } : {}
2176
- });
2177
- try {
2178
- if (context.softDeleted) {
2179
- const result = {
2180
- acknowledged: true,
2181
- deletedCount: context.softDeletedCount ?? 0,
2182
- soft: true
2183
- };
2184
- await this._emitHook("after:deleteMany", {
2185
- context,
2186
- result
2187
- });
2188
- return result;
2189
- }
2190
- const finalQuery = context.query || filter;
2191
- if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
2192
- const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
2193
- await this._emitHook("after:deleteMany", {
2194
- context,
2195
- result
2196
- });
2197
- return result;
2198
- } catch (error) {
2199
- await this._emitErrorHook("error:deleteMany", {
2200
- context,
2201
- error
2202
- });
2203
- throw this._handleError(error);
2204
- }
2205
- }
2206
- /**
2207
- * Kit-native aggregation pipeline — takes a MongoDB stage array and
2208
- * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
2209
- * `$facet`, `$graphLookup`, and any other mongo-specific power
2210
- * features that don't translate to SQL.
2211
- *
2212
- * For backend-portable aggregations (filter + group + measures +
2213
- * sort + limit) use `aggregate(req: AggRequest)` instead — it
2214
- * compiles the repo-core IR to the equivalent pipeline internally
2215
- * and produces the same row shape sqlitekit emits.
2216
- *
2217
- * Routes through the hook system for policy enforcement
2218
- * (multi-tenant, soft-delete).
2219
- *
2220
- * @param pipeline - MongoDB aggregation stage array
2221
- * @param options - Aggregation options including governance controls
2222
- */
2223
- async aggregatePipeline(pipeline, options = {}) {
2224
- const context = await this._buildContext("aggregatePipeline", {
2225
- pipeline,
2226
- ...options
2227
- });
2228
- const maxStages = options.maxPipelineStages;
2229
- if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
2230
- return this._runOp("aggregatePipeline", context, async () => {
2231
- const finalPipeline = [...pipeline];
2232
- if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
2233
- const aggregation = this.Model.aggregate(finalPipeline);
2234
- if (options.session) aggregation.session(options.session);
2235
- if (options.allowDiskUse) aggregation.allowDiskUse(true);
2236
- if (options.readPreference) aggregation.read(options.readPreference);
2237
- if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
2238
- if (options.comment) aggregation.option({ comment: options.comment });
2239
- if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
2240
- if (options.collation) aggregation.collation(options.collation);
2241
- return await aggregation.exec();
2242
- });
2243
- }
2244
- /**
2245
- * Paginated kit-native aggregation pipeline. Same mongo-specific
2246
- * scope as `aggregatePipeline` — reach for this when you need
2247
- * `$lookup` / `$unwind` / `$facet` alongside pagination.
2248
- *
2249
- * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
2250
- * cross-backend dashboard code; that method compiles the portable
2251
- * IR to an equivalent pipeline.
2252
- *
2253
- * Policy hooks (multi-tenant, soft-delete) inject context.filters
2254
- * which are prepended as a `$match` stage.
2255
- */
2256
- async aggregatePipelinePaginate(options = {}) {
2257
- const context = await this._buildContext("aggregatePipelinePaginate", options);
2258
- const finalPipeline = [...context.pipeline || options.pipeline || []];
2259
- if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
2260
- const aggOptions = {
2261
- ...context,
2262
- pipeline: finalPipeline
2263
- };
2264
- return this._runOp("aggregatePipelinePaginate", context, () => this._pagination.aggregatePaginate(aggOptions));
2265
- }
2266
- /**
2267
- * Portable aggregation. Compiles the repo-core `AggRequest` IR to
2268
- * `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
2269
- * {$skip}, {$limit}]` against this repo's collection. Output rows
2270
- * carry one key per `groupBy` column plus one key per measure alias
2271
- * — identical shape to sqlitekit's `aggregate(req)`, so dashboards
2272
- * work unchanged across backends.
2273
- *
2274
- * Without `groupBy`: returns a single-row result with just the
2275
- * measures (scalar aggregation). Pass
2276
- * `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
2277
- * simple summary.
2278
- *
2279
- * For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
2280
- * `$graphLookup`, window operators) reach for `aggregatePipeline`
2281
- * — that's the kit-native escape hatch.
2282
- *
2283
- * Routes through the hook system for policy enforcement
2284
- * (multi-tenant, soft-delete) — plugins inject their scope into
2285
- * `context.filter`, which is merged into the pre-aggregate `$match`
2286
- * before compilation.
2287
- */
2288
- async aggregate(req) {
2289
- const context = await this._buildContext("aggregate", { aggRequest: req });
2290
- return this._runOp("aggregate", context, async () => {
2291
- const finalReq = this._injectPolicyScopeIntoAgg(req, context);
2292
- return { rows: await executeAgg(this.Model, finalReq, { session: context.session }) };
2293
- });
2294
- }
2295
- /**
2296
- * Offset-paginated portable aggregation. Same IR as `aggregate`,
2297
- * wrapped in the standard `OffsetPaginationResultCore` envelope so
2298
- * UI code paginates aggregated dashboards with the same primitives
2299
- * as raw document lists.
2300
- *
2301
- * `countStrategy: 'none'` skips the second round-trip that computes
2302
- * `total`; the envelope reports `total: 0`, `pages: 0`, and derives
2303
- * `hasNext` from a `LIMIT N+1` peek on the data pipeline.
2304
- */
2305
- async aggregatePaginate(req) {
2306
- const context = await this._buildContext("aggregatePaginate", { aggRequest: req });
2307
- const page = Math.max(1, req.page ?? 1);
2308
- const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
2309
- const countStrategy = req.countStrategy ?? "exact";
2310
- const offset = (page - 1) * limit;
2311
- const session = context.session;
2312
- try {
2313
- const finalReq = this._injectPolicyScopeIntoAgg(req, context);
2314
- if (countStrategy === "none") {
2315
- const peek = await executeAgg(this.Model, {
2316
- ...finalReq,
2317
- limit: limit + 1,
2318
- offset
2319
- }, session ? { session } : {});
2320
- const hasNext = peek.length > limit;
2321
- const result = {
2322
- method: "offset",
2323
- docs: hasNext ? peek.slice(0, limit) : peek,
2324
- page,
2325
- limit,
2326
- total: 0,
2327
- pages: 0,
2328
- hasNext,
2329
- hasPrev: page > 1
2330
- };
2331
- await this._emitHook("after:aggregatePaginate", {
2332
- context,
2333
- result
2334
- });
2335
- return result;
2336
- }
2337
- const [docs, total] = await Promise.all([executeAgg(this.Model, {
2338
- ...finalReq,
2339
- limit,
2340
- offset
2341
- }, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
2342
- const result = {
2343
- method: "offset",
2344
- docs,
2345
- page,
2346
- limit,
2347
- total,
2348
- pages: Math.max(1, Math.ceil(total / limit)),
2349
- hasNext: page * limit < total,
2350
- hasPrev: page > 1
2351
- };
2352
- await this._emitHook("after:aggregatePaginate", {
2353
- context,
2354
- result
2355
- });
2356
- return result;
2357
- } catch (error) {
2358
- await this._emitErrorHook("error:aggregatePaginate", {
2359
- context,
2360
- error
2361
- });
2362
- throw this._handleError(error);
2363
- }
2364
- }
2365
- /**
2366
- * Merge policy-hook-injected filters (multi-tenant scope,
2367
- * soft-delete) into the pre-aggregate `filter` slot of the request.
2368
- * Plugins speak mongo-query shape (`context.query` / `context.filters`);
2369
- * the caller's `req.filter` may be either Filter IR or a mongo query.
2370
- *
2371
- * Each part is compiled through `compileFilterToMongo` BEFORE the
2372
- * `$and` merge — otherwise an IR node like `eq('status', 'paid')`
2373
- * (`{ op, field, value }`) gets nested inside `$and` as a literal,
2374
- * `compileFilterToMongo` of the merged value finds no top-level `op`
2375
- * (so `isFilter` is false), and Mongo receives the IR object as a
2376
- * raw query clause that matches no real document. Symptom: aggregates
2377
- * silently return 0 rows whenever a callerpasses IR + a policy
2378
- * plugin is installed.
2379
- */
2380
- _injectPolicyScopeIntoAgg(req, context) {
2381
- const scopeCandidates = [];
2382
- for (const candidate of [context.filters, context.query]) if (candidate && typeof candidate === "object" && Object.keys(candidate).length > 0) scopeCandidates.push(candidate);
2383
- if (scopeCandidates.length === 0) return req;
2384
- const parts = [];
2385
- for (const c of scopeCandidates) {
2386
- const compiled = compileFilterToMongo(c);
2387
- if (Object.keys(compiled).length > 0) parts.push(compiled);
2388
- }
2389
- if (req.filter) {
2390
- const compiled = compileFilterToMongo(req.filter);
2391
- if (Object.keys(compiled).length > 0) parts.push(compiled);
2392
- }
2393
- if (parts.length === 0) return req;
2394
- const merged = parts.length === 1 ? parts[0] : { $and: parts };
2395
- return {
2396
- ...req,
2397
- filter: merged
2398
- };
2399
- }
2400
- /**
2401
- * Get distinct values
2402
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
2403
- */
2404
- async distinct(field, query = {}, options = {}) {
2405
- const context = await this._buildContext("distinct", {
2406
- query,
2407
- ...options
2408
- });
2409
- return this._runOp("distinct", context, () => {
2410
- const finalQuery = context.query || query;
2411
- const readPreference = context.readPreference ?? options.readPreference;
2412
- return distinct(this.Model, field, finalQuery, {
2413
- session: options.session,
2414
- readPreference
2415
- });
2416
- });
2417
- }
2418
- /**
2419
- * Query with custom field lookups ($lookup)
2420
- * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
2421
- *
2422
- * @example
2423
- * ```typescript
2424
- * // Join employees with departments using slug instead of ObjectId
2425
- * const employees = await employeeRepo.lookupPopulate({
2426
- * filters: { status: 'active' },
2427
- * lookups: [
2428
- * {
2429
- * from: 'departments',
2430
- * localField: 'departmentSlug',
2431
- * foreignField: 'slug',
2432
- * as: 'department',
2433
- * single: true
2434
- * }
2435
- * ],
2436
- * sort: '-createdAt',
2437
- * page: 1,
2438
- * limit: 50
2439
- * });
2440
- * ```
2441
- */
2442
- async lookupPopulate(options) {
2443
- const context = await this._buildContext("lookupPopulate", options);
2444
- try {
2445
- const MAX_LOOKUPS = 10;
2446
- const lookups = context.lookups ?? options.lookups;
2447
- if (lookups.length > MAX_LOOKUPS) throw createError(400, `Too many lookups (${lookups.length}). Maximum is ${MAX_LOOKUPS}.`);
2448
- const filters = context.filters ?? options.filters;
2449
- const sort = context.sort ?? options.sort;
2450
- const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
2451
- const readPref = context.readPreference ?? options.readPreference;
2452
- const session = context.session ?? options.session;
2453
- const collation = context.collation ?? options.collation;
2454
- const after = context.after ?? options.after;
2455
- const pageFromContext = context.page ?? options.page;
2456
- const isKeyset = !!after || !pageFromContext && !!sort;
2457
- const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
2458
- const selectSpec = context.select ?? options.select;
2459
- let projection;
2460
- if (selectSpec) {
2461
- if (typeof selectSpec === "string") {
2462
- projection = {};
2463
- for (const field of selectSpec.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
2464
- else projection[field] = 1;
2465
- } else if (Array.isArray(selectSpec)) {
2466
- projection = {};
2467
- for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
2468
- else projection[field] = 1;
2469
- } else projection = { ...selectSpec };
2470
- if (projection) {
2471
- if (Object.values(projection).some((v) => v === 1)) for (const lookup of lookups) {
2472
- const asField = lookup.as || lookup.from;
2473
- if (!(asField in projection)) projection[asField] = 1;
2474
- }
2475
- }
2476
- }
2477
- const appendLookupStages = (pipeline) => {
2478
- pipeline.push(...LookupBuilder.multiple(lookups));
2479
- for (const lookup of lookups) if (lookup.single) {
2480
- const asField = lookup.as || lookup.from;
2481
- pipeline.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
2482
- }
2483
- const finalProjection = ensureLookupProjectionIncludesCursorFields(projection, isKeyset && sort ? this._parseSort(sort) : void 0);
2484
- if (finalProjection) pipeline.push({ $project: finalProjection });
2485
- };
2486
- if (isKeyset && sort) {
2487
- const parsedSort = this._parseSort(sort);
2488
- const { validateKeysetSort } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
2489
- const { encodeCursor, resolveCursorFilter } = await import("./cursor-J84rR6wG.mjs").then((n) => n.t);
2490
- const { getPrimaryField } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
2491
- const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
2492
- const cursorVersion = this._pagination.config.cursorVersion ?? 1;
2493
- const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
2494
- const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
2495
- if (projection) {
2496
- if (Object.values(projection).some((v) => v === 1)) {
2497
- for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
2498
- }
2499
- }
2500
- const pipeline = [];
2501
- if (Object.keys(matchFilters).length > 0) pipeline.push({ $match: matchFilters });
2502
- pipeline.push({ $sort: normalizedSort });
2503
- pipeline.push({ $limit: limit + 1 });
2504
- appendLookupStages(pipeline);
2505
- const aggregation = this.Model.aggregate(pipeline).session(session || null);
2506
- if (collation) aggregation.collation(collation);
2507
- if (readPref) aggregation.read(readPref);
2508
- const docs = await aggregation;
2509
- const hasMore = docs.length > limit;
2510
- if (hasMore) docs.pop();
2511
- const primaryField = getPrimaryField(normalizedSort);
2512
- const result = {
2513
- method: "keyset",
2514
- docs,
2515
- limit,
2516
- hasMore,
2517
- next: hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null
2518
- };
2519
- await this._emitHook("after:lookupPopulate", {
2520
- context,
2521
- result
2522
- });
2523
- return result;
2524
- }
2525
- const page = pageFromContext ?? 1;
2526
- const skip = (page - 1) * limit;
2527
- if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
2528
- const dataPipeline = [];
2529
- if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
2530
- if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
2531
- if (countStrategy === "none") {
2532
- dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
2533
- appendLookupStages(dataPipeline);
2534
- const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
2535
- if (collation) aggregation.collation(collation);
2536
- if (readPref) aggregation.read(readPref);
2537
- const docs = await aggregation;
2538
- const hasNext = docs.length > limit;
2539
- if (hasNext) docs.pop();
2540
- const result = {
2541
- method: "offset",
2542
- docs,
2543
- page,
2544
- limit,
2545
- total: 0,
2546
- pages: 0,
2547
- hasNext,
2548
- hasPrev: page > 1
2549
- };
2550
- await this._emitHook("after:lookupPopulate", {
2551
- context,
2552
- result
2553
- });
2554
- return result;
2555
- }
2556
- dataPipeline.push({ $skip: skip }, { $limit: limit });
2557
- appendLookupStages(dataPipeline);
2558
- const countPipeline = [];
2559
- if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
2560
- countPipeline.push({ $count: "total" });
2561
- const pipeline = [{ $facet: {
2562
- metadata: countPipeline,
2563
- data: dataPipeline
2564
- } }];
2565
- const aggregation = this.Model.aggregate(pipeline).session(session || null);
2566
- if (collation) aggregation.collation(collation);
2567
- if (readPref) aggregation.read(readPref);
2568
- const facetResult = (await aggregation)[0] || {
2569
- metadata: [],
2570
- data: []
2571
- };
2572
- const total = facetResult.metadata[0]?.total || 0;
2573
- const result = {
2574
- method: "offset",
2575
- docs: facetResult.data || [],
2576
- page,
2577
- limit,
2578
- total,
2579
- pages: Math.max(1, Math.ceil(total / limit)),
2580
- hasNext: page * limit < total,
2581
- hasPrev: page > 1
2582
- };
2583
- await this._emitHook("after:lookupPopulate", {
2584
- context,
2585
- result
2586
- });
2587
- return result;
2588
- } catch (error) {
2589
- await this._emitErrorHook("error:lookupPopulate", {
2590
- context,
2591
- error
2592
- });
2593
- throw this._handleError(error);
2594
- }
2595
- }
2596
- /**
2597
- * Create an aggregation builder for this model
2598
- * Useful for building complex custom aggregations
2599
- *
2600
- * @example
2601
- * ```typescript
2602
- * const pipeline = repo.buildAggregation()
2603
- * .match({ status: 'active' })
2604
- * .lookup('departments', 'deptSlug', 'slug', 'department', true)
2605
- * .group({ _id: '$department', count: { $sum: 1 } })
2606
- * .sort({ count: -1 })
2607
- * .build();
2608
- *
2609
- * const results = await repo.Model.aggregate(pipeline);
2610
- * ```
2611
- */
2612
- buildAggregation() {
2613
- return new AggregationBuilder();
2614
- }
2615
- /**
2616
- * Create a lookup builder
2617
- * Useful for building $lookup stages independently
2618
- *
2619
- * @example
2620
- * ```typescript
2621
- * const lookupStages = repo.buildLookup('departments')
2622
- * .localField('deptSlug')
2623
- * .foreignField('slug')
2624
- * .as('department')
2625
- * .single()
2626
- * .build();
2627
- *
2628
- * const pipeline = [
2629
- * { $match: { status: 'active' } },
2630
- * ...lookupStages
2631
- * ];
2632
- * ```
2633
- */
2634
- buildLookup(from) {
2635
- return new LookupBuilder(from);
2636
- }
2637
- /**
2638
- * Execute callback within a MongoDB transaction with automatic retry on
2639
- * transient failures.
2640
- *
2641
- * **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
2642
- * not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
2643
- * contract that every kit (sqlitekit, pgkit, prismakit) already implements
2644
- * — so cross-kit plugins and apps are portable. Session threading happens
2645
- * transparently: every CRUD call on `txRepo` auto-injects the session into
2646
- * its options bag, including plugin-added methods (`upsert`, `increment`,
2647
- * `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
2648
- *
2649
- * For raw-session cross-repo workflows — multiple repositories coordinating
2650
- * writes — use the standalone `withTransaction(connection, fn)` export
2651
- * from `@classytic/mongokit`. That helper remains session-based by design.
2652
- *
2653
- * @param callback - Receives a tx-bound `Repository<TDoc>` — call its
2654
- * methods directly, no need to pass `{ session }` manually
2655
- * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
2656
- * @param options.onFallback - Called when falling back to non-transactional execution
2657
- * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
2658
- *
2659
- * @example
2660
- * ```typescript
2661
- * const order = await repo.withTransaction(async (txRepo) => {
2662
- * const created = await txRepo.create({ total: 100 });
2663
- * await txRepo.update(created._id, { confirmed: true });
2664
- * return created;
2665
- * });
2666
- *
2667
- * // With fallback for standalone/dev environments
2668
- * await repo.withTransaction(
2669
- * async (txRepo) => {
2670
- * await txRepo.create(doc);
2671
- * },
2672
- * {
2673
- * allowFallback: true,
2674
- * onFallback: (err) => logger.warn('Running without transaction', err),
2675
- * },
2676
- * );
2677
- *
2678
- * // Cross-repo with explicit session:
2679
- * import { withTransaction } from '@classytic/mongokit';
2680
- * await withTransaction(mongoose.connection, async (session) => {
2681
- * const order = await orderRepo.create(data, { session });
2682
- * await inventoryRepo.decrement(..., { session });
2683
- * });
2684
- * ```
2685
- */
2686
- async withTransaction(callback, options = {}) {
2687
- return withTransaction(this.Model.db, async (session) => {
2688
- return callback(createTxBoundRepo(this, session));
2689
- }, options);
2690
- }
2691
- /**
2692
- * Execute custom query with event emission
2693
- */
2694
- async _executeQuery(buildQuery) {
2695
- const operation = buildQuery.name || "custom";
2696
- const context = await this._buildContext(operation, {});
2697
- return this._runOp(operation, context, () => buildQuery(this.Model));
2698
- }
2699
- /**
2700
- * Build operation context and run before-hooks (sorted by priority).
2701
- *
2702
- * Narrows `RepositoryBase._buildContext`'s return type to mongokit's
2703
- * richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
2704
- * populate / readPreference / session, etc.). Runtime is inherited from
2705
- * the base class — this override is purely a type-narrowing shim.
2706
- *
2707
- * Hook execution order is deterministic:
2708
- * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
2709
- * 2. CACHE (200) — cache lookup (after policy filters are injected)
2710
- * 3. OBSERVABILITY (300) — audit logging, metrics
2711
- * 4. DEFAULT (500) — user-registered hooks
2712
- */
2713
- async _buildContext(operation, options) {
2714
- const normalized = this._normalizeFilterSlots(options);
2715
- return await super._buildContext(operation, normalized);
2716
- }
2717
- /**
2718
- * Walk the well-known filter-carrying keys (`query`, `filters`,
2719
- * `having`) and route any Filter IR through `compileFilterToMongo`.
2720
- * Plain Mongo queries pass through untouched — `isFilter` is the
2721
- * discriminator. Returns a shallow clone; never mutates input.
2722
- */
2723
- _normalizeFilterSlots(options) {
2724
- let cloned = null;
2725
- for (const key of [
2726
- "query",
2727
- "filters",
2728
- "having"
2729
- ]) {
2730
- const value = options[key];
2731
- if (value === void 0 || value === null) continue;
2732
- if (!isFilter(value)) continue;
2733
- cloned ??= { ...options };
2734
- cloned[key] = compileFilterToMongo(value);
2735
- }
2736
- return cloned ?? options;
2737
- }
2738
- /**
2739
- * Resolve the effective sort for getAll. Handles $near conflict detection
2740
- * (warns + drops explicit sort when $near is in filters).
2741
- */
2742
- _resolveSort(context, params, isNearQuery) {
2743
- const explicitSort = context.sort ?? params.sort;
2744
- if (isNearQuery) {
2745
- if (explicitSort !== void 0) warn(`[mongokit] Repository "${this.model}" dropping explicit sort (${JSON.stringify(explicitSort)}) because the filter contains $near / $nearSphere. MongoDB forbids explicit sort with $near. Use [withinRadius] instead of [near] if you need a custom sort.`);
2746
- return;
2747
- }
2748
- return explicitSort ?? "-createdAt";
2749
- }
2750
- /**
2751
- * Detect whether to use keyset (cursor) or offset (page) pagination.
2752
- */
2753
- _detectPaginationMode(mode, page, after, sort, context, params) {
2754
- if (mode) return mode === "keyset";
2755
- return !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
2756
- }
2757
- /**
2758
- * Build the MongoDB query filter with search merged in. Handles text
2759
- * search ($text), regex search ($or of $regex), and the search-resolver
2760
- * plugin contract (search already cleared by a before:getAll hook).
2761
- */
2762
- _buildSearchQuery(filters, search) {
2763
- const query = { ...filters };
2764
- if (!search) return query;
2765
- if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
2766
- let effectiveMode = this.searchMode === "regex" ? "regex" : "text";
2767
- if (this.searchMode === "auto") effectiveMode = this._hasTextIndex ? "text" : "regex";
2768
- if (effectiveMode === "regex") {
2769
- if (!this.searchFields || this.searchFields.length === 0) throw createError(400, `Repository "${this.model}" configured with searchMode: '${this.searchMode}' but no searchFields provided.`);
2770
- const escaped = String(search).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2771
- const orConds = this.searchFields.map((field) => ({ [field]: {
2772
- $regex: escaped,
2773
- $options: "i"
2774
- } }));
2775
- if (Array.isArray(query.$or)) {
2776
- const existingOr = query.$or;
2777
- delete query.$or;
2778
- query.$and = [
2779
- ...Array.isArray(query.$and) ? query.$and : [],
2780
- { $or: existingOr },
2781
- { $or: orConds }
2782
- ];
2783
- } else query.$or = orConds;
2784
- } else if (this._hasTextIndex) query.$text = { $search: search };
2785
- else {
2786
- const modelName = String(this.model);
2787
- throw createError(400, `No text index found for ${modelName}. Cannot perform text search. Configure ${modelName}'s Repository with { searchMode: 'regex', searchFields: [...] } to enable index-free search, or create a text index on the collection for searchMode: 'text'. See https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter`, {
2788
- code: "SEARCH_NOT_CONFIGURED",
2789
- meta: {
2790
- model: modelName,
2791
- configuredMode: this.searchMode,
2792
- availableModes: [
2793
- "text",
2794
- "regex",
2795
- "auto"
2796
- ],
2797
- docs: "https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter"
2798
- }
2799
- });
2800
- }
2801
- return query;
2802
- }
2803
- /**
2804
- * Parse sort string or object
2805
- */
2806
- _parseSort(sort) {
2807
- if (!sort) return { createdAt: -1 };
2808
- if (typeof sort === "object") {
2809
- if (Object.keys(sort).length === 0) return { createdAt: -1 };
2810
- return sort;
2811
- }
2812
- const sortObj = {};
2813
- const fields = sort.split(",").map((s) => s.trim());
2814
- for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
2815
- else sortObj[field] = 1;
2816
- return sortObj;
2817
- }
2818
- /**
2819
- * Parse populate specification
2820
- */
2821
- _parsePopulate(populate) {
2822
- if (!populate) return [];
2823
- if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
2824
- if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
2825
- return [populate];
2826
- }
2827
- /**
2828
- * Classify a driver error as an authoritative duplicate-key conflict.
2829
- *
2830
- * Used by arc's outbox / idempotency adapters to distinguish
2831
- * "write already landed (idempotent no-op)" from "transient DB error
2832
- * (must retry)". Every backend signals duplicate keys differently —
2833
- * MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
2834
- * predicate lives on the kit that knows its driver. Cross-driver
2835
- * adapters then depend only on the boolean outcome, not the shape of
2836
- * the underlying error.
2837
- *
2838
- * Deliberately narrow: matches ONLY `code === 11000` and
2839
- * `codeName === 'DuplicateKey'`. Does NOT match
2840
- * `err.name === 'MongoServerError'`, which is also true for
2841
- * WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
2842
- * other server-side error — treating those as duplicate keys would
2843
- * silently swallow transactional retries.
2844
- *
2845
- * @example
2846
- * try {
2847
- * await outboxRepo.create(event);
2848
- * } catch (err) {
2849
- * if (outboxRepo.isDuplicateKeyError(err)) {
2850
- * // Idempotent no-op — event was already written by a prior attempt.
2851
- * return;
2852
- * }
2853
- * throw err; // transient DB error — upstream retries
2854
- * }
2855
- */
2856
- isDuplicateKeyError(err) {
2857
- return isDuplicateKeyError(err);
2858
- }
2859
- /**
2860
- * Handle errors with proper HTTP status codes
2861
- */
2862
- _handleError(error) {
2863
- if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
2864
- if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
2865
- const duplicateErr = parseDuplicateKeyError(error);
2866
- if (duplicateErr) return duplicateErr;
2867
- if (error.status && error.message) return error;
2868
- return createError(500, error.message || "Internal Server Error");
2869
- }
2870
- };
2871
- //#endregion
2872
- //#region src/plugins/cache.plugin.ts
2873
- /**
2874
- * Cache Plugin
2875
- *
2876
- * Optional caching layer for MongoKit with automatic invalidation.
2877
- * Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
2878
- *
2879
- * Features:
2880
- * - Cache-aside (read-through) pattern with configurable TTLs
2881
- * - Automatic invalidation on create/update/delete
2882
- * - Collection version tags for efficient list cache invalidation
2883
- * - Manual invalidation methods for microservice scenarios
2884
- * - Skip cache per-operation with `skipCache: true`
2885
- *
2886
- * @example
2887
- * ```typescript
2888
- * import { Repository, cachePlugin } from '@classytic/mongokit';
2889
- * import Redis from 'ioredis';
2890
- *
2891
- * const redis = new Redis();
2892
- *
2893
- * const userRepo = new Repository(UserModel, [
2894
- * cachePlugin({
2895
- * adapter: {
2896
- * async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
2897
- * async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
2898
- * async delete(key) { await redis.del(key); },
2899
- * async clear(pattern) {
2900
- * const keys = await redis.keys(pattern || '*');
2901
- * if (keys.length) await redis.del(...keys);
2902
- * }
2903
- * },
2904
- * ttl: 60, // 1 minute default
2905
- * })
2906
- * ]);
2907
- *
2908
- * // Reads check cache first
2909
- * const user = await userRepo.getById(id); // cached
2910
- *
2911
- * // Skip cache for fresh data
2912
- * const fresh = await userRepo.getById(id, { skipCache: true });
2913
- *
2914
- * // Mutations auto-invalidate
2915
- * await userRepo.update(id, { name: 'New Name' }); // invalidates cache
2916
- *
2917
- * // Manual invalidation for microservice sync
2918
- * await userRepo.invalidateCache(id); // invalidate single doc
2919
- * await userRepo.invalidateAllCache(); // invalidate all for this model
2920
- * ```
2921
- */
2922
- /**
2923
- * Resolve the user's jitter config into a deterministic `ttl -> ttl` function.
2924
- *
2925
- * - `undefined` / `0`: identity (no jitter).
2926
- * - number in (0, 1]: symmetric fractional jitter. `0.1` → uniform in [0.9, 1.1].
2927
- * - function: used as-is; caller owns the math. Clamped to >= 1s below.
2928
- *
2929
- * The final TTL is always clamped to a whole second >= 1 so adapters that
2930
- * treat 0 as "no expiry" (Redis SETEX semantics) don't accidentally persist
2931
- * entries forever.
2932
- */
2933
- function resolveJitter(jitter) {
2934
- if (!jitter) return (ttl) => ttl;
2935
- if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
2936
- const fraction = Math.min(1, Math.max(0, jitter));
2937
- if (fraction === 0) return (ttl) => ttl;
2938
- return (ttl) => {
2939
- const delta = ttl * fraction;
2940
- const jittered = ttl - delta + Math.random() * 2 * delta;
2941
- return Math.max(1, Math.round(jittered));
2942
- };
2943
- }
2944
- /**
2945
- * Cache plugin factory
2946
- *
2947
- * @param options - Cache configuration
2948
- * @returns Plugin instance
2949
- */
2950
- function cachePlugin(options) {
2951
- const config = {
2952
- adapter: options.adapter,
2953
- ttl: options.ttl ?? 60,
2954
- byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
2955
- queryTtl: options.queryTtl ?? options.ttl ?? 60,
2956
- prefix: options.prefix ?? "mk",
2957
- debug: options.debug ?? false,
2958
- skipIfLargeLimit: options.skipIf?.largeLimit ?? 100,
2959
- applyJitter: resolveJitter(options.jitter)
2960
- };
2961
- const stats = {
2962
- hits: 0,
2963
- misses: 0,
2964
- sets: 0,
2965
- invalidations: 0,
2966
- errors: 0
2967
- };
2968
- const log = (msg, data) => {
2969
- if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
2970
- };
2971
- return {
2972
- name: "cache",
2973
- apply(repo) {
2974
- const model = repo.model;
2975
- const byIdKeyRegistry = /* @__PURE__ */ new Map();
2976
- function trackByIdKey(docId, cacheKey) {
2977
- let keys = byIdKeyRegistry.get(docId);
2978
- if (!keys) {
2979
- keys = /* @__PURE__ */ new Set();
2980
- byIdKeyRegistry.set(docId, keys);
2981
- }
2982
- keys.add(cacheKey);
2983
- }
2984
- async function getVersion() {
2985
- try {
2986
- return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
2987
- } catch (e) {
2988
- log(`Cache error in getVersion for ${model}:`, e);
2989
- return 0;
2990
- }
2991
- }
2992
- /**
2993
- * Bump collection version in the adapter (invalidates all list caches).
2994
- * Uses Date.now() so version always moves forward — safe after eviction or deploy.
2995
- */
2996
- async function bumpVersion() {
2997
- const newVersion = Date.now();
2998
- try {
2999
- await config.adapter.set(versionKey(config.prefix, model), newVersion, config.applyJitter(config.ttl * 10));
3000
- stats.invalidations++;
3001
- log(`Bumped version for ${model} to:`, newVersion);
3002
- } catch (e) {
3003
- log(`Failed to bump version for ${model}:`, e);
3004
- }
3005
- }
3006
- /**
3007
- * Invalidate a specific document by ID (all shape variants).
3008
- * Deletes every tracked shape-variant key individually via delete(),
3009
- * so adapters without pattern-based clear() still get full invalidation.
3010
- */
3011
- async function invalidateById(id) {
3012
- try {
3013
- const baseKey = byIdKey(config.prefix, model, id);
3014
- await config.adapter.delete(baseKey);
3015
- const trackedKeys = byIdKeyRegistry.get(id);
3016
- if (trackedKeys) {
3017
- for (const key of trackedKeys) if (key !== baseKey) await config.adapter.delete(key);
3018
- byIdKeyRegistry.delete(id);
3019
- }
3020
- stats.invalidations++;
3021
- log(`Invalidated byId cache for:`, id);
3022
- } catch (e) {
3023
- log(`Failed to invalidate byId cache:`, e);
3024
- }
3025
- }
3026
- /**
3027
- * before:getById - Check cache for document
3028
- * Runs at CACHE priority (200) — after policy hooks inject filters
3029
- */
3030
- repo.on("before:getById", async (context) => {
3031
- if (context.skipCache) {
3032
- log(`Skipping cache for getById: ${context.id}`);
3033
- return;
3034
- }
3035
- const id = String(context.id);
3036
- const key = byIdKey(config.prefix, model, id, {
3037
- select: context.select,
3038
- populate: context.populate,
3039
- lean: context.lean
3040
- });
3041
- try {
3042
- const cached = await config.adapter.get(key);
3043
- if (cached !== null) {
3044
- stats.hits++;
3045
- log(`Cache HIT for getById:`, key);
3046
- context._cacheHit = true;
3047
- context._cachedResult = cached;
3048
- } else {
3049
- stats.misses++;
3050
- log(`Cache MISS for getById:`, key);
3051
- }
3052
- } catch (e) {
3053
- log(`Cache error for getById:`, e);
3054
- stats.errors++;
3055
- }
3056
- }, { priority: HOOK_PRIORITY$1.CACHE });
3057
- /**
3058
- * before:getByQuery - Check cache for single-doc query
3059
- * Runs at CACHE priority (200) — after policy hooks inject filters
3060
- */
3061
- repo.on("before:getByQuery", async (context) => {
3062
- if (context.skipCache) {
3063
- log(`Skipping cache for getByQuery`);
3064
- return;
3065
- }
3066
- const collectionVersion = await getVersion();
3067
- const query = context.query || {};
3068
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
3069
- select: context.select,
3070
- populate: context.populate
3071
- });
3072
- try {
3073
- const cached = await config.adapter.get(key);
3074
- if (cached !== null) {
3075
- stats.hits++;
3076
- log(`Cache HIT for getByQuery:`, key);
3077
- context._cacheHit = true;
3078
- context._cachedResult = cached;
3079
- } else {
3080
- stats.misses++;
3081
- log(`Cache MISS for getByQuery:`, key);
3082
- }
3083
- } catch (e) {
3084
- log(`Cache error for getByQuery:`, e);
3085
- stats.errors++;
3086
- }
3087
- }, { priority: HOOK_PRIORITY$1.CACHE });
3088
- /**
3089
- * before:getOne - Same cache logic as getByQuery (single-doc)
3090
- */
3091
- repo.on("before:getOne", async (context) => {
3092
- if (context.skipCache) return;
3093
- try {
3094
- const collectionVersion = await getVersion();
3095
- const query = context.query || {};
3096
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
3097
- select: context.select,
3098
- populate: context.populate
3099
- });
3100
- const cached = await config.adapter.get(key);
3101
- if (cached !== null && cached !== void 0) {
3102
- log(`Cache HIT for getOne: ${key}`);
3103
- stats.hits++;
3104
- context._cacheHit = true;
3105
- context._cachedResult = cached;
3106
- } else {
3107
- log(`Cache MISS for getOne: ${key}`);
3108
- stats.misses++;
3109
- context._cacheKey = key;
3110
- }
3111
- } catch (e) {
3112
- log(`Cache error for getOne:`, e);
3113
- stats.errors++;
3114
- }
3115
- }, { priority: HOOK_PRIORITY$1.CACHE });
3116
- /**
3117
- * before:getAll - Check cache for list query
3118
- * Runs at CACHE priority (200) — after policy hooks inject filters
3119
- */
3120
- repo.on("before:getAll", async (context) => {
3121
- if (context.skipCache) {
3122
- log(`Skipping cache for getAll`);
3123
- return;
3124
- }
3125
- const limit = context.limit;
3126
- if (limit && limit > config.skipIfLargeLimit) {
3127
- log(`Skipping cache for large query (limit: ${limit})`);
3128
- return;
3129
- }
3130
- const collectionVersion = await getVersion();
3131
- const params = {
3132
- filters: context.filters,
3133
- sort: context.sort,
3134
- page: context.page,
3135
- limit,
3136
- after: context.after,
3137
- select: context.select,
3138
- populate: context.populate,
3139
- search: context.search,
3140
- mode: context.mode,
3141
- lean: context.lean,
3142
- readPreference: context.readPreference,
3143
- hint: context.hint,
3144
- maxTimeMS: context.maxTimeMS,
3145
- countStrategy: context.countStrategy
3146
- };
3147
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
3148
- try {
3149
- const cached = await config.adapter.get(key);
3150
- if (cached !== null) {
3151
- stats.hits++;
3152
- log(`Cache HIT for getAll:`, key);
3153
- context._cacheHit = true;
3154
- context._cachedResult = cached;
3155
- } else {
3156
- stats.misses++;
3157
- log(`Cache MISS for getAll:`, key);
3158
- }
3159
- } catch (e) {
3160
- log(`Cache error for getAll:`, e);
3161
- stats.errors++;
3162
- }
3163
- }, { priority: HOOK_PRIORITY$1.CACHE });
3164
- /**
3165
- * after:getById - Cache the result
3166
- */
3167
- repo.on("after:getById", async (payload) => {
3168
- const { context, result } = payload;
3169
- if (context._cacheHit) return;
3170
- if (context.skipCache) return;
3171
- if (result === null) return;
3172
- const id = String(context.id);
3173
- const key = byIdKey(config.prefix, model, id, {
3174
- select: context.select,
3175
- populate: context.populate,
3176
- lean: context.lean
3177
- });
3178
- const ttl = config.applyJitter(context.cacheTtl ?? config.byIdTtl);
3179
- try {
3180
- await config.adapter.set(key, result, ttl);
3181
- trackByIdKey(id, key);
3182
- stats.sets++;
3183
- log(`Cached getById result:`, key);
3184
- } catch (e) {
3185
- log(`Failed to cache getById:`, e);
3186
- }
3187
- });
3188
- /**
3189
- * after:getByQuery - Cache the result
3190
- */
3191
- repo.on("after:getByQuery", async (payload) => {
3192
- const { context, result } = payload;
3193
- if (context._cacheHit) return;
3194
- if (context.skipCache) return;
3195
- if (result === null) return;
3196
- const collectionVersion = await getVersion();
3197
- const query = context.query || {};
3198
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
3199
- select: context.select,
3200
- populate: context.populate
3201
- });
3202
- const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
3203
- try {
3204
- await config.adapter.set(key, result, ttl);
3205
- stats.sets++;
3206
- log(`Cached getByQuery result:`, key);
3207
- } catch (e) {
3208
- log(`Failed to cache getByQuery:`, e);
3209
- }
3210
- });
3211
- /**
3212
- * after:getOne - Cache the result (same as getByQuery)
3213
- */
3214
- repo.on("after:getOne", async (payload) => {
3215
- const { context, result } = payload;
3216
- if (context._cacheHit || context.skipCache || result === null) return;
3217
- const collectionVersion = await getVersion();
3218
- const query = context.query || {};
3219
- const key = byQueryKey(config.prefix, model, collectionVersion, query, {
3220
- select: context.select,
3221
- populate: context.populate
3222
- });
3223
- try {
3224
- await config.adapter.set(key, result, config.applyJitter(context.cacheTtl ?? config.queryTtl));
3225
- stats.sets++;
3226
- } catch (e) {
3227
- log(`Failed to cache getOne:`, e);
3228
- }
3229
- });
3230
- /**
3231
- * after:getAll - Cache the result
3232
- */
3233
- repo.on("after:getAll", async (payload) => {
3234
- const { context, result } = payload;
3235
- if (context._cacheHit) return;
3236
- if (context.skipCache) return;
3237
- const limit = context.limit;
3238
- if (limit && limit > config.skipIfLargeLimit) return;
3239
- const collectionVersion = await getVersion();
3240
- const params = {
3241
- filters: context.filters,
3242
- sort: context.sort,
3243
- page: context.page,
3244
- limit,
3245
- after: context.after,
3246
- select: context.select,
3247
- populate: context.populate,
3248
- search: context.search,
3249
- mode: context.mode,
3250
- lean: context.lean,
3251
- readPreference: context.readPreference,
3252
- hint: context.hint,
3253
- maxTimeMS: context.maxTimeMS,
3254
- countStrategy: context.countStrategy
3255
- };
3256
- const key = listQueryKey(config.prefix, model, collectionVersion, params);
3257
- const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
3258
- try {
3259
- await config.adapter.set(key, result, ttl);
3260
- stats.sets++;
3261
- log(`Cached getAll result:`, key);
3262
- } catch (e) {
3263
- log(`Failed to cache getAll:`, e);
3264
- }
3265
- });
3266
- /**
3267
- * after:create - Bump version to invalidate list caches
3268
- */
3269
- repo.on("after:create", async () => {
3270
- await bumpVersion();
3271
- });
3272
- /**
3273
- * after:createMany - Bump version to invalidate list caches
3274
- */
3275
- repo.on("after:createMany", async () => {
3276
- await bumpVersion();
3277
- });
3278
- /**
3279
- * after:update - Invalidate by ID and bump version
3280
- */
3281
- repo.on("after:update", async (payload) => {
3282
- const { context } = payload;
3283
- const id = String(context.id);
3284
- await Promise.all([invalidateById(id), bumpVersion()]);
3285
- });
3286
- /**
3287
- * after:updateMany - Bump version (can't track individual IDs efficiently)
3288
- */
3289
- repo.on("after:updateMany", async () => {
3290
- await bumpVersion();
3291
- });
3292
- /**
3293
- * after:findOneAndUpdate — atomic CAS may insert (upsert) or mutate.
3294
- * Result carries the matched/inserted doc; invalidate that ID and bump
3295
- * version. Null result (no match, no upsert) is a no-op.
3296
- */
3297
- repo.on("after:findOneAndUpdate", async (payload) => {
3298
- const { result } = payload;
3299
- if (!result) return;
3300
- const idKey = repo.idField || "_id";
3301
- const id = result?.[idKey];
3302
- if (id == null) {
3303
- await bumpVersion();
3304
- return;
3305
- }
3306
- await Promise.all([invalidateById(String(id)), bumpVersion()]);
3307
- });
3308
- /**
3309
- * after:delete - Invalidate by ID and bump version
3310
- */
3311
- repo.on("after:delete", async (payload) => {
3312
- const { context } = payload;
3313
- const id = String(context.id);
3314
- await Promise.all([invalidateById(id), bumpVersion()]);
3315
- });
3316
- /**
3317
- * after:deleteMany - Bump version
3318
- */
3319
- repo.on("after:deleteMany", async () => {
3320
- await bumpVersion();
3321
- });
3322
- /**
3323
- * after:bulkWrite - Bump version (bulk ops may insert/update/delete)
3324
- */
3325
- repo.on("after:bulkWrite", async () => {
3326
- await bumpVersion();
3327
- });
3328
- /**
3329
- * Invalidate cache for a specific document
3330
- * Use when document was updated outside this service
3331
- *
3332
- * @example
3333
- * await userRepo.invalidateCache('507f1f77bcf86cd799439011');
3334
- */
3335
- repo.invalidateCache = async (id) => {
3336
- await invalidateById(id);
3337
- log(`Manual invalidation for ID:`, id);
3338
- };
3339
- /**
3340
- * Invalidate all list caches for this model
3341
- * Use when bulk changes happened outside this service
3342
- *
3343
- * @example
3344
- * await userRepo.invalidateListCache();
3345
- */
3346
- repo.invalidateListCache = async () => {
3347
- await bumpVersion();
3348
- log(`Manual list cache invalidation for ${model}`);
3349
- };
3350
- /**
3351
- * Invalidate ALL cache entries for this model
3352
- * Nuclear option - use sparingly
3353
- *
3354
- * @example
3355
- * await userRepo.invalidateAllCache();
3356
- */
3357
- repo.invalidateAllCache = async () => {
3358
- if (config.adapter.clear) try {
3359
- await config.adapter.clear(modelPattern(config.prefix, model));
3360
- stats.invalidations++;
3361
- log(`Full cache invalidation for ${model}`);
3362
- } catch (e) {
3363
- log(`Failed full cache invalidation for ${model}:`, e);
3364
- }
3365
- else {
3366
- await bumpVersion();
3367
- log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
3368
- }
3369
- };
3370
- /**
3371
- * Get cache statistics for monitoring
3372
- *
3373
- * @example
3374
- * const stats = userRepo.getCacheStats();
3375
- * console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
3376
- */
3377
- repo.getCacheStats = () => ({ ...stats });
3378
- /**
3379
- * Reset cache statistics
3380
- */
3381
- repo.resetCacheStats = () => {
3382
- stats.hits = 0;
3383
- stats.misses = 0;
3384
- stats.sets = 0;
3385
- stats.invalidations = 0;
3386
- stats.errors = 0;
3387
- };
3388
- }
3389
- };
3390
- }
3391
- //#endregion
3392
- //#region src/plugins/cascade.plugin.ts
3393
- /**
3394
- * Cascade Delete Plugin
3395
- *
3396
- * Automatically deletes related documents when a parent document is deleted.
3397
- *
3398
- * Two routing modes per relation:
3399
- *
3400
- * 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
3401
- * Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
3402
- * query, { mode })`, so the target's `before:delete` / `before:deleteMany`
3403
- * hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
3404
- * and the target's own `softDeletePlugin` (with its configured
3405
- * `deletedField`) all run correctly.
3406
- *
3407
- * 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
3408
- * writes directly via `mongoose.models[name].updateMany / deleteMany`,
3409
- * **bypassing** the target's hooks. Safe only for trivial targets with
3410
- * no policy plugins. Retained for backwards compatibility — prefer the
3411
- * repo-routed form for new code.
3412
- *
3413
- * The parent's delete mode propagates: a hard-deleted parent cascades hard,
3414
- * a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
3415
- * the decision per-relation.
3416
- *
3417
- * @example Repo-routed (new)
3418
- * ```ts
3419
- * const productRepo = new Repository(Product, [
3420
- * methodRegistryPlugin(),
3421
- * cascadePlugin({
3422
- * relations: [
3423
- * { repo: stockEntryRepo, foreignKey: 'product' },
3424
- * { repo: stockMovementRepo, foreignKey: 'product' },
3425
- * ],
3426
- * }),
3427
- * ]);
3428
- * ```
3429
- *
3430
- * @example Model-routed (legacy)
3431
- * ```ts
3432
- * cascadePlugin({
3433
- * relations: [
3434
- * { model: 'StockEntry', foreignKey: 'product' },
3435
- * ],
3436
- * });
3437
- * ```
3438
- */
3439
- /**
3440
- * Collect top-level parent-context fields that a target plugin (multi-tenant,
3441
- * audit) will likely need. We can't know the target's contextKey, so we
3442
- * forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
3443
- * These are the conventions enforced by the built-in plugins.
3444
- */
3445
- function collectScopeForward(context) {
3446
- const out = {};
3447
- if (context.organizationId !== void 0) out.organizationId = context.organizationId;
3448
- if (context.tenantId !== void 0) out.tenantId = context.tenantId;
3449
- if (context.user !== void 0) out.user = context.user;
3450
- return out;
3451
- }
3452
- /**
3453
- * Cascade delete plugin.
3454
- */
3455
- function cascadePlugin(options) {
3456
- const { relations, parallel = true, logger } = options;
3457
- if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
3458
- for (const rel of relations) {
3459
- if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
3460
- if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
3461
- }
3462
- return {
3463
- name: "cascade",
3464
- apply(repo) {
3465
- repo.on("after:delete", async (payload) => {
3466
- const { context } = payload;
3467
- const deletedId = context.id;
3468
- if (!deletedId) {
3469
- logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
3470
- return;
3471
- }
3472
- const isSoftParent = context.softDeleted === true;
3473
- const scopeForward = collectScopeForward(context);
3474
- const cascadeOne = async (relation) => {
3475
- const shouldSoftDelete = relation.softDelete ?? isSoftParent;
3476
- const cascadeCtx = {
3477
- targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
3478
- session: context.session,
3479
- shouldSoftDelete,
3480
- user: context.user,
3481
- scopeForward
3482
- };
3483
- try {
3484
- if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
3485
- else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
3486
- } catch (error) {
3487
- logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
3488
- parentModel: context.model,
3489
- parentId: String(deletedId),
3490
- relatedModel: cascadeCtx.targetModelName,
3491
- foreignKey: relation.foreignKey,
3492
- error: error.message
3493
- });
3494
- throw error;
3495
- }
3496
- };
3497
- await runCascades(relations, cascadeOne, parallel);
3498
- });
3499
- repo.on("before:deleteMany", async (context) => {
3500
- const query = context.query;
3501
- if (!query || Object.keys(query).length === 0) return;
3502
- const idField = repo.idField || "_id";
3503
- context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
3504
- });
3505
- repo.on("after:deleteMany", async (payload) => {
3506
- const { context } = payload;
3507
- const ids = context._cascadeIds;
3508
- if (!ids || ids.length === 0) return;
3509
- const isSoftParent = context.softDeleted === true;
3510
- const scopeForward = collectScopeForward(context);
3511
- const cascadeBulk = async (relation) => {
3512
- const shouldSoftDelete = relation.softDelete ?? isSoftParent;
3513
- const cascadeCtx = {
3514
- targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
3515
- session: context.session,
3516
- shouldSoftDelete,
3517
- user: context.user,
3518
- scopeForward
3519
- };
3520
- try {
3521
- if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
3522
- else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
3523
- } catch (error) {
3524
- logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
3525
- parentModel: context.model,
3526
- relatedModel: cascadeCtx.targetModelName,
3527
- foreignKey: relation.foreignKey,
3528
- error: error.message
3529
- });
3530
- throw error;
3531
- }
3532
- };
3533
- await runCascades(relations, cascadeBulk, parallel);
3534
- });
3535
- }
3536
- };
3537
- }
3538
- /**
3539
- * Repo-routed cascade for a single parent delete.
3540
- * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
3541
- * hooks (multi-tenant, audit, target's own soft-delete plugin).
3542
- */
3543
- async function cascadeViaRepoBulk(relation, parentId, ctx) {
3544
- const targetRepo = relation.repo;
3545
- 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.`);
3546
- await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
3547
- ...ctx.scopeForward,
3548
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
3549
- session: ctx.session
3550
- });
3551
- }
3552
- /**
3553
- * Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
3554
- */
3555
- async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
3556
- const targetRepo = relation.repo;
3557
- 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.`);
3558
- await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
3559
- ...ctx.scopeForward,
3560
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
3561
- session: ctx.session
3562
- });
3563
- }
3564
- /**
3565
- * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
3566
- * Retained for backwards compatibility with existing cascadePlugin consumers.
3567
- */
3568
- async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
3569
- const RelatedModel = mongoose.models[relation.model];
3570
- if (!RelatedModel) {
3571
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
3572
- return;
3573
- }
3574
- const query = { [relation.foreignKey]: parentId };
3575
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
3576
- deletedAt: /* @__PURE__ */ new Date(),
3577
- ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
3578
- }, { session: ctx.session });
3579
- else await RelatedModel.deleteMany(query, { session: ctx.session });
3580
- }
3581
- async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
3582
- const RelatedModel = mongoose.models[relation.model];
3583
- if (!RelatedModel) {
3584
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
3585
- return;
3586
- }
3587
- const query = { [relation.foreignKey]: { $in: parentIds } };
3588
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
3589
- deletedAt: /* @__PURE__ */ new Date(),
3590
- ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
3591
- }, { session: ctx.session });
3592
- else await RelatedModel.deleteMany(query, { session: ctx.session });
3593
- }
3594
- /**
3595
- * Execute a list of cascade operations, honoring the `parallel` flag.
3596
- * Uses `allSettled` so one failure doesn't abort siblings; throws the first
3597
- * rejection (with a composite message if several failed) after all complete.
3598
- */
3599
- async function runCascades(relations, fn, parallel) {
3600
- if (parallel) {
3601
- const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
3602
- if (failures.length) {
3603
- const err = failures[0].reason;
3604
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
3605
- throw err;
3606
- }
3607
- } else for (const relation of relations) await fn(relation);
3608
- }
3609
- //#endregion
3610
- //#region src/plugins/custom-id.plugin.ts
3611
- /**
3612
- * Custom ID Plugin
3613
- *
3614
- * Generates custom document IDs using pluggable generators.
3615
- * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
3616
- * date-partitioned sequences, and fully custom generators.
3617
- *
3618
- * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
3619
- * counters collection — guaranteeing no duplicate IDs under concurrency.
3620
- *
3621
- * @example Basic sequential counter
3622
- * ```typescript
3623
- * const invoiceRepo = new Repository(InvoiceModel, [
3624
- * customIdPlugin({
3625
- * field: 'invoiceNumber',
3626
- * generator: sequentialId({
3627
- * prefix: 'INV',
3628
- * model: InvoiceModel,
3629
- * }),
3630
- * }),
3631
- * ]);
3632
- *
3633
- * const inv = await invoiceRepo.create({ amount: 100 });
3634
- * // inv.invoiceNumber → "INV-0001"
3635
- * ```
3636
- *
3637
- * @example Date-partitioned counter (resets monthly)
3638
- * ```typescript
3639
- * const billRepo = new Repository(BillModel, [
3640
- * customIdPlugin({
3641
- * field: 'billNumber',
3642
- * generator: dateSequentialId({
3643
- * prefix: 'BILL',
3644
- * model: BillModel,
3645
- * partition: 'monthly',
3646
- * separator: '-',
3647
- * padding: 4,
3648
- * }),
3649
- * }),
3650
- * ]);
3651
- *
3652
- * const bill = await billRepo.create({ total: 250 });
3653
- * // bill.billNumber → "BILL-2026-02-0001"
3654
- * ```
3655
- *
3656
- * @example Custom generator function
3657
- * ```typescript
3658
- * const orderRepo = new Repository(OrderModel, [
3659
- * customIdPlugin({
3660
- * field: 'orderRef',
3661
- * generator: async (context) => {
3662
- * const region = context.data?.region || 'US';
3663
- * const seq = await getNextSequence('orders');
3664
- * return `ORD-${region}-${seq}`;
3665
- * },
3666
- * }),
3667
- * ]);
3668
- * ```
3669
- */
3670
- /** Schema for the internal counters collection */
3671
- const counterSchema = new mongoose.Schema({
3672
- _id: {
3673
- type: String,
3674
- required: true
3675
- },
3676
- seq: {
3677
- type: Number,
3678
- default: 0
3679
- }
3680
- }, {
3681
- collection: "_mongokit_counters",
3682
- versionKey: false
3683
- });
3684
- /**
3685
- * Get or create the Counter model on the given connection.
3686
- * Falls back to the default mongoose connection if none is provided.
3687
- * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
3688
- */
3689
- function getCounterModel(connection) {
3690
- const conn = connection ?? mongoose.connection;
3691
- if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
3692
- return conn.model("_MongoKitCounter", counterSchema);
3693
- }
3694
- /**
3695
- * Atomically increment and return the next sequence value for a given key.
3696
- * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
3697
- * heavy concurrency.
3698
- *
3699
- * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
3700
- * @param increment - Value to increment by (default: 1)
3701
- * @param connection - Mongoose connection to use (defaults to the default connection)
3702
- * @param session - Optional client session. When provided, the counter bump
3703
- * participates in the caller's transaction — if the surrounding transaction
3704
- * aborts, the counter does NOT advance. Without a session, the bump commits
3705
- * immediately and is not rolled back if a sibling write fails.
3706
- * @returns The next sequence number (after increment)
3707
- *
3708
- * @example
3709
- * const seq = await getNextSequence('invoices');
3710
- * // First call → 1, second → 2, ...
3711
- *
3712
- * @example Batch increment for createMany
3713
- * const startSeq = await getNextSequence('invoices', 5);
3714
- * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
3715
- *
3716
- * @example Transactional counter bump
3717
- * await withTransaction(conn, async (session) => {
3718
- * const seq = await getNextSequence('invoices', 1, conn, session);
3719
- * await invoiceRepo.create({ invoiceNumber: `INV-${seq}` }, { session });
3720
- * // if this throws, the counter is rolled back with the insert
3721
- * });
3722
- */
3723
- async function getNextSequence(counterKey, increment = 1, connection, session) {
3724
- const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
3725
- upsert: true,
3726
- returnDocument: "after",
3727
- session
3728
- });
3729
- if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
3730
- return result.seq;
3731
- }
3732
- /**
3733
- * Generator: Simple sequential counter.
3734
- * Produces IDs like `INV-0001`, `INV-0002`, etc.
3735
- *
3736
- * Uses atomic MongoDB counters — safe under concurrency.
3737
- *
3738
- * @example
3739
- * ```typescript
3740
- * customIdPlugin({
3741
- * field: 'invoiceNumber',
3742
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
3743
- * })
3744
- * ```
3745
- */
3746
- function sequentialId(options) {
3747
- const { prefix, model, padding = 4, separator = "-", counterKey } = options;
3748
- const key = counterKey || model.modelName;
3749
- return async (context) => {
3750
- const seq = await getNextSequence(key, 1, context._counterConnection, context.session);
3751
- return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
3752
- };
3753
- }
3754
- /**
3755
- * Generator: Date-partitioned sequential counter.
3756
- * Counter resets per period — great for invoice/bill numbering.
3757
- *
3758
- * Produces IDs like:
3759
- * - yearly: `BILL-2026-0001`
3760
- * - monthly: `BILL-2026-02-0001`
3761
- * - daily: `BILL-2026-02-20-0001`
3762
- *
3763
- * @example
3764
- * ```typescript
3765
- * customIdPlugin({
3766
- * field: 'billNumber',
3767
- * generator: dateSequentialId({
3768
- * prefix: 'BILL',
3769
- * model: BillModel,
3770
- * partition: 'monthly',
3771
- * }),
3772
- * })
3773
- * ```
3774
- */
3775
- function dateSequentialId(options) {
3776
- const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
3777
- return async (context) => {
3778
- const now = /* @__PURE__ */ new Date();
3779
- const year = String(now.getFullYear());
3780
- const month = String(now.getMonth() + 1).padStart(2, "0");
3781
- const day = String(now.getDate()).padStart(2, "0");
3782
- let datePart;
3783
- let counterKey;
3784
- switch (partition) {
3785
- case "yearly":
3786
- datePart = year;
3787
- counterKey = `${model.modelName}:${year}`;
3788
- break;
3789
- case "daily":
3790
- datePart = `${year}${separator}${month}${separator}${day}`;
3791
- counterKey = `${model.modelName}:${year}-${month}-${day}`;
3792
- break;
3793
- default:
3794
- datePart = `${year}${separator}${month}`;
3795
- counterKey = `${model.modelName}:${year}-${month}`;
3796
- break;
3797
- }
3798
- const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
3799
- return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
3800
- };
3801
- }
3802
- /**
3803
- * Generator: Prefix + random alphanumeric suffix.
3804
- * Does NOT require a database round-trip — purely in-memory.
3805
- *
3806
- * Produces IDs like: `USR_a7b3xk9m2p1q`
3807
- *
3808
- * Good for: user-facing IDs where ordering doesn't matter.
3809
- * Not suitable for sequential numbering.
3810
- *
3811
- * @example
3812
- * ```typescript
3813
- * customIdPlugin({
3814
- * field: 'publicId',
3815
- * generator: prefixedId({ prefix: 'USR', length: 10 }),
3816
- * })
3817
- * ```
3818
- */
3819
- function prefixedId(options) {
3820
- const { prefix, separator = "_", length = 12 } = options;
3821
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
3822
- return (_context) => {
3823
- let result = "";
3824
- const bytes = new Uint8Array(length);
3825
- if (typeof globalThis.crypto?.getRandomValues === "function") {
3826
- globalThis.crypto.getRandomValues(bytes);
3827
- for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
3828
- } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
3829
- return `${prefix}${separator}${result}`;
3830
- };
3831
- }
3832
- /**
3833
- * Custom ID plugin — injects generated IDs into documents before creation.
3834
- *
3835
- * @param options - Configuration for ID generation
3836
- * @returns Plugin instance
3837
- *
3838
- * @example
3839
- * ```typescript
3840
- * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
3841
- *
3842
- * const invoiceRepo = new Repository(InvoiceModel, [
3843
- * customIdPlugin({
3844
- * field: 'invoiceNumber',
3845
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
3846
- * }),
3847
- * ]);
3848
- *
3849
- * const inv = await invoiceRepo.create({ amount: 100 });
3850
- * console.log(inv.invoiceNumber); // "INV-0001"
3851
- * ```
3852
- */
3853
- function customIdPlugin(options) {
3854
- const fieldName = options.field || "customId";
3855
- const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
3856
- return {
3857
- name: "custom-id",
3858
- apply(repo) {
3859
- const repoConnection = repo.Model.db;
3860
- repo.on("before:create", async (context) => {
3861
- if (!context.data) return;
3862
- if (generateOnlyIfEmpty && context.data[fieldName]) return;
3863
- context._counterConnection = repoConnection;
3864
- context.data[fieldName] = await options.generator(context);
3865
- });
3866
- repo.on("before:createMany", async (context) => {
3867
- if (!context.dataArray) return;
3868
- context._counterConnection = repoConnection;
3869
- const docsNeedingIds = [];
3870
- for (const doc of context.dataArray) {
3871
- if (generateOnlyIfEmpty && doc[fieldName]) continue;
3872
- docsNeedingIds.push(doc);
3873
- }
3874
- if (docsNeedingIds.length === 0) return;
3875
- for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
3876
- ...context,
3877
- data: doc
3878
- });
3879
- });
3880
- }
3881
- };
3882
- }
3883
- //#endregion
3884
- //#region src/plugins/elastic.plugin.ts
3885
- function elasticSearchPlugin(options) {
3886
- return {
3887
- name: "elastic-search",
3888
- apply(repo) {
3889
- if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
3890
- repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
3891
- const { client, index, idField = "_id" } = options;
3892
- const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
3893
- const from = Math.max(searchOptions.from || 0, 0);
3894
- const esResponse = await client.search({
3895
- index,
3896
- body: {
3897
- query: searchQuery,
3898
- size: limit,
3899
- from
3900
- }
3901
- });
3902
- const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
3903
- if (hits.length === 0) return {
3904
- docs: [],
3905
- total: 0,
3906
- limit,
3907
- from
3908
- };
3909
- const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
3910
- const total = typeof totalValue === "number" ? totalValue : 0;
3911
- const docsOrder = /* @__PURE__ */ new Map();
3912
- const scores = /* @__PURE__ */ new Map();
3913
- const ids = [];
3914
- hits.forEach((hit, idx) => {
3915
- const docId = hit._source?.[idField] || hit[idField] || hit._id;
3916
- if (docId) {
3917
- const strId = String(docId);
3918
- docsOrder.set(strId, idx);
3919
- if (hit._score !== void 0) scores.set(strId, hit._score);
3920
- ids.push(strId);
3921
- }
3922
- });
3923
- if (ids.length === 0) return {
3924
- docs: [],
3925
- total,
3926
- limit,
3927
- from
3928
- };
3929
- const mongoIdField = repo.idField || "_id";
3930
- const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
3931
- if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
3932
- if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
3933
- if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
3934
- return {
3935
- docs: (await mongoQuery.exec()).sort((a, b) => {
3936
- const aId = String(a[mongoIdField]);
3937
- const bId = String(b[mongoIdField]);
3938
- return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
3939
- }).map((doc) => {
3940
- const strId = String(doc[mongoIdField]);
3941
- if (searchOptions.mongoOptions?.lean !== false) return {
3942
- ...doc,
3943
- _score: scores.get(strId)
3944
- };
3945
- return doc;
3946
- }),
3947
- total,
3948
- limit,
3949
- from
3950
- };
3951
- });
3952
- }
3953
- };
3954
- }
3955
- //#endregion
3956
- //#region src/plugins/field-filter.plugin.ts
3957
- /**
3958
- * Field filter plugin that restricts fields based on user context
3959
- *
3960
- * @example
3961
- * const fieldPreset = {
3962
- * public: ['id', 'name'],
3963
- * authenticated: ['email'],
3964
- * admin: ['createdAt', 'internalNotes']
3965
- * };
3966
- *
3967
- * const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
3968
- */
3969
- function fieldFilterPlugin(fieldPreset) {
3970
- return {
3971
- name: "fieldFilter",
3972
- apply(repo) {
3973
- const applyFieldFiltering = (context) => {
3974
- if (!fieldPreset) return;
3975
- const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
3976
- if (context.select) context.select = `${presetSelect} ${context.select}`;
3977
- else context.select = presetSelect;
3978
- };
3979
- repo.on("before:getAll", applyFieldFiltering);
3980
- repo.on("before:findAll", applyFieldFiltering);
3981
- repo.on("before:getById", applyFieldFiltering);
3982
- repo.on("before:getByQuery", applyFieldFiltering);
3983
- repo.on("before:getOne", applyFieldFiltering);
3984
- }
3985
- };
3986
- }
3987
- //#endregion
3988
- //#region src/plugins/method-registry.plugin.ts
3989
- /**
3990
- * Method registry plugin that enables dynamic method registration
3991
- */
3992
- function methodRegistryPlugin() {
3993
- return {
3994
- name: "method-registry",
3995
- apply(repo) {
3996
- const registeredMethods = [];
3997
- /**
3998
- * Register a new method on the repository instance
3999
- */
4000
- repo.registerMethod = (name, fn) => {
4001
- 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.`);
4002
- if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
4003
- if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
4004
- repo[name] = fn.bind(repo);
4005
- registeredMethods.push(name);
4006
- repo.emit("method:registered", {
4007
- name,
4008
- fn
4009
- });
4010
- };
4011
- /**
4012
- * Check if a method is registered
4013
- */
4014
- repo.hasMethod = (name) => typeof repo[name] === "function";
4015
- /**
4016
- * Get list of all dynamically registered methods
4017
- */
4018
- repo.getRegisteredMethods = () => [...registeredMethods];
4019
- }
4020
- };
4021
- }
4022
- //#endregion
4023
- //#region src/plugins/mongo-operations.plugin.ts
4024
- /**
4025
- * MongoDB Operations Plugin
4026
- *
4027
- * Adds MongoDB-specific operations to repositories.
4028
- * Requires method-registry.plugin.js to be loaded first.
4029
- */
4030
- /**
4031
- * MongoDB operations plugin
4032
- *
4033
- * Adds MongoDB-specific atomic operations to repositories:
4034
- * - upsert: Create or update document
4035
- * - increment/decrement: Atomic numeric operations
4036
- * - pushToArray/pullFromArray/addToSet: Array operations
4037
- * - setField/unsetField/renameField: Field operations
4038
- * - multiplyField: Multiply numeric field
4039
- * - setMin/setMax: Conditional min/max updates
4040
- *
4041
- * @example Basic usage (no TypeScript autocomplete)
4042
- * ```typescript
4043
- * const repo = new Repository(ProductModel, [
4044
- * methodRegistryPlugin(),
4045
- * mongoOperationsPlugin(),
4046
- * ]);
4047
- *
4048
- * // Works at runtime but TypeScript doesn't know about these methods
4049
- * await (repo as any).increment(productId, 'views', 1);
4050
- * await (repo as any).pushToArray(productId, 'tags', 'featured');
4051
- * ```
4052
- *
4053
- * @example With TypeScript type safety (recommended)
4054
- * ```typescript
4055
- * import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
4056
- * import type { MongoOperationsMethods } from '@classytic/mongokit';
4057
- *
4058
- * class ProductRepo extends Repository<IProduct> {
4059
- * // Add your custom methods here
4060
- * }
4061
- *
4062
- * // Create with type assertion to get autocomplete for plugin methods
4063
- * type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
4064
- *
4065
- * const repo = new ProductRepo(ProductModel, [
4066
- * methodRegistryPlugin(),
4067
- * mongoOperationsPlugin(),
4068
- * ]) as ProductRepoWithPlugins;
4069
- *
4070
- * // Now TypeScript provides autocomplete and type checking!
4071
- * await repo.increment(productId, 'views', 1);
4072
- * await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
4073
- * await repo.pushToArray(productId, 'tags', 'featured');
4074
- * ```
4075
- */
4076
- function mongoOperationsPlugin() {
4077
- return {
4078
- name: "mongo-operations",
4079
- apply(repo) {
4080
- if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
4081
- /**
4082
- * Update existing document or insert new one
4083
- */
4084
- repo.registerMethod("upsert", async function(query, data, options = {}) {
4085
- return upsert(this.Model, query, data, options);
4086
- });
4087
- const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
4088
- if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
4089
- return this.update(id, { [operator]: { [field]: value } }, options);
4090
- };
4091
- /**
4092
- * Atomically increment numeric field
4093
- */
4094
- repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
4095
- return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
4096
- });
4097
- /**
4098
- * Atomically decrement numeric field
4099
- */
4100
- repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
4101
- return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
4102
- });
4103
- const applyOperator = function(id, field, value, operator, options) {
4104
- return this.update(id, { [operator]: { [field]: value } }, options);
4105
- };
4106
- /**
4107
- * Push value to array field
4108
- */
4109
- repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
4110
- return applyOperator.call(this, id, field, value, "$push", options);
4111
- });
4112
- /**
4113
- * Remove value from array field
4114
- */
4115
- repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
4116
- return applyOperator.call(this, id, field, value, "$pull", options);
4117
- });
4118
- /**
4119
- * Add value to array only if not already present (unique)
4120
- */
4121
- repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
4122
- return applyOperator.call(this, id, field, value, "$addToSet", options);
4123
- });
4124
- /**
4125
- * Set field value (alias for update with $set)
4126
- */
4127
- repo.registerMethod("setField", async function(id, field, value, options = {}) {
4128
- return applyOperator.call(this, id, field, value, "$set", options);
4129
- });
4130
- /**
4131
- * Unset (remove) field from document
4132
- */
4133
- repo.registerMethod("unsetField", async function(id, fields, options = {}) {
4134
- const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
4135
- acc[field] = "";
4136
- return acc;
4137
- }, {});
4138
- return this.update(id, { $unset: unsetObj }, options);
4139
- });
4140
- /**
4141
- * Rename field in document
4142
- */
4143
- repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
4144
- return this.update(id, { $rename: { [oldName]: newName } }, options);
4145
- });
4146
- /**
4147
- * Multiply numeric field by value
4148
- */
4149
- repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
4150
- return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
4151
- });
4152
- /**
4153
- * Set field to minimum value (only if current value is greater)
4154
- */
4155
- repo.registerMethod("setMin", async function(id, field, value, options = {}) {
4156
- return applyOperator.call(this, id, field, value, "$min", options);
4157
- });
4158
- /**
4159
- * Set field to maximum value (only if current value is less)
4160
- */
4161
- repo.registerMethod("setMax", async function(id, field, value, options = {}) {
4162
- return applyOperator.call(this, id, field, value, "$max", options);
4163
- });
4164
- /**
4165
- * Atomic update with multiple MongoDB operators in a single call
4166
- *
4167
- * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
4168
- * into one atomic database operation.
4169
- *
4170
- * @example
4171
- * // Combine $inc + $set in one atomic call
4172
- * await repo.atomicUpdate(id, {
4173
- * $inc: { views: 1, commentCount: 1 },
4174
- * $set: { lastActiveAt: new Date() }
4175
- * });
4176
- *
4177
- * // Multiple operators: $inc + $set + $push
4178
- * await repo.atomicUpdate(id, {
4179
- * $inc: { 'metrics.total': 1 },
4180
- * $set: { updatedAt: new Date() },
4181
- * $push: { history: { action: 'update', at: new Date() } }
4182
- * });
4183
- *
4184
- * // $push with $each modifier
4185
- * await repo.atomicUpdate(id, {
4186
- * $push: { tags: { $each: ['featured', 'popular'] } },
4187
- * $inc: { tagCount: 2 }
4188
- * });
4189
- *
4190
- * // With arrayFilters for positional updates
4191
- * await repo.atomicUpdate(id, {
4192
- * $set: { 'items.$[elem].quantity': 5 }
4193
- * }, { arrayFilters: [{ 'elem._id': itemId }] });
4194
- */
4195
- repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
4196
- const validOperators = new Set([
4197
- "$inc",
4198
- "$set",
4199
- "$unset",
4200
- "$push",
4201
- "$pull",
4202
- "$addToSet",
4203
- "$pop",
4204
- "$rename",
4205
- "$min",
4206
- "$max",
4207
- "$mul",
4208
- "$setOnInsert",
4209
- "$bit",
4210
- "$currentDate"
4211
- ]);
4212
- const keys = Object.keys(operators);
4213
- if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
4214
- for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
4215
- return this.update(id, operators, options);
4216
- });
4217
- }
4218
- };
4219
- }
4220
- //#endregion
4221
- //#region src/plugins/multi-tenant.plugin.ts
4222
- /**
4223
- * True when the op's policy target already has `tenantField` set by the
4224
- * caller. Used to decide whether the plugin can safely skip injecting a
4225
- * tenant scope rather than throwing on a missing context.
4226
- *
4227
- * - `data` — `context.data[tenantField]` is present
4228
- * - `dataArray` — every row in `context.dataArray` has `tenantField`
4229
- * - `query` — `context.query[tenantField]` is present
4230
- * - `filters` — `context.filters[tenantField]` is present
4231
- * - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
4232
- * - `none` — unreachable (the hook isn't registered for these ops)
4233
- *
4234
- * For multi-row targets (`dataArray`, `operations`) we require EVERY row to
4235
- * be stamped. Partial stamping is ambiguous (we have no resolver value to
4236
- * fill in the gaps) and is safer to treat as "not stamped" so the caller
4237
- * either stamps all rows or supplies a context/resolver.
4238
- */
4239
- function payloadHasTenantField(context, policyKey, tenantField) {
4240
- switch (policyKey) {
4241
- case "data": return context.data?.[tenantField] != null;
4242
- case "dataArray": {
4243
- const arr = context.dataArray;
4244
- if (!Array.isArray(arr) || arr.length === 0) return false;
4245
- return arr.every((row) => row && row[tenantField] != null);
4246
- }
4247
- case "query": return context.query?.[tenantField] != null;
4248
- case "filters": return context.filters?.[tenantField] != null;
4249
- case "operations": {
4250
- const ops = context.operations;
4251
- if (!Array.isArray(ops) || ops.length === 0) return false;
4252
- return ops.every((subOp) => {
4253
- for (const key of [
4254
- "updateOne",
4255
- "updateMany",
4256
- "deleteOne",
4257
- "deleteMany",
4258
- "replaceOne"
4259
- ]) {
4260
- const body = subOp[key];
4261
- if (body) return body.filter?.[tenantField] != null;
4262
- }
4263
- const ins = subOp.insertOne;
4264
- if (ins) return ins.document?.[tenantField] != null;
4265
- return false;
4266
- });
4267
- }
4268
- default: return false;
4269
- }
4270
- }
4271
- function multiTenantPlugin(options = {}) {
4272
- const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string", allowDataInjection = true } = options;
4273
- const extraOps = [{
4274
- op: "getDeleted",
4275
- policyKey: "filters"
4276
- }];
4277
- return {
4278
- name: "multi-tenant",
4279
- apply(repo) {
4280
- const allOps = [...ALL_OPERATIONS.map((op) => ({
4281
- op,
4282
- policyKey: OP_REGISTRY[op].policyKey
4283
- })), ...extraOps];
4284
- for (const { op, policyKey } of allOps) {
4285
- if (skipOperations.includes(op)) continue;
4286
- if (policyKey === "none") continue;
4287
- repo.on(`before:${op}`, (context) => {
4288
- if (skipWhen?.(context, op)) return;
4289
- let tenantId = context[contextKey];
4290
- if (!tenantId && resolveContext) {
4291
- tenantId = resolveContext();
4292
- if (tenantId) context[contextKey] = tenantId;
4293
- }
4294
- if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
4295
- if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
4296
- if (!tenantId) return;
4297
- const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
4298
- switch (policyKey) {
4299
- case "filters":
4300
- context.filters = {
4301
- ...context.filters,
4302
- [tenantField]: castId
4303
- };
4304
- break;
4305
- case "query":
4306
- context.query = {
4307
- ...context.query,
4308
- [tenantField]: castId
4309
- };
4310
- break;
4311
- case "data":
4312
- if (context.data) context.data[tenantField] = castId;
4313
- break;
4314
- case "dataArray":
4315
- if (context.dataArray) {
4316
- for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
4317
- }
4318
- break;
4319
- case "operations":
4320
- if (context.operations) {
4321
- const ops = context.operations;
4322
- for (const subOp of ops) {
4323
- for (const key of [
4324
- "updateOne",
4325
- "updateMany",
4326
- "deleteOne",
4327
- "deleteMany",
4328
- "replaceOne"
4329
- ]) {
4330
- const opBody = subOp[key];
4331
- if (opBody?.filter) opBody.filter = {
4332
- ...opBody.filter,
4333
- [tenantField]: castId
4334
- };
4335
- }
4336
- const insertBody = subOp.insertOne;
4337
- if (insertBody?.document) insertBody.document[tenantField] = castId;
4338
- }
4339
- }
4340
- break;
4341
- }
4342
- }, { priority: HOOK_PRIORITY$1.POLICY });
4343
- }
4344
- }
4345
- };
4346
- }
4347
- //#endregion
4348
- //#region src/plugins/observability.plugin.ts
4349
- /**
4350
- * Observability Plugin
4351
- *
4352
- * Adds operation timing, structured metrics, and APM hook points.
4353
- * Works with any monitoring system — just provide an onMetric callback.
4354
- *
4355
- * @example
4356
- * ```typescript
4357
- * const repo = new Repository(User, [
4358
- * observabilityPlugin({
4359
- * onMetric: (metric) => {
4360
- * // Send to DataDog, New Relic, OpenTelemetry, or console
4361
- * console.log(`${metric.operation} took ${metric.durationMs}ms`);
4362
- * statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
4363
- * },
4364
- * }),
4365
- * ]);
4366
- * ```
4367
- */
4368
- const DEFAULT_OPS = ALL_OPERATIONS;
4369
- const timers = /* @__PURE__ */ new WeakMap();
4370
- function observabilityPlugin(options) {
4371
- const { onMetric, slowThresholdMs } = options;
4372
- const ops = options.operations ?? DEFAULT_OPS;
4373
- return {
4374
- name: "observability",
4375
- apply(repo) {
4376
- for (const op of ops) {
4377
- repo.on(`before:${op}`, (context) => {
4378
- timers.set(context, performance.now());
4379
- }, { priority: 300 });
4380
- repo.on(`after:${op}`, ({ context }) => {
4381
- const start = timers.get(context);
4382
- if (start == null) return;
4383
- const durationMs = Math.round((performance.now() - start) * 100) / 100;
4384
- timers.delete(context);
4385
- if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
4386
- onMetric({
4387
- operation: op,
4388
- model: context.model || repo.model,
4389
- durationMs,
4390
- success: true,
4391
- startedAt: new Date(Date.now() - durationMs),
4392
- userId: context.user?._id?.toString() || context.user?.id?.toString(),
4393
- organizationId: context.organizationId?.toString()
4394
- });
4395
- });
4396
- repo.on(`error:${op}`, ({ context, error }) => {
4397
- const start = timers.get(context);
4398
- if (start == null) return;
4399
- const durationMs = Math.round((performance.now() - start) * 100) / 100;
4400
- timers.delete(context);
4401
- onMetric({
4402
- operation: op,
4403
- model: context.model || repo.model,
4404
- durationMs,
4405
- success: false,
4406
- error: error.message,
4407
- startedAt: new Date(Date.now() - durationMs),
4408
- userId: context.user?._id?.toString() || context.user?.id?.toString(),
4409
- organizationId: context.organizationId?.toString()
4410
- });
4411
- });
4412
- }
4413
- }
4414
- };
4415
- }
4416
- //#endregion
4417
- //#region src/plugins/soft-delete.plugin.ts
4418
- /**
4419
- * Build filter condition based on filter mode
4420
- */
4421
- function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
4422
- if (includeDeleted) return {};
4423
- if (filterMode === "exists") return { [deletedField]: { $exists: false } };
4424
- return { [deletedField]: null };
4425
- }
4426
- /**
4427
- * Build filter condition for finding deleted documents
4428
- */
4429
- function buildGetDeletedFilter(deletedField, filterMode) {
4430
- if (filterMode === "exists") return { [deletedField]: {
4431
- $exists: true,
4432
- $ne: null
4433
- } };
4434
- return { [deletedField]: { $ne: null } };
4435
- }
4436
- /**
4437
- * Soft delete plugin
4438
- *
4439
- * @example Basic usage
4440
- * ```typescript
4441
- * const repo = new Repository(Model, [
4442
- * softDeletePlugin({ deletedField: 'deletedAt' })
4443
- * ]);
4444
- *
4445
- * // Delete (soft)
4446
- * await repo.delete(id);
4447
- *
4448
- * // Restore
4449
- * await repo.restore(id);
4450
- *
4451
- * // Get deleted documents
4452
- * await repo.getDeleted({ page: 1, limit: 20 });
4453
- * ```
4454
- *
4455
- * @example With null filter mode (for schemas with default: null)
4456
- * ```typescript
4457
- * // Schema: { deletedAt: { type: Date, default: null } }
4458
- * const repo = new Repository(Model, [
4459
- * softDeletePlugin({
4460
- * deletedField: 'deletedAt',
4461
- * filterMode: 'null', // default - works with default: null
4462
- * })
4463
- * ]);
4464
- * ```
4465
- *
4466
- * @example With TTL for auto-cleanup
4467
- * ```typescript
4468
- * const repo = new Repository(Model, [
4469
- * softDeletePlugin({
4470
- * deletedField: 'deletedAt',
4471
- * ttlDays: 30, // Auto-delete after 30 days
4472
- * })
4473
- * ]);
4474
- * ```
4475
- */
4476
- function softDeletePlugin(options = {}) {
4477
- const deletedField = options.deletedField || "deletedAt";
4478
- const deletedByField = options.deletedByField || "deletedBy";
4479
- const filterMode = options.filterMode || "null";
4480
- const addRestoreMethod = options.addRestoreMethod !== false;
4481
- const addGetDeletedMethod = options.addGetDeletedMethod !== false;
4482
- const ttlDays = options.ttlDays;
4483
- return {
4484
- name: "softDelete",
4485
- apply(repo) {
4486
- try {
4487
- const schemaPaths = repo.Model.schema.paths;
4488
- for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
4489
- if (pathName === "_id" || pathName === deletedField) continue;
4490
- 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 } }`);
4491
- }
4492
- } catch (err) {
4493
- warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
4494
- }
4495
- if (ttlDays !== void 0 && ttlDays > 0) {
4496
- const ttlSeconds = ttlDays * 24 * 60 * 60;
4497
- repo.Model.collection.createIndex({ [deletedField]: 1 }, {
4498
- expireAfterSeconds: ttlSeconds,
4499
- partialFilterExpression: { [deletedField]: { $type: "date" } }
4500
- }).catch((err) => {
4501
- if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
4502
- });
4503
- }
4504
- repo.on("before:delete", async (context) => {
4505
- if (context.deleteMode === "hard") return;
4506
- if (options.soft !== false) {
4507
- const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
4508
- if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
4509
- const deleteQuery = {
4510
- [repo.idField || "_id"]: context.id,
4511
- ...context.query || {}
4512
- };
4513
- if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
4514
- const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
4515
- error.status = 404;
4516
- throw error;
4517
- }
4518
- context.softDeleted = true;
4519
- }
4520
- }, { priority: HOOK_PRIORITY$1.POLICY });
4521
- const injectDeleteFilterToFilters = (context) => {
4522
- if (options.soft !== false) {
4523
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
4524
- if (Object.keys(deleteFilter).length > 0) context.filters = {
4525
- ...context.filters || {},
4526
- ...deleteFilter
4527
- };
4528
- }
4529
- };
4530
- const injectDeleteFilterToQuery = (context) => {
4531
- if (options.soft !== false) {
4532
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
4533
- if (Object.keys(deleteFilter).length > 0) context.query = {
4534
- ...context.query || {},
4535
- ...deleteFilter
4536
- };
4537
- }
4538
- };
4539
- const SOFT_DELETE_SPECIAL = new Set([
4540
- "updateMany",
4541
- "deleteMany",
4542
- "bulkWrite"
4543
- ]);
4544
- for (const op of ALL_OPERATIONS) {
4545
- if (SOFT_DELETE_SPECIAL.has(op)) continue;
4546
- const desc = OP_REGISTRY[op];
4547
- const isFilterableMutation = op === "findOneAndUpdate";
4548
- if (desc.mutates && !isFilterableMutation) continue;
4549
- if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY$1.POLICY });
4550
- else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY$1.POLICY });
4551
- }
4552
- repo.on("before:updateMany", (context) => {
4553
- if (options.soft !== false) {
4554
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
4555
- if (Object.keys(deleteFilter).length > 0) context.query = {
4556
- ...context.query || {},
4557
- ...deleteFilter
4558
- };
4559
- }
4560
- }, { priority: HOOK_PRIORITY$1.POLICY });
4561
- repo.on("before:deleteMany", async (context) => {
4562
- if (context.deleteMode === "hard") return;
4563
- if (options.soft !== false) {
4564
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
4565
- const finalQuery = {
4566
- ...context.query || {},
4567
- ...deleteFilter
4568
- };
4569
- const updateRes = await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
4570
- context.softDeleted = true;
4571
- context.softDeletedCount = updateRes?.modifiedCount ?? 0;
4572
- }
4573
- }, { priority: HOOK_PRIORITY$1.POLICY });
4574
- if (addRestoreMethod) {
4575
- const restoreMethod = async function(id, restoreOptions = {}) {
4576
- const context = await this._buildContext("restore", {
4577
- id,
4578
- ...restoreOptions
4579
- });
4580
- const updateData = {
4581
- [deletedField]: null,
4582
- [deletedByField]: null
4583
- };
4584
- const restoreQuery = {
4585
- _id: id,
4586
- ...context.query || {}
4587
- };
4588
- const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
4589
- returnDocument: "after",
4590
- session: restoreOptions.session
4591
- });
4592
- if (!result) {
4593
- const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
4594
- error.status = 404;
4595
- throw error;
4596
- }
4597
- await this.emitAsync("after:restore", {
4598
- context,
4599
- result
4600
- });
4601
- return result;
4602
- };
4603
- if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
4604
- else repo.restore = restoreMethod.bind(repo);
4605
- }
4606
- if (addGetDeletedMethod) {
4607
- const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
4608
- const context = await this._buildContext("getDeleted", {
4609
- ...params,
4610
- ...getDeletedOptions
4611
- });
4612
- const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
4613
- const combinedFilters = {
4614
- ...params.filters || {},
4615
- ...deletedFilter,
4616
- ...context.filters || {},
4617
- ...context.query || {}
4618
- };
4619
- const page = params.page || 1;
4620
- const limit = params.limit || 20;
4621
- const skip = (page - 1) * limit;
4622
- let sortSpec = { [deletedField]: -1 };
4623
- if (params.sort) if (typeof params.sort === "string") {
4624
- const sortOrder = params.sort.startsWith("-") ? -1 : 1;
4625
- sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
4626
- } else sortSpec = params.sort;
4627
- let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
4628
- if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
4629
- if (getDeletedOptions.select) {
4630
- const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
4631
- query = query.select(selectValue);
4632
- }
4633
- if (getDeletedOptions.populate) {
4634
- const populateSpec = getDeletedOptions.populate;
4635
- if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
4636
- else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
4637
- else query = query.populate(populateSpec);
4638
- }
4639
- if (getDeletedOptions.lean !== false) query = query.lean();
4640
- const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
4641
- const pages = Math.ceil(total / limit);
4642
- return {
4643
- method: "offset",
4644
- docs,
4645
- page,
4646
- limit,
4647
- total,
4648
- pages,
4649
- hasNext: page < pages,
4650
- hasPrev: page > 1
4651
- };
4652
- };
4653
- if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
4654
- else repo.getDeleted = getDeletedMethod.bind(repo);
4655
- }
4656
- }
4657
- };
4658
- }
4659
- //#endregion
4660
- //#region src/plugins/subdocument.plugin.ts
4661
- /**
4662
- * Subdocument plugin for managing nested arrays
4663
- *
4664
- * @example
4665
- * const repo = new Repository(Model, [
4666
- * methodRegistryPlugin(),
4667
- * subdocumentPlugin(),
4668
- * ]);
4669
- *
4670
- * await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
4671
- * await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
4672
- */
4673
- function subdocumentPlugin() {
4674
- return {
4675
- name: "subdocument",
4676
- apply(repo) {
4677
- if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
4678
- /**
4679
- * Add subdocument to array
4680
- */
4681
- repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
4682
- return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
4683
- });
4684
- /**
4685
- * Get subdocument from array
4686
- */
4687
- repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
4688
- return this._executeQuery(async (Model) => {
4689
- const parent = await Model.findById(parentId).session(options.session).exec();
4690
- if (!parent) throw createError(404, "Parent not found");
4691
- const arrayField = parent[arrayPath];
4692
- if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
4693
- const sub = arrayField.id(subId);
4694
- if (!sub) throw createError(404, "Subdocument not found");
4695
- return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
4696
- });
4697
- });
4698
- /**
4699
- * Update subdocument in array
4700
- */
4701
- repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
4702
- return this._executeQuery(async (Model) => {
4703
- const query = {
4704
- _id: parentId,
4705
- [`${arrayPath}._id`]: subId
4706
- };
4707
- const update = { $set: { [`${arrayPath}.$`]: {
4708
- ...updateData,
4709
- _id: subId
4710
- } } };
4711
- const result = await Model.findOneAndUpdate(query, update, {
4712
- returnDocument: "after",
4713
- runValidators: true,
4714
- session: options.session
4715
- }).exec();
4716
- if (!result) throw createError(404, "Parent or subdocument not found");
4717
- return result;
4718
- });
4719
- });
4720
- /**
4721
- * Delete subdocument from array
4722
- */
4723
- repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
4724
- return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
4725
- });
4726
- }
4727
- };
4728
- }
4729
- //#endregion
4730
- //#region src/plugins/tenant-context.ts
4731
- /**
4732
- * Request-scoped tenant context via AsyncLocalStorage.
4733
- *
4734
- * The multi-tenant plugin already accepts a `resolveContext` callback for
4735
- * reading tenant ID from an external store. This helper wraps Node's
4736
- * `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
4737
- * to wire the plumbing by hand — a common source of cross-tenant leaks.
4738
- *
4739
- * @example
4740
- * import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
4741
- *
4742
- * const tenantContext = createTenantContext();
4743
- *
4744
- * // In your HTTP middleware (Express / Fastify / NestJS):
4745
- * app.use((req, _res, next) => {
4746
- * tenantContext.run({ tenantId: req.auth.organizationId }, next);
4747
- * });
4748
- *
4749
- * // In your repository definition:
4750
- * const repo = new Repository(Invoice, [
4751
- * multiTenantPlugin({
4752
- * tenantField: 'organizationId',
4753
- * resolveContext: () => tenantContext.getTenantId(),
4754
- * }),
4755
- * ]);
4756
- *
4757
- * // In your handler — no need to pass organizationId manually:
4758
- * await repo.getAll({ filters: { status: 'paid' } });
4759
- */
4760
- function createTenantContext() {
4761
- const storage = new AsyncLocalStorage();
4762
- return {
4763
- storage,
4764
- run(store, fn) {
4765
- return storage.run(store, fn);
4766
- },
4767
- getStore() {
4768
- return storage.getStore();
4769
- },
4770
- getTenantId() {
4771
- return storage.getStore()?.tenantId;
4772
- },
4773
- requireTenantId() {
4774
- const id = storage.getStore()?.tenantId;
4775
- if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
4776
- return id;
4777
- }
4778
- };
4779
- }
4780
- //#endregion
4781
- //#region src/plugins/timestamp.plugin.ts
4782
- /**
4783
- * Timestamp plugin that auto-injects timestamps
4784
- *
4785
- * @example
4786
- * const repo = new Repository(Model, [timestampPlugin()]);
4787
- */
4788
- function timestampPlugin() {
4789
- return {
4790
- name: "timestamp",
4791
- apply(repo) {
4792
- repo.on("before:create", (context) => {
4793
- if (!context.data) return;
4794
- const now = /* @__PURE__ */ new Date();
4795
- if (!context.data.createdAt) context.data.createdAt = now;
4796
- if (!context.data.updatedAt) context.data.updatedAt = now;
4797
- });
4798
- repo.on("before:update", (context) => {
4799
- if (!context.data) return;
4800
- context.data.updatedAt = /* @__PURE__ */ new Date();
4801
- });
4802
- repo.on("before:findOneAndUpdate", (context) => {
4803
- if (!context.data) return;
4804
- const now = /* @__PURE__ */ new Date();
4805
- if (Array.isArray(context.data)) return;
4806
- const update = context.data;
4807
- if (Object.keys(update).some((k) => k.startsWith("$"))) {
4808
- const set = update.$set || {};
4809
- set.updatedAt = now;
4810
- update.$set = set;
4811
- const setOnInsert = update.$setOnInsert || {};
4812
- if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
4813
- update.$setOnInsert = setOnInsert;
4814
- } else update.updatedAt = now;
4815
- });
4816
- }
4817
- };
4818
- }
4819
- //#endregion
4820
- //#region src/plugins/validation-chain.plugin.ts
4821
- /**
4822
- * Validation chain plugin
4823
- *
4824
- * @example
4825
- * const repo = new Repository(Model, [
4826
- * validationChainPlugin([
4827
- * requireField('email'),
4828
- * uniqueField('email', 'Email already exists'),
4829
- * blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
4830
- * ])
4831
- * ]);
4832
- */
4833
- function validationChainPlugin(validators = [], options = {}) {
4834
- const { stopOnFirstError = true } = options;
4835
- validators.forEach((v, idx) => {
4836
- if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
4837
- if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
4838
- });
4839
- const validatorsByOperation = {
4840
- create: [],
4841
- update: [],
4842
- findOneAndUpdate: [],
4843
- delete: [],
4844
- createMany: []
4845
- };
4846
- const allOperationsValidators = [];
4847
- validators.forEach((v) => {
4848
- if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
4849
- else v.operations.forEach((op) => {
4850
- if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
4851
- });
4852
- });
4853
- return {
4854
- name: "validation-chain",
4855
- apply(repo) {
4856
- const getValidatorsForOperation = (operation) => {
4857
- const specific = validatorsByOperation[operation] || [];
4858
- return [...allOperationsValidators, ...specific];
4859
- };
4860
- const runValidators = async (operation, context) => {
4861
- const operationValidators = getValidatorsForOperation(operation);
4862
- const errors = [];
4863
- for (const validator of operationValidators) try {
4864
- await validator.validate(context, repo);
4865
- } catch (error) {
4866
- if (stopOnFirstError) throw error;
4867
- errors.push({
4868
- validator: validator.name,
4869
- error: error.message || String(error)
4870
- });
4871
- }
4872
- if (errors.length > 0) {
4873
- const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
4874
- err.validationErrors = errors;
4875
- throw err;
4876
- }
4877
- };
4878
- repo.on("before:create", async (context) => runValidators("create", context));
4879
- repo.on("before:createMany", async (context) => runValidators("createMany", context));
4880
- repo.on("before:update", async (context) => runValidators("update", context));
4881
- repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
4882
- repo.on("before:delete", async (context) => runValidators("delete", context));
4883
- }
4884
- };
4885
- }
4886
- /**
4887
- * Block operation if condition is true
4888
- *
4889
- * @example
4890
- * blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
4891
- */
4892
- function blockIf(name, operations, condition, errorMessage) {
4893
- return {
4894
- name,
4895
- operations,
4896
- validate: (context) => {
4897
- if (condition(context)) throw createError(403, errorMessage);
4898
- }
4899
- };
4900
- }
4901
- /**
4902
- * Require a field to be present
4903
- */
4904
- function requireField(field, operations = ["create"]) {
4905
- return {
4906
- name: `require-${field}`,
4907
- operations,
4908
- validate: (context) => {
4909
- if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
4910
- }
4911
- };
4912
- }
4913
- /**
4914
- * Auto-inject a value if not present
4915
- */
4916
- function autoInject(field, getter, operations = ["create"]) {
4917
- return {
4918
- name: `auto-inject-${field}`,
4919
- operations,
4920
- validate: (context) => {
4921
- if (context.data && !(field in context.data)) {
4922
- const value = getter(context);
4923
- if (value !== null && value !== void 0) context.data[field] = value;
4924
- }
4925
- }
4926
- };
4927
- }
4928
- /**
4929
- * Make a field immutable (cannot be updated)
4930
- */
4931
- function immutableField(field) {
4932
- return {
4933
- name: `immutable-${field}`,
4934
- operations: ["update"],
4935
- validate: (context) => {
4936
- if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
4937
- }
4938
- };
4939
- }
4940
- /**
4941
- * Ensure field value is unique
4942
- */
4943
- function uniqueField(field, errorMessage) {
4944
- return {
4945
- name: `unique-${field}`,
4946
- operations: ["create", "update"],
4947
- validate: async (context, repo) => {
4948
- if (!context.data?.[field]) return;
4949
- if (!repo) {
4950
- warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
4951
- return;
4952
- }
4953
- const query = { [field]: context.data[field] };
4954
- const getByQuery = repo.getByQuery;
4955
- if (typeof getByQuery !== "function") {
4956
- warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
4957
- return;
4958
- }
4959
- const idKey = repo.idField || "_id";
4960
- const existing = await getByQuery.call(repo, query, {
4961
- select: idKey,
4962
- lean: true,
4963
- throwOnNotFound: false
4964
- });
4965
- if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
4966
- }
4967
- };
4968
- }
4969
- //#endregion
4970
- export { AuditTrailQuery as A, cachePlugin as C, withTransaction as D, isTransactionUnsupported as E, MUTATING_OPERATIONS as F, OP_REGISTRY as I, READ_OPERATIONS as L, auditLogPlugin as M, aggregateHelpersPlugin as N, AggregationBuilder as O, ALL_OPERATIONS as P, operationsByPolicyKey as R, cascadePlugin as S, Repository as T, customIdPlugin as _, uniqueField as a, prefixedId as b, createTenantContext as c, observabilityPlugin as d, multiTenantPlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, auditTrailPlugin as j, batchOperationsPlugin as k, subdocumentPlugin as l, methodRegistryPlugin as m, blockIf as n, validationChainPlugin as o, mongoOperationsPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, dateSequentialId as v, HOOK_PRIORITY$1 as w, sequentialId as x, getNextSequence as y };