@classytic/mongokit 3.2.2 → 3.2.3

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.
package/README.md CHANGED
@@ -12,12 +12,12 @@
12
12
  - **Zero dependencies** - Only Mongoose as peer dependency
13
13
  - **Explicit + smart pagination** - Explicit `mode` control or auto-detection; offset, keyset, and aggregate
14
14
  - **Event-driven** - Pre/post hooks for all operations (granular scalability hooks)
15
- - **16 built-in plugins** - Caching, soft delete, validation, multi-tenant, custom IDs, observability, Elasticsearch, and more
15
+ - **17 built-in plugins** - Caching, soft delete, audit trail, validation, multi-tenant, custom IDs, observability, Elasticsearch, and more
16
16
  - **Distributed cache safety** - List cache versions stored in the adapter (Redis) for multi-pod correctness
17
17
  - **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
18
18
  - **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
19
19
  - **TypeScript first** - Full type safety with discriminated unions
20
- - **604 passing tests** - Battle-tested and production-ready
20
+ - **592 passing tests** - Battle-tested and production-ready
21
21
 
22
22
  ## Installation
23
23
 
@@ -216,6 +216,7 @@ const repo = new Repository(UserModel, [
216
216
  | `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
217
217
  | `customIdPlugin(opts)` | Auto-generate sequential/random IDs with atomic counters |
218
218
  | `elasticSearchPlugin(opts)` | Delegate text/semantic search to Elasticsearch/OpenSearch |
219
+ | `auditTrailPlugin(opts)` | DB-persisted audit trail with change tracking and TTL |
219
220
  | `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
220
221
 
221
222
  ### Soft Delete
@@ -359,6 +360,126 @@ await repo.update(userId, { name: "New" }, { organizationId: "org_123" });
359
360
  // Cross-tenant update/delete is blocked — returns "not found"
360
361
  ```
361
362
 
363
+ ### Audit Trail (DB-Persisted)
364
+
365
+ The `auditTrailPlugin` persists operation audit entries to a shared MongoDB collection. Unlike `auditLogPlugin` (which logs to an external logger), this stores a queryable audit trail in the database with automatic TTL cleanup.
366
+
367
+ ```typescript
368
+ import {
369
+ Repository,
370
+ methodRegistryPlugin,
371
+ auditTrailPlugin,
372
+ } from "@classytic/mongokit";
373
+
374
+ const repo = new Repository(JobModel, [
375
+ methodRegistryPlugin(),
376
+ auditTrailPlugin({
377
+ operations: ["create", "update", "delete"], // Which ops to track
378
+ trackChanges: true, // Field-level before/after diff on updates
379
+ trackDocument: false, // Full doc snapshot on create (heavy)
380
+ ttlDays: 90, // Auto-purge after 90 days (MongoDB TTL index)
381
+ excludeFields: ["password", "token"], // Redact sensitive fields
382
+ metadata: (context) => ({
383
+ // Custom metadata per entry
384
+ ip: context.req?.ip,
385
+ userAgent: context.req?.headers?.["user-agent"],
386
+ }),
387
+ }),
388
+ ]);
389
+
390
+ // Query audit trail for a specific document (requires methodRegistryPlugin)
391
+ const trail = await repo.getAuditTrail(documentId, {
392
+ page: 1,
393
+ limit: 20,
394
+ operation: "update", // Optional filter
395
+ });
396
+ // → { docs, page, limit, total, pages, hasNext, hasPrev }
397
+ ```
398
+
399
+ **What gets stored:**
400
+
401
+ ```javascript
402
+ {
403
+ model: 'Job',
404
+ operation: 'update',
405
+ documentId: ObjectId('...'),
406
+ userId: ObjectId('...'),
407
+ orgId: ObjectId('...'),
408
+ changes: {
409
+ title: { from: 'Old Title', to: 'New Title' },
410
+ salary: { from: 50000, to: 65000 },
411
+ },
412
+ metadata: { ip: '192.168.1.1' },
413
+ timestamp: ISODate('2026-02-26T...'),
414
+ }
415
+ ```
416
+
417
+ **Standalone queries** (admin dashboards, audit APIs — no repo needed):
418
+
419
+ ```typescript
420
+ import { AuditTrailQuery } from "@classytic/mongokit";
421
+
422
+ const auditQuery = new AuditTrailQuery(); // 'audit_trails' collection
423
+
424
+ // All audits for an org
425
+ const orgAudits = await auditQuery.getOrgTrail(orgId);
426
+
427
+ // All actions by a user
428
+ const userAudits = await auditQuery.getUserTrail(userId);
429
+
430
+ // History of a specific document
431
+ const docHistory = await auditQuery.getDocumentTrail("Job", jobId);
432
+
433
+ // Custom query with date range
434
+ const recent = await auditQuery.query({
435
+ orgId,
436
+ operation: "delete",
437
+ from: new Date("2025-01-01"),
438
+ to: new Date(),
439
+ page: 1,
440
+ limit: 50,
441
+ });
442
+
443
+ // Direct model access for anything custom
444
+ const model = auditQuery.getModel();
445
+ const deleteCount = await model.countDocuments({ operation: "delete" });
446
+ ```
447
+
448
+ **Key design decisions:**
449
+
450
+ - **Fire & forget** — audit writes are async and never block or fail the main operation
451
+ - **Shared collection** — one `audit_trails` collection for all models (filtered by `model` field)
452
+ - **TTL index** — MongoDB auto-deletes old entries, no cron needed
453
+ - **Change diff** — compares before/after on updates, stores only changed fields
454
+
455
+ **Plugin options:**
456
+
457
+ | Option | Default | Description |
458
+ | --------------- | -------------------------------- | -------------------------------------- |
459
+ | `operations` | `['create', 'update', 'delete']` | Which operations to audit |
460
+ | `trackChanges` | `true` | Store before/after diff on updates |
461
+ | `trackDocument` | `false` | Store full document snapshot on create |
462
+ | `ttlDays` | `undefined` (keep forever) | Auto-purge after N days |
463
+ | `collectionName`| `'audit_trails'` | MongoDB collection name |
464
+ | `excludeFields` | `[]` | Fields to redact from diffs/snapshots |
465
+ | `metadata` | `undefined` | Callback to inject custom metadata |
466
+
467
+ **TypeScript type safety:**
468
+
469
+ ```typescript
470
+ import type { AuditTrailMethods } from "@classytic/mongokit";
471
+
472
+ type JobRepoWithAudit = JobRepo & AuditTrailMethods;
473
+
474
+ const repo = new JobRepo(JobModel, [
475
+ methodRegistryPlugin(),
476
+ auditTrailPlugin({ ttlDays: 90 }),
477
+ ]) as JobRepoWithAudit;
478
+
479
+ // Full autocomplete for getAuditTrail
480
+ const trail = await repo.getAuditTrail(jobId, { operation: "update" });
481
+ ```
482
+
362
483
  ### Observability
363
484
 
364
485
  ```javascript
@@ -715,7 +836,7 @@ await repo.restore(id);
715
836
  await repo.invalidateCache(id);
716
837
  ```
717
838
 
718
- **Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`
839
+ **Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`, `AuditTrailMethods`
719
840
 
720
841
  ## Event System
721
842
 
@@ -1126,7 +1247,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
1126
1247
  - Uses its own event system (not Mongoose middleware)
1127
1248
  - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
1128
1249
  - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
1129
- - All 604 tests pass on Mongoose 9
1250
+ - All 597 tests pass on Mongoose 9
1130
1251
 
1131
1252
  ## License
1132
1253
 
@@ -732,6 +732,152 @@ interface ObservabilityOptions {
732
732
  }
733
733
  declare function observabilityPlugin(options: ObservabilityOptions): Plugin;
734
734
  //#endregion
735
+ //#region src/plugins/audit-trail.plugin.d.ts
736
+ interface AuditTrailOptions {
737
+ /** Operations to track (default: ['create', 'update', 'delete']) */
738
+ operations?: AuditOperation[];
739
+ /** Store field-level before/after diff on updates (default: true) */
740
+ trackChanges?: boolean;
741
+ /** Store full document snapshot on create (default: false — can be heavy) */
742
+ trackDocument?: boolean;
743
+ /** Auto-purge after N days via MongoDB TTL index (default: undefined — keep forever) */
744
+ ttlDays?: number;
745
+ /** MongoDB collection name (default: 'audit_trails') */
746
+ collectionName?: string;
747
+ /**
748
+ * Extract custom metadata from the repository context.
749
+ * Returned object is stored on the audit entry as `metadata`.
750
+ */
751
+ metadata?: (context: RepositoryContext) => Record<string, unknown>;
752
+ /**
753
+ * Fields to exclude from change tracking (e.g., passwords, tokens).
754
+ * These fields are redacted in the `changes` diff.
755
+ */
756
+ excludeFields?: string[];
757
+ }
758
+ type AuditOperation = 'create' | 'update' | 'delete';
759
+ interface AuditEntry {
760
+ model: string;
761
+ operation: AuditOperation;
762
+ documentId: unknown;
763
+ userId?: unknown;
764
+ orgId?: unknown;
765
+ changes?: Record<string, {
766
+ from: unknown;
767
+ to: unknown;
768
+ }>;
769
+ document?: Record<string, unknown>;
770
+ metadata?: Record<string, unknown>;
771
+ timestamp: Date;
772
+ }
773
+ declare function auditTrailPlugin(options?: AuditTrailOptions): Plugin;
774
+ interface AuditQueryOptions {
775
+ model?: string;
776
+ documentId?: string | ObjectId;
777
+ userId?: string | ObjectId;
778
+ orgId?: string | ObjectId;
779
+ operation?: AuditOperation;
780
+ from?: Date;
781
+ to?: Date;
782
+ page?: number;
783
+ limit?: number;
784
+ }
785
+ interface AuditQueryResult {
786
+ docs: AuditEntry[];
787
+ page: number;
788
+ limit: number;
789
+ total: number;
790
+ pages: number;
791
+ hasNext: boolean;
792
+ hasPrev: boolean;
793
+ }
794
+ /**
795
+ * Standalone audit trail query utility.
796
+ * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
797
+ *
798
+ * @example
799
+ * ```typescript
800
+ * import { AuditTrailQuery } from '@classytic/mongokit';
801
+ *
802
+ * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
803
+ *
804
+ * // All audits for an org
805
+ * const orgAudits = await auditQuery.query({ orgId: '...' });
806
+ *
807
+ * // All updates by a user
808
+ * const userUpdates = await auditQuery.query({
809
+ * userId: '...',
810
+ * operation: 'update',
811
+ * });
812
+ *
813
+ * // All audits for a specific document
814
+ * const docHistory = await auditQuery.query({
815
+ * model: 'Job',
816
+ * documentId: '...',
817
+ * });
818
+ *
819
+ * // Date range
820
+ * const recent = await auditQuery.query({
821
+ * from: new Date('2025-01-01'),
822
+ * to: new Date(),
823
+ * page: 1,
824
+ * limit: 50,
825
+ * });
826
+ *
827
+ * // Direct model access for custom queries
828
+ * const model = auditQuery.getModel();
829
+ * const count = await model.countDocuments({ operation: 'delete' });
830
+ * ```
831
+ */
832
+ declare class AuditTrailQuery {
833
+ private model;
834
+ constructor(collectionName?: string, ttlDays?: number);
835
+ /**
836
+ * Get the underlying Mongoose model for custom queries
837
+ */
838
+ getModel(): mongoose.Model<AuditEntry>;
839
+ /**
840
+ * Query audit entries with filters and pagination
841
+ */
842
+ query(options?: AuditQueryOptions): Promise<AuditQueryResult>;
843
+ /**
844
+ * Get audit trail for a specific document
845
+ */
846
+ getDocumentTrail(model: string, documentId: string | ObjectId, options?: {
847
+ page?: number;
848
+ limit?: number;
849
+ operation?: AuditOperation;
850
+ }): Promise<AuditQueryResult>;
851
+ /**
852
+ * Get all audits for a user
853
+ */
854
+ getUserTrail(userId: string | ObjectId, options?: {
855
+ page?: number;
856
+ limit?: number;
857
+ operation?: AuditOperation;
858
+ orgId?: string | ObjectId;
859
+ }): Promise<AuditQueryResult>;
860
+ /**
861
+ * Get all audits for an organization
862
+ */
863
+ getOrgTrail(orgId: string | ObjectId, options?: {
864
+ page?: number;
865
+ limit?: number;
866
+ operation?: AuditOperation;
867
+ model?: string;
868
+ }): Promise<AuditQueryResult>;
869
+ }
870
+ interface AuditTrailMethods {
871
+ /**
872
+ * Get paginated audit trail for a document
873
+ */
874
+ getAuditTrail(documentId: string | ObjectId, options?: {
875
+ page?: number;
876
+ limit?: number;
877
+ operation?: AuditOperation;
878
+ }): Promise<AuditQueryResult>;
879
+ }
880
+ //#endregion
735
881
  //#region src/plugins/elastic.plugin.d.ts
736
882
  interface ElasticSearchOptions {
737
883
  /** Elasticsearch or OpenSearch client instance */
@@ -890,4 +1036,4 @@ declare function prefixedId(options: PrefixedIdOptions): IdGenerator;
890
1036
  */
891
1037
  declare function customIdPlugin(options: CustomIdOptions): Plugin;
892
1038
  //#endregion
893
- export { blockIf as A, timestampPlugin as B, AggregateHelpersMethods as C, MongoOperationsMethods as D, batchOperationsPlugin as E, MethodRegistryRepository as F, methodRegistryPlugin as I, SoftDeleteMethods as L, requireField as M, uniqueField as N, mongoOperationsPlugin as O, validationChainPlugin as P, softDeletePlugin as R, subdocumentPlugin as S, BatchOperationsMethods as T, fieldFilterPlugin as V, multiTenantPlugin as _, SequentialIdOptions as a, cachePlugin as b, getNextSequence as c, ElasticSearchOptions as d, elasticSearchPlugin as f, MultiTenantOptions as g, observabilityPlugin as h, PrefixedIdOptions as i, immutableField as j, autoInject as k, prefixedId as l, OperationMetric as m, DateSequentialIdOptions as n, customIdPlugin as o, ObservabilityOptions as p, IdGenerator as r, dateSequentialId as s, CustomIdOptions as t, sequentialId as u, cascadePlugin as v, aggregateHelpersPlugin as w, SubdocumentMethods as x, CacheMethods as y, auditLogPlugin as z };
1039
+ export { subdocumentPlugin as A, requireField as B, observabilityPlugin as C, CacheMethods as D, cascadePlugin as E, MongoOperationsMethods as F, SoftDeleteMethods as G, validationChainPlugin as H, mongoOperationsPlugin as I, timestampPlugin as J, softDeletePlugin as K, autoInject as L, aggregateHelpersPlugin as M, BatchOperationsMethods as N, cachePlugin as O, batchOperationsPlugin as P, blockIf as R, OperationMetric as S, multiTenantPlugin as T, MethodRegistryRepository as U, uniqueField as V, methodRegistryPlugin as W, fieldFilterPlugin as Y, AuditTrailMethods as _, SequentialIdOptions as a, auditTrailPlugin as b, getNextSequence as c, ElasticSearchOptions as d, elasticSearchPlugin as f, AuditQueryResult as g, AuditQueryOptions as h, PrefixedIdOptions as i, AggregateHelpersMethods as j, SubdocumentMethods as k, prefixedId as l, AuditOperation as m, DateSequentialIdOptions as n, customIdPlugin as o, AuditEntry as p, auditLogPlugin as q, IdGenerator as r, dateSequentialId as s, CustomIdOptions as t, sequentialId as u, AuditTrailOptions as v, MultiTenantOptions as w, ObservabilityOptions as x, AuditTrailQuery as y, immutableField as z };
@@ -1487,6 +1487,357 @@ function observabilityPlugin(options) {
1487
1487
  };
1488
1488
  }
1489
1489
 
1490
+ //#endregion
1491
+ //#region src/plugins/audit-trail.plugin.ts
1492
+ /**
1493
+ * Audit Trail Plugin
1494
+ *
1495
+ * Persists operation audit entries to a MongoDB collection.
1496
+ * Fire-and-forget: writes happen async and never block or fail the main operation.
1497
+ *
1498
+ * Features:
1499
+ * - Tracks create, update, delete operations
1500
+ * - Field-level change tracking (before/after diff on updates)
1501
+ * - TTL auto-cleanup via MongoDB TTL index
1502
+ * - Custom metadata per entry (IP, user-agent, etc.)
1503
+ * - Shared `audit_trails` collection across all models
1504
+ *
1505
+ * @example
1506
+ * ```typescript
1507
+ * const repo = new Repository(Job, [
1508
+ * auditTrailPlugin({
1509
+ * operations: ['create', 'update', 'delete'],
1510
+ * trackChanges: true,
1511
+ * ttlDays: 90,
1512
+ * metadata: (context) => ({
1513
+ * ip: context.req?.ip,
1514
+ * }),
1515
+ * }),
1516
+ * ]);
1517
+ * ```
1518
+ */
1519
+ const modelCache = /* @__PURE__ */ new Map();
1520
+ function getAuditModel(collectionName, ttlDays) {
1521
+ const existing = modelCache.get(collectionName);
1522
+ if (existing) return existing;
1523
+ const schema = new mongoose.Schema({
1524
+ model: {
1525
+ type: String,
1526
+ required: true,
1527
+ index: true
1528
+ },
1529
+ operation: {
1530
+ type: String,
1531
+ required: true,
1532
+ enum: [
1533
+ "create",
1534
+ "update",
1535
+ "delete"
1536
+ ]
1537
+ },
1538
+ documentId: {
1539
+ type: mongoose.Schema.Types.Mixed,
1540
+ required: true,
1541
+ index: true
1542
+ },
1543
+ userId: {
1544
+ type: mongoose.Schema.Types.Mixed,
1545
+ index: true
1546
+ },
1547
+ orgId: {
1548
+ type: mongoose.Schema.Types.Mixed,
1549
+ index: true
1550
+ },
1551
+ changes: { type: mongoose.Schema.Types.Mixed },
1552
+ document: { type: mongoose.Schema.Types.Mixed },
1553
+ metadata: { type: mongoose.Schema.Types.Mixed },
1554
+ timestamp: {
1555
+ type: Date,
1556
+ default: Date.now,
1557
+ index: true
1558
+ }
1559
+ }, {
1560
+ collection: collectionName,
1561
+ versionKey: false
1562
+ });
1563
+ schema.index({
1564
+ model: 1,
1565
+ documentId: 1,
1566
+ timestamp: -1
1567
+ });
1568
+ schema.index({
1569
+ orgId: 1,
1570
+ userId: 1,
1571
+ timestamp: -1
1572
+ });
1573
+ if (ttlDays !== void 0 && ttlDays > 0) {
1574
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
1575
+ schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
1576
+ }
1577
+ const modelName = `AuditTrail_${collectionName}`;
1578
+ const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
1579
+ modelCache.set(collectionName, model);
1580
+ return model;
1581
+ }
1582
+ /** Compute field-level diff between previous and updated document */
1583
+ function computeChanges(prev, next, excludeFields) {
1584
+ const changes = {};
1585
+ const exclude = new Set(excludeFields);
1586
+ for (const key of Object.keys(next)) {
1587
+ if (exclude.has(key)) continue;
1588
+ if (key === "_id" || key === "__v" || key === "updatedAt") continue;
1589
+ const prevVal = prev[key];
1590
+ const nextVal = next[key];
1591
+ if (!deepEqual(prevVal, nextVal)) changes[key] = {
1592
+ from: prevVal,
1593
+ to: nextVal
1594
+ };
1595
+ }
1596
+ return Object.keys(changes).length > 0 ? changes : void 0;
1597
+ }
1598
+ /** Simple deep equality check for audit diffing */
1599
+ function deepEqual(a, b) {
1600
+ if (a === b) return true;
1601
+ if (a == null && b == null) return true;
1602
+ if (a == null || b == null) return false;
1603
+ if (typeof a === "object" && typeof b === "object") {
1604
+ const aStr = a.toString?.();
1605
+ const bStr = b.toString?.();
1606
+ if (aStr && bStr && aStr === bStr) return true;
1607
+ }
1608
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
1609
+ try {
1610
+ return JSON.stringify(a) === JSON.stringify(b);
1611
+ } catch {
1612
+ return false;
1613
+ }
1614
+ }
1615
+ /** Extract user ID from context */
1616
+ function getUserId(context) {
1617
+ return context.user?._id || context.user?.id;
1618
+ }
1619
+ /** Fire-and-forget: write audit entry, never throw */
1620
+ function writeAudit(AuditModel, entry) {
1621
+ Promise.resolve().then(() => {
1622
+ AuditModel.create({
1623
+ ...entry,
1624
+ timestamp: /* @__PURE__ */ new Date()
1625
+ }).catch((err) => {
1626
+ warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
1627
+ });
1628
+ });
1629
+ }
1630
+ const snapshots = /* @__PURE__ */ new WeakMap();
1631
+ function auditTrailPlugin(options = {}) {
1632
+ const { operations = [
1633
+ "create",
1634
+ "update",
1635
+ "delete"
1636
+ ], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
1637
+ const opsSet = new Set(operations);
1638
+ return {
1639
+ name: "auditTrail",
1640
+ apply(repo) {
1641
+ const AuditModel = getAuditModel(collectionName, ttlDays);
1642
+ if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
1643
+ const doc = toPlainObject(result);
1644
+ writeAudit(AuditModel, {
1645
+ model: context.model || repo.model,
1646
+ operation: "create",
1647
+ documentId: doc?._id,
1648
+ userId: getUserId(context),
1649
+ orgId: context.organizationId,
1650
+ document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
1651
+ metadata: metadata?.(context)
1652
+ });
1653
+ });
1654
+ if (opsSet.has("update")) {
1655
+ if (trackChanges) repo.on("before:update", async (context) => {
1656
+ if (!context.id) return;
1657
+ try {
1658
+ const prev = await repo.Model.findById(context.id).lean();
1659
+ if (prev) snapshots.set(context, prev);
1660
+ } catch (err) {
1661
+ warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
1662
+ }
1663
+ });
1664
+ repo.on("after:update", ({ context, result }) => {
1665
+ const doc = result;
1666
+ let changes;
1667
+ if (trackChanges) {
1668
+ const prev = snapshots.get(context);
1669
+ if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
1670
+ snapshots.delete(context);
1671
+ }
1672
+ writeAudit(AuditModel, {
1673
+ model: context.model || repo.model,
1674
+ operation: "update",
1675
+ documentId: context.id || doc?._id,
1676
+ userId: getUserId(context),
1677
+ orgId: context.organizationId,
1678
+ changes,
1679
+ metadata: metadata?.(context)
1680
+ });
1681
+ });
1682
+ }
1683
+ if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
1684
+ writeAudit(AuditModel, {
1685
+ model: context.model || repo.model,
1686
+ operation: "delete",
1687
+ documentId: context.id,
1688
+ userId: getUserId(context),
1689
+ orgId: context.organizationId,
1690
+ metadata: metadata?.(context)
1691
+ });
1692
+ });
1693
+ if (typeof repo.registerMethod === "function")
1694
+ /**
1695
+ * Get audit trail for a specific document
1696
+ */
1697
+ repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
1698
+ const { page = 1, limit = 20, operation } = queryOptions;
1699
+ const skip = (page - 1) * limit;
1700
+ const filter = {
1701
+ model: this.model,
1702
+ documentId
1703
+ };
1704
+ if (operation) filter.operation = operation;
1705
+ const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
1706
+ return {
1707
+ docs,
1708
+ page,
1709
+ limit,
1710
+ total,
1711
+ pages: Math.ceil(total / limit),
1712
+ hasNext: page < Math.ceil(total / limit),
1713
+ hasPrev: page > 1
1714
+ };
1715
+ });
1716
+ }
1717
+ };
1718
+ }
1719
+ /** Convert Mongoose document to plain object */
1720
+ function toPlainObject(doc) {
1721
+ if (!doc) return {};
1722
+ if (typeof doc.toObject === "function") return doc.toObject();
1723
+ return doc;
1724
+ }
1725
+ /** Remove excluded fields from a document snapshot */
1726
+ function sanitizeDoc(doc, excludeFields) {
1727
+ if (excludeFields.length === 0) return doc;
1728
+ const result = { ...doc };
1729
+ for (const field of excludeFields) delete result[field];
1730
+ return result;
1731
+ }
1732
+ /**
1733
+ * Standalone audit trail query utility.
1734
+ * Use this to query audits across all models — e.g., admin dashboards, audit APIs.
1735
+ *
1736
+ * @example
1737
+ * ```typescript
1738
+ * import { AuditTrailQuery } from '@classytic/mongokit';
1739
+ *
1740
+ * const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
1741
+ *
1742
+ * // All audits for an org
1743
+ * const orgAudits = await auditQuery.query({ orgId: '...' });
1744
+ *
1745
+ * // All updates by a user
1746
+ * const userUpdates = await auditQuery.query({
1747
+ * userId: '...',
1748
+ * operation: 'update',
1749
+ * });
1750
+ *
1751
+ * // All audits for a specific document
1752
+ * const docHistory = await auditQuery.query({
1753
+ * model: 'Job',
1754
+ * documentId: '...',
1755
+ * });
1756
+ *
1757
+ * // Date range
1758
+ * const recent = await auditQuery.query({
1759
+ * from: new Date('2025-01-01'),
1760
+ * to: new Date(),
1761
+ * page: 1,
1762
+ * limit: 50,
1763
+ * });
1764
+ *
1765
+ * // Direct model access for custom queries
1766
+ * const model = auditQuery.getModel();
1767
+ * const count = await model.countDocuments({ operation: 'delete' });
1768
+ * ```
1769
+ */
1770
+ var AuditTrailQuery = class {
1771
+ model;
1772
+ constructor(collectionName = "audit_trails", ttlDays) {
1773
+ this.model = getAuditModel(collectionName, ttlDays);
1774
+ }
1775
+ /**
1776
+ * Get the underlying Mongoose model for custom queries
1777
+ */
1778
+ getModel() {
1779
+ return this.model;
1780
+ }
1781
+ /**
1782
+ * Query audit entries with filters and pagination
1783
+ */
1784
+ async query(options = {}) {
1785
+ const { page = 1, limit = 20 } = options;
1786
+ const skip = (page - 1) * limit;
1787
+ const filter = {};
1788
+ if (options.model) filter.model = options.model;
1789
+ if (options.documentId) filter.documentId = options.documentId;
1790
+ if (options.userId) filter.userId = options.userId;
1791
+ if (options.orgId) filter.orgId = options.orgId;
1792
+ if (options.operation) filter.operation = options.operation;
1793
+ if (options.from || options.to) {
1794
+ const dateFilter = {};
1795
+ if (options.from) dateFilter.$gte = options.from;
1796
+ if (options.to) dateFilter.$lte = options.to;
1797
+ filter.timestamp = dateFilter;
1798
+ }
1799
+ const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
1800
+ const pages = Math.ceil(total / limit);
1801
+ return {
1802
+ docs,
1803
+ page,
1804
+ limit,
1805
+ total,
1806
+ pages,
1807
+ hasNext: page < pages,
1808
+ hasPrev: page > 1
1809
+ };
1810
+ }
1811
+ /**
1812
+ * Get audit trail for a specific document
1813
+ */
1814
+ async getDocumentTrail(model, documentId, options = {}) {
1815
+ return this.query({
1816
+ model,
1817
+ documentId,
1818
+ ...options
1819
+ });
1820
+ }
1821
+ /**
1822
+ * Get all audits for a user
1823
+ */
1824
+ async getUserTrail(userId, options = {}) {
1825
+ return this.query({
1826
+ userId,
1827
+ ...options
1828
+ });
1829
+ }
1830
+ /**
1831
+ * Get all audits for an organization
1832
+ */
1833
+ async getOrgTrail(orgId, options = {}) {
1834
+ return this.query({
1835
+ orgId,
1836
+ ...options
1837
+ });
1838
+ }
1839
+ };
1840
+
1490
1841
  //#endregion
1491
1842
  //#region src/plugins/elastic.plugin.ts
1492
1843
  function elasticSearchPlugin(options) {
@@ -1815,4 +2166,4 @@ function customIdPlugin(options) {
1815
2166
  }
1816
2167
 
1817
2168
  //#endregion
1818
- export { auditLogPlugin as C, softDeletePlugin as S, fieldFilterPlugin as T, immutableField as _, sequentialId as a, validationChainPlugin as b, multiTenantPlugin as c, subdocumentPlugin as d, aggregateHelpersPlugin as f, blockIf as g, autoInject as h, prefixedId as i, cascadePlugin as l, mongoOperationsPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, batchOperationsPlugin as p, getNextSequence as r, observabilityPlugin as s, customIdPlugin as t, cachePlugin as u, requireField as v, timestampPlugin as w, methodRegistryPlugin as x, uniqueField as y };
2169
+ export { methodRegistryPlugin as C, fieldFilterPlugin as D, timestampPlugin as E, validationChainPlugin as S, auditLogPlugin as T, autoInject as _, sequentialId as a, requireField as b, auditTrailPlugin as c, cascadePlugin as d, cachePlugin as f, mongoOperationsPlugin as g, batchOperationsPlugin as h, prefixedId as i, observabilityPlugin as l, aggregateHelpersPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, subdocumentPlugin as p, getNextSequence as r, AuditTrailQuery as s, customIdPlugin as t, multiTenantPlugin as u, blockIf as v, softDeletePlugin as w, uniqueField as x, immutableField as y };
package/dist/index.d.mts CHANGED
@@ -2,7 +2,7 @@ import { $ as SchemaBuilderOptions, A as KeysetPaginationOptions, B as Paginatio
2
2
  import "./aggregate-CCHI7F51.mjs";
3
3
  import { t as index_d_exports } from "./actions/index.mjs";
4
4
  import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
5
- import { A as blockIf, B as timestampPlugin, C as AggregateHelpersMethods, D as MongoOperationsMethods, E as batchOperationsPlugin, I as methodRegistryPlugin, L as SoftDeleteMethods, M as requireField, N as uniqueField, O as mongoOperationsPlugin, P as validationChainPlugin, R as softDeletePlugin, S as subdocumentPlugin, T as BatchOperationsMethods, V as fieldFilterPlugin, _ as multiTenantPlugin, a as SequentialIdOptions, b as cachePlugin, c as getNextSequence, d as ElasticSearchOptions, f as elasticSearchPlugin, g as MultiTenantOptions, h as observabilityPlugin, i as PrefixedIdOptions, j as immutableField, k as autoInject, l as prefixedId, m as OperationMetric, n as DateSequentialIdOptions, o as customIdPlugin, p as ObservabilityOptions, r as IdGenerator, s as dateSequentialId, t as CustomIdOptions, u as sequentialId, v as cascadePlugin, w as aggregateHelpersPlugin, x as SubdocumentMethods, y as CacheMethods, z as auditLogPlugin } from "./custom-id.plugin-BzZI4gnE.mjs";
5
+ import { A as subdocumentPlugin, B as requireField, C as observabilityPlugin, D as CacheMethods, E as cascadePlugin, F as MongoOperationsMethods, G as SoftDeleteMethods, H as validationChainPlugin, I as mongoOperationsPlugin, J as timestampPlugin, K as softDeletePlugin, L as autoInject, M as aggregateHelpersPlugin, N as BatchOperationsMethods, O as cachePlugin, P as batchOperationsPlugin, R as blockIf, S as OperationMetric, T as multiTenantPlugin, V as uniqueField, W as methodRegistryPlugin, Y as fieldFilterPlugin, _ as AuditTrailMethods, a as SequentialIdOptions, b as auditTrailPlugin, c as getNextSequence, d as ElasticSearchOptions, f as elasticSearchPlugin, g as AuditQueryResult, h as AuditQueryOptions, i as PrefixedIdOptions, j as AggregateHelpersMethods, k as SubdocumentMethods, l as prefixedId, m as AuditOperation, n as DateSequentialIdOptions, o as customIdPlugin, p as AuditEntry, q as auditLogPlugin, r as IdGenerator, s as dateSequentialId, t as CustomIdOptions, u as sequentialId, v as AuditTrailOptions, w as MultiTenantOptions, x as ObservabilityOptions, y as AuditTrailQuery, z as immutableField } from "./custom-id.plugin-BmK0SjR9.mjs";
6
6
  import { a as isFieldUpdateAllowed, c as configureLogger, d as filterResponseData, f as getFieldsForUser, i as getSystemManagedFields, l as createError, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, p as getMongooseProjection, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel, u as createFieldPreset } from "./mongooseToJsonSchema-Wbvjfwkn.mjs";
7
7
  import * as mongoose$1 from "mongoose";
8
8
  import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
@@ -841,6 +841,15 @@ interface QueryParserOptions {
841
841
  allowedFilterFields?: string[];
842
842
  /** Allowed fields for sorting. If set, ignores unknown fields. */
843
843
  allowedSortFields?: string[];
844
+ /**
845
+ * Whitelist of allowed filter operators.
846
+ * When set, only these operators can be used in filters.
847
+ * When undefined, all built-in operators are allowed.
848
+ * Values are human-readable keys: 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin',
849
+ * 'like', 'contains', 'regex', 'exists', 'size', 'type'
850
+ * @example ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
851
+ */
852
+ allowedOperators?: string[];
844
853
  }
845
854
  /**
846
855
  * Modern Query Parser
@@ -872,6 +881,34 @@ declare class QueryParser {
872
881
  * ```
873
882
  */
874
883
  parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
884
+ /**
885
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
886
+ * Arc's defineResource() auto-detects this method and uses it
887
+ * to document list endpoint query parameters in OpenAPI/Swagger.
888
+ *
889
+ * The schema respects parser configuration:
890
+ * - `allowedOperators`: only documents allowed operators
891
+ * - `allowedFilterFields`: generates explicit field[op] entries
892
+ * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
893
+ * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
894
+ */
895
+ getQuerySchema(): {
896
+ type: "object";
897
+ properties: Record<string, unknown>;
898
+ required?: string[];
899
+ };
900
+ /**
901
+ * Get the JSON Schema type for a filter operator
902
+ */
903
+ private _getOperatorSchemaType;
904
+ /**
905
+ * Get a human-readable description for a filter operator
906
+ */
907
+ private _getOperatorDescription;
908
+ /**
909
+ * Build a summary description of all available filter operators
910
+ */
911
+ private _buildOperatorDescription;
875
912
  /**
876
913
  * Parse lookup configurations from URL parameters
877
914
  *
@@ -1009,4 +1046,4 @@ declare class QueryParser {
1009
1046
  */
1010
1047
  declare function createRepository<TDoc>(Model: mongoose$1.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
1011
1048
  //#endregion
1012
- export { type AggregateHelpersMethods, type AggregatePaginationOptions, type AggregatePaginationResult, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type BatchOperationsMethods, type CacheAdapter, type CacheMethods, type CacheOperationOptions, type CacheOptions, type CacheStats, type CascadeOptions, type CascadeRelation, type CreateInput, type CreateOptions, type CrudSchemas, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldRules, type FilterQuery, type GroupResult, type HookMode, type HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type JsonSchema, type KeysOfType, type KeysetPaginationOptions, type KeysetPaginationResult, type Logger, LookupBuilder, type LookupOptions, type MinMaxResult, type MongoOperationsMethods, type MultiTenantOptions, type NonNullableFields, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OffsetPaginationResult, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type PaginationResult, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, QueryParser, type QueryParserOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaBuilderOptions, type SearchMode, type SelectSpec, type SequentialIdOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type UpdateInput, type UpdateManyResult, type UpdateOptions, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidationResult, type ValidatorDefinition, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
1049
+ export { type AggregateHelpersMethods, type AggregatePaginationOptions, type AggregatePaginationResult, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BatchOperationsMethods, type CacheAdapter, type CacheMethods, type CacheOperationOptions, type CacheOptions, type CacheStats, type CascadeOptions, type CascadeRelation, type CreateInput, type CreateOptions, type CrudSchemas, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldRules, type FilterQuery, type GroupResult, type HookMode, type HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type JsonSchema, type KeysOfType, type KeysetPaginationOptions, type KeysetPaginationResult, type Logger, LookupBuilder, type LookupOptions, type MinMaxResult, type MongoOperationsMethods, type MultiTenantOptions, type NonNullableFields, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OffsetPaginationResult, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type PaginationResult, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, QueryParser, type QueryParserOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaBuilderOptions, type SearchMode, type SelectSpec, type SequentialIdOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type UpdateInput, type UpdateManyResult, type UpdateOptions, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidationResult, type ValidatorDefinition, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { n as createMany, t as create } from "./create-BuO6xt0v.mjs";
3
3
  import { a as deleteById, d as getById, f as getByQuery, i as LookupBuilder, l as count, p as getOrCreate, r as distinct, s as update, t as aggregate, u as exists } from "./aggregate-BAi4Do-X.mjs";
4
4
  import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
5
5
  import { c as filterResponseData, l as getFieldsForUser, s as createFieldPreset, u as getMongooseProjection } from "./cache-keys-C8Z9B5sw.mjs";
6
- import { C as auditLogPlugin, S as softDeletePlugin, T as fieldFilterPlugin, _ as immutableField, a as sequentialId, b as validationChainPlugin, c as multiTenantPlugin, d as subdocumentPlugin, f as aggregateHelpersPlugin, g as blockIf, h as autoInject, i as prefixedId, l as cascadePlugin, m as mongoOperationsPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as batchOperationsPlugin, r as getNextSequence, s as observabilityPlugin, t as customIdPlugin, u as cachePlugin, v as requireField, w as timestampPlugin, x as methodRegistryPlugin, y as uniqueField } from "./custom-id.plugin-B_zIs6gE.mjs";
6
+ import { C as methodRegistryPlugin, D as fieldFilterPlugin, E as timestampPlugin, S as validationChainPlugin, T as auditLogPlugin, _ as autoInject, a as sequentialId, b as requireField, c as auditTrailPlugin, d as cascadePlugin, f as cachePlugin, g as mongoOperationsPlugin, h as batchOperationsPlugin, i as prefixedId, l as observabilityPlugin, m as aggregateHelpersPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as subdocumentPlugin, r as getNextSequence, s as AuditTrailQuery, t as customIdPlugin, u as multiTenantPlugin, v as blockIf, w as softDeletePlugin, x as uniqueField, y as immutableField } from "./custom-id.plugin-m0VW6yYm.mjs";
7
7
  import { a as isFieldUpdateAllowed, i as getSystemManagedFields, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-COdDEkIJ.mjs";
8
8
  import { t as actions_exports } from "./actions/index.mjs";
9
9
  import mongoose from "mongoose";
@@ -1266,7 +1266,8 @@ var QueryParser = class {
1266
1266
  searchFields: options.searchFields,
1267
1267
  allowedLookupCollections: options.allowedLookupCollections,
1268
1268
  allowedFilterFields: options.allowedFilterFields,
1269
- allowedSortFields: options.allowedSortFields
1269
+ allowedSortFields: options.allowedSortFields,
1270
+ allowedOperators: options.allowedOperators
1270
1271
  };
1271
1272
  if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1272
1273
  warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
@@ -1336,6 +1337,143 @@ var QueryParser = class {
1336
1337
  return parsed;
1337
1338
  }
1338
1339
  /**
1340
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
1341
+ * Arc's defineResource() auto-detects this method and uses it
1342
+ * to document list endpoint query parameters in OpenAPI/Swagger.
1343
+ *
1344
+ * The schema respects parser configuration:
1345
+ * - `allowedOperators`: only documents allowed operators
1346
+ * - `allowedFilterFields`: generates explicit field[op] entries
1347
+ * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
1348
+ * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
1349
+ */
1350
+ getQuerySchema() {
1351
+ const properties = {
1352
+ page: {
1353
+ type: "integer",
1354
+ description: "Page number for offset pagination",
1355
+ default: 1,
1356
+ minimum: 1
1357
+ },
1358
+ limit: {
1359
+ type: "integer",
1360
+ description: "Number of items per page",
1361
+ default: 20,
1362
+ minimum: 1,
1363
+ maximum: this.options.maxLimit
1364
+ },
1365
+ sort: {
1366
+ type: "string",
1367
+ description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
1368
+ },
1369
+ search: {
1370
+ type: "string",
1371
+ description: this.options.searchMode === "regex" ? `Search across fields${this.options.searchFields ? ` (${this.options.searchFields.join(", ")})` : ""} using case-insensitive regex` : "Full-text search query (requires text index)",
1372
+ maxLength: this.options.maxSearchLength
1373
+ },
1374
+ select: {
1375
+ type: "string",
1376
+ description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
1377
+ },
1378
+ populate: {
1379
+ type: "string",
1380
+ description: "Fields to populate/join (comma-separated). Example: author,category"
1381
+ },
1382
+ after: {
1383
+ type: "string",
1384
+ description: "Cursor value for keyset pagination"
1385
+ }
1386
+ };
1387
+ if (this.options.enableLookups) properties["lookup"] = {
1388
+ type: "object",
1389
+ description: "Custom field lookups ($lookup). Example: lookup[department]=slug or lookup[department][localField]=deptId&lookup[department][foreignField]=_id"
1390
+ };
1391
+ if (this.options.enableAggregations) properties["aggregate"] = {
1392
+ type: "object",
1393
+ description: "Aggregation pipeline stages. Supports: group, match, sort, project. Example: aggregate[group][_id]=$status"
1394
+ };
1395
+ const availableOperators = this.options.allowedOperators ? Object.entries(this.operators).filter(([key]) => this.options.allowedOperators.includes(key)) : Object.entries(this.operators);
1396
+ if (this.options.allowedFilterFields && this.options.allowedFilterFields.length > 0) for (const field of this.options.allowedFilterFields) {
1397
+ properties[field] = {
1398
+ type: "string",
1399
+ description: `Filter by ${field} (exact match)`
1400
+ };
1401
+ for (const [op, mongoOp] of availableOperators) {
1402
+ if (op === "eq") continue;
1403
+ properties[`${field}[${op}]`] = {
1404
+ type: this._getOperatorSchemaType(op),
1405
+ description: this._getOperatorDescription(op, field, mongoOp)
1406
+ };
1407
+ }
1408
+ }
1409
+ properties["_filterOperators"] = {
1410
+ type: "string",
1411
+ description: this._buildOperatorDescription(availableOperators),
1412
+ "x-internal": true
1413
+ };
1414
+ return {
1415
+ type: "object",
1416
+ properties
1417
+ };
1418
+ }
1419
+ /**
1420
+ * Get the JSON Schema type for a filter operator
1421
+ */
1422
+ _getOperatorSchemaType(op) {
1423
+ if ([
1424
+ "gt",
1425
+ "gte",
1426
+ "lt",
1427
+ "lte",
1428
+ "size"
1429
+ ].includes(op)) return "number";
1430
+ if (["exists"].includes(op)) return "boolean";
1431
+ return "string";
1432
+ }
1433
+ /**
1434
+ * Get a human-readable description for a filter operator
1435
+ */
1436
+ _getOperatorDescription(op, field, mongoOp) {
1437
+ return {
1438
+ ne: `${field} not equal to value (${mongoOp})`,
1439
+ gt: `${field} greater than value (${mongoOp})`,
1440
+ gte: `${field} greater than or equal to value (${mongoOp})`,
1441
+ lt: `${field} less than value (${mongoOp})`,
1442
+ lte: `${field} less than or equal to value (${mongoOp})`,
1443
+ in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
1444
+ nin: `${field} not in comma-separated list (${mongoOp})`,
1445
+ like: `${field} matches pattern (case-insensitive regex)`,
1446
+ contains: `${field} contains substring (case-insensitive regex)`,
1447
+ regex: `${field} matches regex pattern (${mongoOp})`,
1448
+ exists: `Field ${field} exists (true/false)`,
1449
+ size: `Array field ${field} has exactly N elements (${mongoOp})`,
1450
+ type: `Field ${field} is of BSON type (${mongoOp})`
1451
+ }[op] || `Filter ${field} with ${mongoOp}`;
1452
+ }
1453
+ /**
1454
+ * Build a summary description of all available filter operators
1455
+ */
1456
+ _buildOperatorDescription(operators) {
1457
+ const lines = ["Available filter operators (use as field[operator]=value):"];
1458
+ for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${{
1459
+ eq: "Equal (default when no operator specified)",
1460
+ ne: "Not equal",
1461
+ gt: "Greater than",
1462
+ gte: "Greater than or equal",
1463
+ lt: "Less than",
1464
+ lte: "Less than or equal",
1465
+ in: "In list (comma-separated values)",
1466
+ nin: "Not in list",
1467
+ like: "Pattern match (case-insensitive)",
1468
+ contains: "Contains substring (case-insensitive)",
1469
+ regex: "Regex pattern",
1470
+ exists: "Field exists (true/false)",
1471
+ size: "Array size equals",
1472
+ type: "BSON type check"
1473
+ }[op] || op}`);
1474
+ return lines.join("\n");
1475
+ }
1476
+ /**
1339
1477
  * Parse lookup configurations from URL parameters
1340
1478
  *
1341
1479
  * Supported formats:
@@ -1608,6 +1746,10 @@ var QueryParser = class {
1608
1746
  _handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
1609
1747
  const [, field, operator] = operatorMatch;
1610
1748
  if (value === "" || value === null || value === void 0) return;
1749
+ if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator.toLowerCase())) {
1750
+ warn(`[mongokit] Operator not in allowlist: ${operator}`);
1751
+ return;
1752
+ }
1611
1753
  if (operator.toLowerCase() === "options" && regexFields[field]) {
1612
1754
  const fieldValue = filters[field];
1613
1755
  if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
@@ -1667,6 +1809,10 @@ var QueryParser = class {
1667
1809
  parsedFilters[field].between = value;
1668
1810
  continue;
1669
1811
  }
1812
+ if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator)) {
1813
+ warn(`[mongokit] Operator not in allowlist: ${operator}`);
1814
+ continue;
1815
+ }
1670
1816
  if (this.operators[operator]) {
1671
1817
  const mongoOperator = this.operators[operator];
1672
1818
  let processedValue;
@@ -1903,4 +2049,4 @@ function createRepository(Model, plugins = [], paginationConfig = {}, options =
1903
2049
  var src_default = Repository;
1904
2050
 
1905
2051
  //#endregion
1906
- export { AggregationBuilder, LookupBuilder, PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
2052
+ export { AggregationBuilder, AuditTrailQuery, LookupBuilder, PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
@@ -1,3 +1,3 @@
1
1
  import "../types-D-gploPr.mjs";
2
- import { A as blockIf, B as timestampPlugin, C as AggregateHelpersMethods, D as MongoOperationsMethods, E as batchOperationsPlugin, F as MethodRegistryRepository, I as methodRegistryPlugin, L as SoftDeleteMethods, M as requireField, N as uniqueField, O as mongoOperationsPlugin, P as validationChainPlugin, R as softDeletePlugin, S as subdocumentPlugin, T as BatchOperationsMethods, V as fieldFilterPlugin, _ as multiTenantPlugin, a as SequentialIdOptions, b as cachePlugin, c as getNextSequence, d as ElasticSearchOptions, f as elasticSearchPlugin, g as MultiTenantOptions, h as observabilityPlugin, i as PrefixedIdOptions, j as immutableField, k as autoInject, l as prefixedId, m as OperationMetric, n as DateSequentialIdOptions, o as customIdPlugin, p as ObservabilityOptions, r as IdGenerator, s as dateSequentialId, t as CustomIdOptions, u as sequentialId, v as cascadePlugin, w as aggregateHelpersPlugin, x as SubdocumentMethods, y as CacheMethods, z as auditLogPlugin } from "../custom-id.plugin-BzZI4gnE.mjs";
3
- export { type AggregateHelpersMethods, type BatchOperationsMethods, type CacheMethods, type CustomIdOptions, type DateSequentialIdOptions, type ElasticSearchOptions, type IdGenerator, type MethodRegistryRepository, type MongoOperationsMethods, type MultiTenantOptions, type ObservabilityOptions, type OperationMetric, type PrefixedIdOptions, type SequentialIdOptions, type SoftDeleteMethods, type SubdocumentMethods, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
2
+ import { A as subdocumentPlugin, B as requireField, C as observabilityPlugin, D as CacheMethods, E as cascadePlugin, F as MongoOperationsMethods, G as SoftDeleteMethods, H as validationChainPlugin, I as mongoOperationsPlugin, J as timestampPlugin, K as softDeletePlugin, L as autoInject, M as aggregateHelpersPlugin, N as BatchOperationsMethods, O as cachePlugin, P as batchOperationsPlugin, R as blockIf, S as OperationMetric, T as multiTenantPlugin, U as MethodRegistryRepository, V as uniqueField, W as methodRegistryPlugin, Y as fieldFilterPlugin, _ as AuditTrailMethods, a as SequentialIdOptions, b as auditTrailPlugin, c as getNextSequence, d as ElasticSearchOptions, f as elasticSearchPlugin, g as AuditQueryResult, h as AuditQueryOptions, i as PrefixedIdOptions, j as AggregateHelpersMethods, k as SubdocumentMethods, l as prefixedId, m as AuditOperation, n as DateSequentialIdOptions, o as customIdPlugin, p as AuditEntry, q as auditLogPlugin, r as IdGenerator, s as dateSequentialId, t as CustomIdOptions, u as sequentialId, v as AuditTrailOptions, w as MultiTenantOptions, x as ObservabilityOptions, y as AuditTrailQuery, z as immutableField } from "../custom-id.plugin-BmK0SjR9.mjs";
3
+ export { type AggregateHelpersMethods, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BatchOperationsMethods, type CacheMethods, type CustomIdOptions, type DateSequentialIdOptions, type ElasticSearchOptions, type IdGenerator, type MethodRegistryRepository, type MongoOperationsMethods, type MultiTenantOptions, type ObservabilityOptions, type OperationMetric, type PrefixedIdOptions, type SequentialIdOptions, type SoftDeleteMethods, type SubdocumentMethods, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
@@ -1,3 +1,3 @@
1
- import { C as auditLogPlugin, S as softDeletePlugin, T as fieldFilterPlugin, _ as immutableField, a as sequentialId, b as validationChainPlugin, c as multiTenantPlugin, d as subdocumentPlugin, f as aggregateHelpersPlugin, g as blockIf, h as autoInject, i as prefixedId, l as cascadePlugin, m as mongoOperationsPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as batchOperationsPlugin, r as getNextSequence, s as observabilityPlugin, t as customIdPlugin, u as cachePlugin, v as requireField, w as timestampPlugin, x as methodRegistryPlugin, y as uniqueField } from "../custom-id.plugin-B_zIs6gE.mjs";
1
+ import { C as methodRegistryPlugin, D as fieldFilterPlugin, E as timestampPlugin, S as validationChainPlugin, T as auditLogPlugin, _ as autoInject, a as sequentialId, b as requireField, c as auditTrailPlugin, d as cascadePlugin, f as cachePlugin, g as mongoOperationsPlugin, h as batchOperationsPlugin, i as prefixedId, l as observabilityPlugin, m as aggregateHelpersPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as subdocumentPlugin, r as getNextSequence, s as AuditTrailQuery, t as customIdPlugin, u as multiTenantPlugin, v as blockIf, w as softDeletePlugin, x as uniqueField, y as immutableField } from "../custom-id.plugin-m0VW6yYm.mjs";
2
2
 
3
- export { aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
3
+ export { AuditTrailQuery, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.2.2",
3
+ "version": "3.2.3",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -93,10 +93,10 @@
93
93
  "test:watch": "vitest",
94
94
  "test:coverage": "vitest run --coverage",
95
95
  "typecheck": "tsc --noEmit",
96
- "prepublishOnly": "npm run build && npm run typecheck && npm test",
96
+ "prepublishOnly": "npm run build && npm run typecheck && npm test",
97
97
  "publish:dry": "npm publish --dry-run --access public",
98
98
  "publish:npm": "npm publish --access public",
99
- "release": "npm run build && npm run typecheck && npm test && npm publish --access public",
99
+ "release": "npm run build && npm run typecheck && npm test && npm publish --access public",
100
100
  "release:patch": "npm version patch && npm run release",
101
101
  "release:minor": "npm version minor && npm run release",
102
102
  "release:major": "npm version major && npm run release"