@classytic/mongokit 3.2.1 → 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.
Files changed (48) hide show
  1. package/README.md +592 -194
  2. package/dist/actions/index.d.mts +9 -0
  3. package/dist/actions/index.mjs +15 -0
  4. package/dist/aggregate-BAi4Do-X.mjs +767 -0
  5. package/dist/aggregate-CCHI7F51.d.mts +269 -0
  6. package/dist/ai/index.d.mts +125 -0
  7. package/dist/ai/index.mjs +203 -0
  8. package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
  9. package/dist/chunk-DQk6qfdC.mjs +18 -0
  10. package/dist/create-BuO6xt0v.mjs +55 -0
  11. package/dist/custom-id.plugin-BmK0SjR9.d.mts +1039 -0
  12. package/dist/custom-id.plugin-m0VW6yYm.mjs +2169 -0
  13. package/dist/index.d.mts +1049 -0
  14. package/dist/index.mjs +2052 -0
  15. package/dist/limits-DsNeCx4D.mjs +299 -0
  16. package/dist/logger-D8ily-PP.mjs +51 -0
  17. package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
  18. package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
  19. package/dist/pagination/PaginationEngine.d.mts +93 -0
  20. package/dist/pagination/PaginationEngine.mjs +196 -0
  21. package/dist/plugins/index.d.mts +3 -0
  22. package/dist/plugins/index.mjs +3 -0
  23. package/dist/types-D-gploPr.d.mts +1241 -0
  24. package/dist/utils/{index.d.ts → index.d.mts} +14 -21
  25. package/dist/utils/index.mjs +5 -0
  26. package/package.json +21 -21
  27. package/dist/actions/index.d.ts +0 -3
  28. package/dist/actions/index.js +0 -5
  29. package/dist/ai/index.d.ts +0 -175
  30. package/dist/ai/index.js +0 -206
  31. package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
  32. package/dist/chunks/chunk-44KXLGPO.js +0 -388
  33. package/dist/chunks/chunk-5G42WJHC.js +0 -737
  34. package/dist/chunks/chunk-B64F5ZWE.js +0 -1226
  35. package/dist/chunks/chunk-GZBKEPVE.js +0 -46
  36. package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
  37. package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
  38. package/dist/chunks/chunk-URLJFIR7.js +0 -22
  39. package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
  40. package/dist/index-BDn5fSTE.d.ts +0 -516
  41. package/dist/index.d.ts +0 -1422
  42. package/dist/index.js +0 -1893
  43. package/dist/pagination/PaginationEngine.d.ts +0 -117
  44. package/dist/pagination/PaginationEngine.js +0 -3
  45. package/dist/plugins/index.d.ts +0 -922
  46. package/dist/plugins/index.js +0 -6
  47. package/dist/types-Jni1KgkP.d.ts +0 -780
  48. package/dist/utils/index.js +0 -5
package/README.md CHANGED
@@ -10,12 +10,14 @@
10
10
  ## Features
11
11
 
12
12
  - **Zero dependencies** - Only Mongoose as peer dependency
13
- - **Smart pagination** - Auto-detects offset vs cursor-based
14
- - **Event-driven** - Pre/post hooks for all operations
15
- - **14 built-in plugins** - Caching, soft delete, validation, multi-tenant, observability, and more
13
+ - **Explicit + smart pagination** - Explicit `mode` control or auto-detection; offset, keyset, and aggregate
14
+ - **Event-driven** - Pre/post hooks for all operations (granular scalability hooks)
15
+ - **17 built-in plugins** - Caching, soft delete, audit trail, validation, multi-tenant, custom IDs, observability, Elasticsearch, and more
16
+ - **Distributed cache safety** - List cache versions stored in the adapter (Redis) for multi-pod correctness
17
+ - **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
16
18
  - **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
17
19
  - **TypeScript first** - Full type safety with discriminated unions
18
- - **547 passing tests** - Battle-tested and production-ready
20
+ - **592 passing tests** - Battle-tested and production-ready
19
21
 
20
22
  ## Installation
21
23
 
@@ -28,19 +30,19 @@ npm install @classytic/mongokit mongoose
28
30
  ## Quick Start
29
31
 
30
32
  ```javascript
31
- import { Repository } from '@classytic/mongokit';
32
- import UserModel from './models/User.js';
33
+ import { Repository } from "@classytic/mongokit";
34
+ import UserModel from "./models/User.js";
33
35
 
34
36
  const userRepo = new Repository(UserModel);
35
37
 
36
38
  // Create
37
- const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
39
+ const user = await userRepo.create({ name: "John", email: "john@example.com" });
38
40
 
39
41
  // Read with auto-detected pagination
40
42
  const users = await userRepo.getAll({ page: 1, limit: 20 });
41
43
 
42
44
  // Update
43
- await userRepo.update(user._id, { name: 'Jane' });
45
+ await userRepo.update(user._id, { name: "Jane" });
44
46
 
45
47
  // Delete
46
48
  await userRepo.delete(user._id);
@@ -48,22 +50,27 @@ await userRepo.delete(user._id);
48
50
 
49
51
  ## Pagination
50
52
 
51
- `getAll()` automatically detects pagination mode based on parameters:
53
+ `getAll()` takes an **explicit `mode`** or auto-detects based on parameters:
52
54
 
53
55
  ```javascript
54
- // Offset pagination (page-based) - for dashboards
56
+ // EXPLICIT: Offset pagination (recommended for dashboards, admin panels)
55
57
  const result = await repo.getAll({
58
+ mode: "offset", // explicit — no ambiguity
56
59
  page: 1,
57
60
  limit: 20,
58
- filters: { status: 'active' },
59
- sort: { createdAt: -1 }
61
+ filters: { status: "active" },
62
+ sort: { createdAt: -1 },
63
+ countStrategy: "exact", // 'exact' | 'estimated' | 'none'
64
+ hint: { createdAt: -1 }, // index hint for query governance
65
+ maxTimeMS: 2000, // kill slow queries
60
66
  });
61
67
  // → { method: 'offset', docs, total, pages, hasNext, hasPrev }
62
68
 
63
- // Keyset pagination (cursor-based) - for infinite scroll
69
+ // EXPLICIT: Keyset pagination (recommended for feeds, infinite scroll)
64
70
  const stream = await repo.getAll({
71
+ mode: "keyset",
65
72
  sort: { createdAt: -1 },
66
- limit: 20
73
+ limit: 20,
67
74
  });
68
75
  // → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
69
76
 
@@ -71,20 +78,37 @@ const stream = await repo.getAll({
71
78
  const next = await repo.getAll({
72
79
  after: stream.next,
73
80
  sort: { createdAt: -1 },
74
- limit: 20
81
+ limit: 20,
75
82
  });
83
+
84
+ // AUTO-DETECTION (backwards compatible, no mode required)
85
+ // page parameter → offset mode
86
+ // after/cursor parameter → keyset mode
87
+ // sort without page → keyset mode (first page)
88
+ // nothing/filters only → offset mode (page 1)
76
89
  ```
77
90
 
78
- **Auto-detection rules:**
79
- - `page` parameter → offset mode
80
- - `after`/`cursor` parameterkeyset mode
81
- - `sort` without `page` → keyset mode (first page)
82
- - Default offset mode (page 1)
91
+ **Auto-detection rules (when `mode` is omitted):**
92
+
93
+ - `page` present**offset** mode
94
+ - `after` or `cursor` present **keyset** mode
95
+ - Non-default `sort` provided without `page` → **keyset** mode
96
+ - Nothing / filters only → **offset** mode (page 1)
97
+
98
+ > ⚠️ **Recommended:** Always pass `mode` explicitly in new code to make intent clear and avoid surprising behavior when query params change.
99
+
100
+ ### Performance Options
101
+
102
+ | Option | Type | Description |
103
+ | --------------- | ------------------------------ | ------------------------------------------------------------------- |
104
+ | `hint` | `string \| object` | Force a specific index — prevents collection scans on large tables |
105
+ | `maxTimeMS` | `number` | Kill query if it takes longer than N ms (prevent runaway queries) |
106
+ | `countStrategy` | `'exact'\|'estimated'\|'none'` | Control cost of total-count query — use `'estimated'` for 10M+ rows |
83
107
 
84
108
  ### Required Indexes
85
109
 
86
110
  ```javascript
87
- // For keyset pagination: sort field + _id
111
+ // For keyset pagination: sort field + _id (compound)
88
112
  PostSchema.index({ createdAt: -1, _id: -1 });
89
113
 
90
114
  // For multi-tenant: tenant + sort field + _id
@@ -95,18 +119,20 @@ UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
95
119
 
96
120
  ### CRUD Operations
97
121
 
98
- | Method | Description |
99
- |--------|-------------|
100
- | `create(data, opts)` | Create single document |
101
- | `createMany(data[], opts)` | Create multiple documents |
102
- | `getById(id, opts)` | Find by ID |
103
- | `getByQuery(query, opts)` | Find one by query |
104
- | `getAll(params, opts)` | Paginated list (auto-detects mode) |
105
- | `getOrCreate(query, data, opts)` | Find or create |
106
- | `update(id, data, opts)` | Update document |
107
- | `delete(id, opts)` | Delete document |
108
- | `count(query, opts)` | Count documents |
109
- | `exists(query, opts)` | Check existence |
122
+ | Method | Description |
123
+ | -------------------------------- | ---------------------------------- |
124
+ | `create(data, opts)` | Create single document |
125
+ | `createMany(data[], opts)` | Create multiple documents |
126
+ | `getById(id, opts)` | Find by ID |
127
+ | `getByQuery(query, opts)` | Find one by query |
128
+ | `getAll(params, opts)` | Paginated list (auto-detects mode) |
129
+ | `getOrCreate(query, data, opts)` | Find or create |
130
+ | `update(id, data, opts)` | Update document |
131
+ | `delete(id, opts)` | Delete document |
132
+ | `count(query, opts)` | Count documents |
133
+ | `exists(query, opts)` | Check existence |
134
+
135
+ > **Note:** All read operations (`getById`, `getByQuery`, `getAll`, `count`, `exists`, `aggregate`, etc.) accept a `readPreference` option in the `opts` parameter (e.g., `readPreference: 'secondaryPreferred'`) to support scaling reads across replica sets.
110
136
 
111
137
  ### Aggregation
112
138
 
@@ -132,8 +158,8 @@ const categories = await repo.distinct('category', { status: 'active' });
132
158
 
133
159
  ```javascript
134
160
  await repo.withTransaction(async (session) => {
135
- await repo.create({ name: 'User 1' }, { session });
136
- await repo.create({ name: 'User 2' }, { session });
161
+ await repo.create({ name: "User 1" }, { session });
162
+ await repo.create({ name: "User 2" }, { session });
137
163
  // Auto-commits on success, auto-rollbacks on error
138
164
  });
139
165
  ```
@@ -142,12 +168,12 @@ await repo.withTransaction(async (session) => {
142
168
 
143
169
  ```javascript
144
170
  const repo = new Repository(UserModel, plugins, {
145
- defaultLimit: 20, // Default docs per page
146
- maxLimit: 100, // Maximum allowed limit
147
- maxPage: 10000, // Maximum page number
148
- deepPageThreshold: 100, // Warn when page exceeds this
149
- useEstimatedCount: false, // Use fast estimated counts
150
- cursorVersion: 1 // Cursor format version
171
+ defaultLimit: 20, // Default docs per page
172
+ maxLimit: 100, // Maximum allowed limit
173
+ maxPage: 10000, // Maximum page number
174
+ deepPageThreshold: 100, // Warn when page exceeds this
175
+ useEstimatedCount: false, // Use fast estimated counts
176
+ cursorVersion: 1, // Cursor format version
151
177
  });
152
178
  ```
153
179
 
@@ -161,59 +187,62 @@ import {
161
187
  timestampPlugin,
162
188
  softDeletePlugin,
163
189
  cachePlugin,
164
- createMemoryCache
165
- } from '@classytic/mongokit';
190
+ createMemoryCache,
191
+ } from "@classytic/mongokit";
166
192
 
167
193
  const repo = new Repository(UserModel, [
168
194
  timestampPlugin(),
169
195
  softDeletePlugin(),
170
- cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
196
+ cachePlugin({ adapter: createMemoryCache(), ttl: 60 }),
171
197
  ]);
172
198
  ```
173
199
 
174
200
  ### Available Plugins
175
201
 
176
- | Plugin | Description |
177
- |--------|-------------|
178
- | `timestampPlugin()` | Auto-manage `createdAt`/`updatedAt` |
179
- | `softDeletePlugin(opts)` | Mark as deleted instead of removing |
180
- | `auditLogPlugin(logger)` | Log all CUD operations |
181
- | `cachePlugin(opts)` | Redis/Memcached/memory caching with auto-invalidation |
182
- | `validationChainPlugin(validators)` | Custom validation rules |
183
- | `fieldFilterPlugin(preset)` | Role-based field visibility |
184
- | `cascadePlugin(opts)` | Auto-delete related documents |
185
- | `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
186
- | `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
187
- | `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany` |
188
- | `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
189
- | `subdocumentPlugin()` | Manage subdocument arrays |
190
- | `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
191
- | `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
202
+ | Plugin | Description |
203
+ | ----------------------------------- | --------------------------------------------------------- |
204
+ | `timestampPlugin()` | Auto-manage `createdAt`/`updatedAt` |
205
+ | `softDeletePlugin(opts)` | Mark as deleted instead of removing |
206
+ | `auditLogPlugin(logger)` | Log all CUD operations |
207
+ | `cachePlugin(opts)` | Redis/Memcached/memory caching with auto-invalidation |
208
+ | `validationChainPlugin(validators)` | Custom validation rules |
209
+ | `fieldFilterPlugin(preset)` | Role-based field visibility |
210
+ | `cascadePlugin(opts)` | Auto-delete related documents |
211
+ | `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
212
+ | `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
213
+ | `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany` |
214
+ | `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
215
+ | `subdocumentPlugin()` | Manage subdocument arrays |
216
+ | `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
217
+ | `customIdPlugin(opts)` | Auto-generate sequential/random IDs with atomic counters |
218
+ | `elasticSearchPlugin(opts)` | Delegate text/semantic search to Elasticsearch/OpenSearch |
219
+ | `auditTrailPlugin(opts)` | DB-persisted audit trail with change tracking and TTL |
220
+ | `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
192
221
 
193
222
  ### Soft Delete
194
223
 
195
224
  ```javascript
196
225
  const repo = new Repository(UserModel, [
197
- softDeletePlugin({ deletedField: 'deletedAt' })
226
+ softDeletePlugin({ deletedField: "deletedAt" }),
198
227
  ]);
199
228
 
200
- await repo.delete(id); // Marks as deleted
201
- await repo.getAll(); // Excludes deleted
202
- await repo.getAll({ includeDeleted: true }); // Includes deleted
229
+ await repo.delete(id); // Marks as deleted
230
+ await repo.getAll(); // Excludes deleted
231
+ await repo.getAll({ includeDeleted: true }); // Includes deleted
203
232
  ```
204
233
 
205
234
  ### Caching
206
235
 
207
236
  ```javascript
208
- import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
237
+ import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
209
238
 
210
239
  const repo = new Repository(UserModel, [
211
240
  cachePlugin({
212
- adapter: createMemoryCache(), // or Redis adapter
213
- ttl: 60, // Default TTL (seconds)
214
- byIdTtl: 300, // TTL for getById
215
- queryTtl: 30, // TTL for lists
216
- })
241
+ adapter: createMemoryCache(), // or Redis adapter
242
+ ttl: 60, // Default TTL (seconds)
243
+ byIdTtl: 300, // TTL for getById
244
+ queryTtl: 30, // TTL for lists
245
+ }),
217
246
  ]);
218
247
 
219
248
  // Reads are cached automatically
@@ -223,7 +252,7 @@ const user = await repo.getById(id);
223
252
  const fresh = await repo.getById(id, { skipCache: true });
224
253
 
225
254
  // Mutations auto-invalidate cache
226
- await repo.update(id, { name: 'New' });
255
+ await repo.update(id, { name: "New" });
227
256
 
228
257
  // Manual invalidation
229
258
  await repo.invalidateCache(id);
@@ -231,12 +260,21 @@ await repo.invalidateAllCache();
231
260
  ```
232
261
 
233
262
  **Redis adapter example:**
263
+
234
264
  ```javascript
235
265
  const redisAdapter = {
236
- async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
237
- async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
238
- async del(key) { await redis.del(key); },
239
- async clear(pattern) { /* optional bulk delete */ }
266
+ async get(key) {
267
+ return JSON.parse((await redis.get(key)) || "null");
268
+ },
269
+ async set(key, value, ttl) {
270
+ await redis.setex(key, ttl, JSON.stringify(value));
271
+ },
272
+ async del(key) {
273
+ await redis.del(key);
274
+ },
275
+ async clear(pattern) {
276
+ /* optional bulk delete */
277
+ },
240
278
  };
241
279
  ```
242
280
 
@@ -249,37 +287,40 @@ import {
249
287
  uniqueField,
250
288
  immutableField,
251
289
  blockIf,
252
- autoInject
253
- } from '@classytic/mongokit';
290
+ autoInject,
291
+ } from "@classytic/mongokit";
254
292
 
255
293
  const repo = new Repository(UserModel, [
256
294
  validationChainPlugin([
257
- requireField('email', ['create']),
258
- uniqueField('email', 'Email already exists'),
259
- immutableField('userId'),
260
- blockIf('noAdminDelete', ['delete'],
261
- (ctx) => ctx.data?.role === 'admin',
262
- 'Cannot delete admin users'),
263
- autoInject('slug', (ctx) => slugify(ctx.data?.name), ['create'])
264
- ])
295
+ requireField("email", ["create"]),
296
+ uniqueField("email", "Email already exists"),
297
+ immutableField("userId"),
298
+ blockIf(
299
+ "noAdminDelete",
300
+ ["delete"],
301
+ (ctx) => ctx.data?.role === "admin",
302
+ "Cannot delete admin users",
303
+ ),
304
+ autoInject("slug", (ctx) => slugify(ctx.data?.name), ["create"]),
305
+ ]),
265
306
  ]);
266
307
  ```
267
308
 
268
309
  ### Cascade Delete
269
310
 
270
311
  ```javascript
271
- import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
312
+ import { cascadePlugin, softDeletePlugin } from "@classytic/mongokit";
272
313
 
273
314
  const repo = new Repository(ProductModel, [
274
315
  softDeletePlugin(),
275
316
  cascadePlugin({
276
317
  relations: [
277
- { model: 'StockEntry', foreignKey: 'product' },
278
- { model: 'Review', foreignKey: 'product', softDelete: false }
318
+ { model: "StockEntry", foreignKey: "product" },
319
+ { model: "Review", foreignKey: "product", softDelete: false },
279
320
  ],
280
321
  parallel: true,
281
- logger: console
282
- })
322
+ logger: console,
323
+ }),
283
324
  ]);
284
325
 
285
326
  // Deleting product also deletes related StockEntry and Review docs
@@ -289,40 +330,160 @@ await repo.delete(productId);
289
330
  ### Field Filtering (RBAC)
290
331
 
291
332
  ```javascript
292
- import { fieldFilterPlugin } from '@classytic/mongokit';
333
+ import { fieldFilterPlugin } from "@classytic/mongokit";
293
334
 
294
335
  const repo = new Repository(UserModel, [
295
336
  fieldFilterPlugin({
296
- public: ['id', 'name', 'avatar'],
297
- authenticated: ['email', 'phone'],
298
- admin: ['createdAt', 'internalNotes']
299
- })
337
+ public: ["id", "name", "avatar"],
338
+ authenticated: ["email", "phone"],
339
+ admin: ["createdAt", "internalNotes"],
340
+ }),
300
341
  ]);
301
342
  ```
302
343
 
303
344
  ### Multi-Tenant
304
345
 
305
346
  ```javascript
306
- import { multiTenantPlugin } from '@classytic/mongokit';
347
+ import { multiTenantPlugin } from "@classytic/mongokit";
307
348
 
308
349
  const repo = new Repository(UserModel, [
309
350
  multiTenantPlugin({
310
- tenantField: 'organizationId',
311
- contextKey: 'organizationId', // reads from context
351
+ tenantField: "organizationId",
352
+ contextKey: "organizationId", // reads from context
312
353
  required: true,
313
- })
354
+ }),
314
355
  ]);
315
356
 
316
357
  // All operations are automatically scoped to the tenant
317
- const users = await repo.getAll({ organizationId: 'org_123' });
318
- await repo.update(userId, { name: 'New' }, { organizationId: 'org_123' });
358
+ const users = await repo.getAll({ organizationId: "org_123" });
359
+ await repo.update(userId, { name: "New" }, { organizationId: "org_123" });
319
360
  // Cross-tenant update/delete is blocked — returns "not found"
320
361
  ```
321
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
+
322
483
  ### Observability
323
484
 
324
485
  ```javascript
325
- import { observabilityPlugin } from '@classytic/mongokit';
486
+ import { observabilityPlugin } from "@classytic/mongokit";
326
487
 
327
488
  const repo = new Repository(UserModel, [
328
489
  observabilityPlugin({
@@ -330,11 +491,154 @@ const repo = new Repository(UserModel, [
330
491
  // Send to DataDog, New Relic, OpenTelemetry, etc.
331
492
  statsd.histogram(`mongokit.${metric.operation}`, metric.duration);
332
493
  },
333
- slowThresholdMs: 200, // log operations slower than 200ms
334
- })
494
+ slowThresholdMs: 200, // log operations slower than 200ms
495
+ }),
496
+ ]);
497
+ ```
498
+
499
+ ### Custom ID Generation
500
+
501
+ Generate human-readable sequential IDs (e.g., `INV-0001`, `BILL-2026-02-0001`) using atomic MongoDB counters — safe under concurrency with zero duplicates.
502
+
503
+ ```typescript
504
+ import {
505
+ Repository,
506
+ customIdPlugin,
507
+ sequentialId,
508
+ dateSequentialId,
509
+ prefixedId,
510
+ } from "@classytic/mongokit";
511
+ ```
512
+
513
+ #### Sequential Counter
514
+
515
+ ```typescript
516
+ const invoiceRepo = new Repository(InvoiceModel, [
517
+ customIdPlugin({
518
+ field: "invoiceNumber",
519
+ generator: sequentialId({
520
+ prefix: "INV",
521
+ model: InvoiceModel,
522
+ }),
523
+ }),
524
+ ]);
525
+
526
+ const inv1 = await invoiceRepo.create({ amount: 100 });
527
+ // inv1.invoiceNumber → "INV-0001"
528
+
529
+ const inv2 = await invoiceRepo.create({ amount: 200 });
530
+ // inv2.invoiceNumber → "INV-0002"
531
+ ```
532
+
533
+ **Options:**
534
+
535
+ | Option | Default | Description |
536
+ | ------------ | ------------ | ---------------------------------------------------- |
537
+ | `prefix` | _(required)_ | Prefix string (e.g., `'INV'`, `'ORD'`) |
538
+ | `model` | _(required)_ | Mongoose model (counter key derived from model name) |
539
+ | `padding` | `4` | Number of digits (`4` → `0001`) |
540
+ | `separator` | `'-'` | Separator between prefix and number |
541
+ | `counterKey` | model name | Custom counter key to avoid collisions |
542
+
543
+ #### Date-Partitioned Counter
544
+
545
+ Counter resets per period — ideal for invoice/bill numbering:
546
+
547
+ ```typescript
548
+ const billRepo = new Repository(BillModel, [
549
+ customIdPlugin({
550
+ field: "billNumber",
551
+ generator: dateSequentialId({
552
+ prefix: "BILL",
553
+ model: BillModel,
554
+ partition: "monthly", // resets each month
555
+ }),
556
+ }),
557
+ ]);
558
+
559
+ const bill = await billRepo.create({ total: 250 });
560
+ // bill.billNumber → "BILL-2026-02-0001"
561
+ ```
562
+
563
+ **Partition modes:**
564
+
565
+ - `'yearly'` → `BILL-2026-0001` (resets every January)
566
+ - `'monthly'` → `BILL-2026-02-0001` (resets every month)
567
+ - `'daily'` → `BILL-2026-02-20-0001` (resets every day)
568
+
569
+ #### Prefixed Random ID
570
+
571
+ No database round-trip — purely in-memory random suffix:
572
+
573
+ ```typescript
574
+ const orderRepo = new Repository(OrderModel, [
575
+ customIdPlugin({
576
+ field: "orderRef",
577
+ generator: prefixedId({ prefix: "ORD", length: 10 }),
578
+ }),
579
+ ]);
580
+
581
+ const order = await orderRepo.create({ total: 99 });
582
+ // order.orderRef → "ORD_a7b3xk9m2p"
583
+ ```
584
+
585
+ #### Custom Generator
586
+
587
+ Write your own generator function for full control:
588
+
589
+ ```typescript
590
+ const repo = new Repository(OrderModel, [
591
+ customIdPlugin({
592
+ field: "orderRef",
593
+ generator: async (context) => {
594
+ const region = context.data?.region || "US";
595
+ const seq = await getNextSequence("orders");
596
+ return `ORD-${region}-${String(seq).padStart(4, "0")}`;
597
+ },
598
+ }),
335
599
  ]);
600
+ // → "ORD-US-0001", "ORD-EU-0002", ...
336
601
  ```
337
602
 
603
+ #### Plugin Options
604
+
605
+ | Option | Default | Description |
606
+ | --------------------- | ------------ | -------------------------------------------- |
607
+ | `field` | `'customId'` | Document field to store the generated ID |
608
+ | `generator` | _(required)_ | Function returning the ID (sync or async) |
609
+ | `generateOnlyIfEmpty` | `true` | Skip generation if field already has a value |
610
+
611
+ #### Batch Creation
612
+
613
+ Works with `createMany` — each document gets its own sequential ID:
614
+
615
+ ```typescript
616
+ const docs = await invoiceRepo.createMany([
617
+ { amount: 10 },
618
+ { amount: 20, invoiceNumber: "MANUAL-001" }, // skipped (already has ID)
619
+ { amount: 30 },
620
+ ]);
621
+ // docs[0].invoiceNumber → "INV-0001"
622
+ // docs[1].invoiceNumber → "MANUAL-001" (preserved)
623
+ // docs[2].invoiceNumber → "INV-0002"
624
+ ```
625
+
626
+ #### Atomic Counter API
627
+
628
+ The `getNextSequence` helper is exported for use in custom generators:
629
+
630
+ ```typescript
631
+ import { getNextSequence } from "@classytic/mongokit";
632
+
633
+ const seq = await getNextSequence("my-counter"); // → 1, 2, 3, ...
634
+ const batch = await getNextSequence("my-counter", 5); // → jumps by 5
635
+ ```
636
+
637
+ Counters are stored in the `_mongokit_counters` collection using MongoDB's atomic `findOneAndUpdate` + `$inc` — guaranteed unique under any level of concurrency.
638
+
639
+ > **Note:** Counters are monotonically increasing and never decrement on document deletion.
640
+ > This is standard behavior for business documents (invoices, bills, receipts) — you never reuse a number.
641
+
338
642
  ### Vector Search (Atlas)
339
643
 
340
644
  ```javascript
@@ -367,10 +671,65 @@ const results = await repo.searchSimilar({ query: [0.1, 0.2, ...], limit: 5 });
367
671
  const vector = await repo.embed('some text');
368
672
  ```
369
673
 
674
+ ### Elasticsearch / OpenSearch Plugin
675
+
676
+ Delegates heavy text and semantic search to an external search engine while fetching full documents from MongoDB. Keeps your OLTP (transactional) MongoDB operations fast by separating search I/O.
677
+
678
+ **Architecture:** Query ES/OpenSearch → get IDs + relevance scores → fetch full docs from MongoDB → return in ES ranking order.
679
+
680
+ ```typescript
681
+ import {
682
+ Repository,
683
+ methodRegistryPlugin,
684
+ elasticSearchPlugin,
685
+ } from "@classytic/mongokit";
686
+ import { Client } from "@elastic/elasticsearch"; // or '@opensearch-project/opensearch'
687
+
688
+ const esClient = new Client({ node: "http://localhost:9200" });
689
+
690
+ const productRepo = new Repository(ProductModel, [
691
+ methodRegistryPlugin(), // Required first
692
+ elasticSearchPlugin({
693
+ client: esClient,
694
+ index: "products",
695
+ idField: "_id", // field in ES doc that maps to MongoDB _id
696
+ }),
697
+ ]);
698
+
699
+ // Perform semantic/full-text search
700
+ const results = await productRepo.search(
701
+ { match: { description: "wireless headphones" } },
702
+ {
703
+ limit: 20, // capped to 1000 max (safety bound)
704
+ from: 0,
705
+ mongoOptions: {
706
+ select: "name price description",
707
+ lean: true,
708
+ },
709
+ },
710
+ );
711
+
712
+ // results.docs - MongoDB documents in ES ranking order
713
+ // results.docs[*]._score - ES relevance score (preserved, including 0)
714
+ // results.total - total hits count from ES
715
+ ```
716
+
717
+ **Why this exists:**
718
+
719
+ - `$text` in MongoDB requires a text index and is not scalable for fuzzy/semantic search
720
+ - ES/OpenSearch provides BM25, vector search, semantic search, analyzers, facets
721
+ - This plugin bridges both: ES rank + MongoDB's transactional documents
722
+
723
+ **Bounds enforcement:**
724
+
725
+ - `limit` is clamped to `[1, 1000]` — prevents runaway ES queries
726
+ - `from` is clamped to `>= 0` — prevents negative offsets
727
+ - Returns `{ docs: [], total: 0 }` immediately if ES returns no hits
728
+
370
729
  ### Logging
371
730
 
372
731
  ```javascript
373
- import { configureLogger } from '@classytic/mongokit';
732
+ import { configureLogger } from "@classytic/mongokit";
374
733
 
375
734
  // Silence all internal warnings
376
735
  configureLogger(false);
@@ -389,16 +748,20 @@ The `mongoOperationsPlugin` adds MongoDB-specific atomic operations like `increm
389
748
  #### Basic Usage (No TypeScript Autocomplete)
390
749
 
391
750
  ```javascript
392
- import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
751
+ import {
752
+ Repository,
753
+ methodRegistryPlugin,
754
+ mongoOperationsPlugin,
755
+ } from "@classytic/mongokit";
393
756
 
394
757
  const repo = new Repository(ProductModel, [
395
- methodRegistryPlugin(), // Required first
396
- mongoOperationsPlugin()
758
+ methodRegistryPlugin(), // Required first
759
+ mongoOperationsPlugin(),
397
760
  ]);
398
761
 
399
762
  // Works at runtime but TypeScript doesn't provide autocomplete
400
- await repo.increment(productId, 'views', 1);
401
- await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
763
+ await repo.increment(productId, "views", 1);
764
+ await repo.upsert({ sku: "ABC" }, { name: "Product", price: 99 });
402
765
  ```
403
766
 
404
767
  #### With TypeScript Type Safety (Recommended)
@@ -406,8 +769,12 @@ await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
406
769
  For full TypeScript autocomplete and type checking, use the `MongoOperationsMethods` type:
407
770
 
408
771
  ```typescript
409
- import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
410
- import type { MongoOperationsMethods } from '@classytic/mongokit';
772
+ import {
773
+ Repository,
774
+ methodRegistryPlugin,
775
+ mongoOperationsPlugin,
776
+ } from "@classytic/mongokit";
777
+ import type { MongoOperationsMethods } from "@classytic/mongokit";
411
778
 
412
779
  // 1. Create your repository class
413
780
  class ProductRepo extends Repository<IProduct> {
@@ -423,17 +790,18 @@ type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
423
790
  // 3. Instantiate with type assertion
424
791
  const repo = new ProductRepo(ProductModel, [
425
792
  methodRegistryPlugin(),
426
- mongoOperationsPlugin()
793
+ mongoOperationsPlugin(),
427
794
  ]) as ProductRepoWithPlugins;
428
795
 
429
796
  // 4. Now TypeScript provides full autocomplete and type checking!
430
- await repo.increment(productId, 'views', 1); // ✅ Autocomplete works
431
- await repo.upsert({ sku: 'ABC' }, { name: 'Product' }); // ✅ Type-safe
432
- await repo.pushToArray(productId, 'tags', 'featured'); // ✅ Validated
433
- await repo.findBySku('ABC'); // ✅ Custom methods too
797
+ await repo.increment(productId, "views", 1); // ✅ Autocomplete works
798
+ await repo.upsert({ sku: "ABC" }, { name: "Product" }); // ✅ Type-safe
799
+ await repo.pushToArray(productId, "tags", "featured"); // ✅ Validated
800
+ await repo.findBySku("ABC"); // ✅ Custom methods too
434
801
  ```
435
802
 
436
803
  **Available operations:**
804
+
437
805
  - `upsert(query, data, opts)` - Create or find document
438
806
  - `increment(id, field, value, opts)` - Atomically increment field
439
807
  - `decrement(id, field, value, opts)` - Atomically decrement field
@@ -452,7 +820,7 @@ await repo.findBySku('ABC'); // ✅ Custom methods too
452
820
  Plugin methods are added at runtime. Use `WithPlugins<TDoc, TRepo>` for TypeScript autocomplete:
453
821
 
454
822
  ```typescript
455
- import type { WithPlugins } from '@classytic/mongokit';
823
+ import type { WithPlugins } from "@classytic/mongokit";
456
824
 
457
825
  class UserRepo extends Repository<IUser> {}
458
826
 
@@ -463,26 +831,26 @@ const repo = new UserRepo(Model, [
463
831
  ]) as WithPlugins<IUser, UserRepo>;
464
832
 
465
833
  // Full TypeScript autocomplete!
466
- await repo.increment(id, 'views', 1);
834
+ await repo.increment(id, "views", 1);
467
835
  await repo.restore(id);
468
836
  await repo.invalidateCache(id);
469
837
  ```
470
838
 
471
- **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`
472
840
 
473
841
  ## Event System
474
842
 
475
843
  ```javascript
476
- repo.on('before:create', async (context) => {
844
+ repo.on("before:create", async (context) => {
477
845
  context.data.processedAt = new Date();
478
846
  });
479
847
 
480
- repo.on('after:create', ({ context, result }) => {
481
- console.log('Created:', result);
848
+ repo.on("after:create", ({ context, result }) => {
849
+ console.log("Created:", result);
482
850
  });
483
851
 
484
- repo.on('error:create', ({ context, error }) => {
485
- console.error('Failed:', error);
852
+ repo.on("error:create", ({ context, error }) => {
853
+ console.error("Failed:", error);
486
854
  });
487
855
  ```
488
856
 
@@ -497,15 +865,19 @@ MongoKit provides a complete toolkit for building REST APIs: QueryParser for req
497
865
  Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
498
866
 
499
867
  ```typescript
500
- import type { IController, IRequestContext, IControllerResponse } from '@classytic/mongokit';
868
+ import type {
869
+ IController,
870
+ IRequestContext,
871
+ IControllerResponse,
872
+ } from "@classytic/mongokit";
501
873
 
502
874
  // IRequestContext - what your controller receives
503
875
  interface IRequestContext {
504
- query: Record<string, unknown>; // URL query params
505
- body: Record<string, unknown>; // Request body
506
- params: Record<string, string>; // Route params (:id)
507
- user?: { id: string; role?: string }; // Auth user
508
- context?: Record<string, unknown>; // Tenant ID, etc.
876
+ query: Record<string, unknown>; // URL query params
877
+ body: Record<string, unknown>; // Request body
878
+ params: Record<string, string>; // Route params (:id)
879
+ user?: { id: string; role?: string }; // Auth user
880
+ context?: Record<string, unknown>; // Tenant ID, etc.
509
881
  }
510
882
 
511
883
  // IControllerResponse - what your controller returns
@@ -518,11 +890,15 @@ interface IControllerResponse<T> {
518
890
 
519
891
  // IController - implement this interface
520
892
  interface IController<TDoc> {
521
- list(ctx: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
893
+ list(
894
+ ctx: IRequestContext,
895
+ ): Promise<IControllerResponse<PaginationResult<TDoc>>>;
522
896
  get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
523
897
  create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
524
898
  update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
525
- delete(ctx: IRequestContext): Promise<IControllerResponse<{ message: string }>>;
899
+ delete(
900
+ ctx: IRequestContext,
901
+ ): Promise<IControllerResponse<{ message: string }>>;
526
902
  }
527
903
  ```
528
904
 
@@ -531,12 +907,12 @@ interface IController<TDoc> {
531
907
  Converts HTTP query strings to MongoDB queries with built-in security:
532
908
 
533
909
  ```typescript
534
- import { QueryParser } from '@classytic/mongokit';
910
+ import { QueryParser } from "@classytic/mongokit";
535
911
 
536
912
  const parser = new QueryParser({
537
- maxLimit: 100, // Prevent excessive queries
538
- maxFilterDepth: 5, // Prevent nested injection
539
- maxRegexLength: 100, // ReDoS protection
913
+ maxLimit: 100, // Prevent excessive queries
914
+ maxFilterDepth: 5, // Prevent nested injection
915
+ maxRegexLength: 100, // ReDoS protection
540
916
  });
541
917
 
542
918
  // Parse request query
@@ -544,6 +920,7 @@ const { filters, limit, page, sort, search } = parser.parse(req.query);
544
920
  ```
545
921
 
546
922
  **Supported query patterns:**
923
+
547
924
  ```bash
548
925
  # Filtering
549
926
  GET /users?status=active&role=admin
@@ -574,6 +951,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
574
951
  ```
575
952
 
576
953
  **Security features:**
954
+
577
955
  - Blocks `$where`, `$function`, `$accumulator`, `$expr` operators
578
956
  - ReDoS protection for regex patterns
579
957
  - Max filter depth enforcement
@@ -586,7 +964,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
586
964
  QueryParser supports Mongoose populate options via URL query parameters:
587
965
 
588
966
  ```typescript
589
- import { QueryParser } from '@classytic/mongokit';
967
+ import { QueryParser } from "@classytic/mongokit";
590
968
 
591
969
  const parser = new QueryParser();
592
970
 
@@ -596,19 +974,19 @@ const parsed = parser.parse(req.query);
596
974
  // Use with Repository
597
975
  const posts = await postRepo.getAll(
598
976
  { filters: parsed.filters, page: parsed.page, limit: parsed.limit },
599
- { populateOptions: parsed.populateOptions }
977
+ { populateOptions: parsed.populateOptions },
600
978
  );
601
979
  ```
602
980
 
603
981
  **Supported populate options:**
604
982
 
605
- | Option | URL Syntax | Description |
606
- |--------|------------|-------------|
607
- | `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
608
- | `match` | `populate[path][match][field]=value` | Filter populated documents |
609
- | `limit` | `populate[path][limit]=10` | Limit number of populated docs |
610
- | `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
611
- | `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
983
+ | Option | URL Syntax | Description |
984
+ | ---------- | ------------------------------------------------ | ----------------------------------------------- |
985
+ | `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
986
+ | `match` | `populate[path][match][field]=value` | Filter populated documents |
987
+ | `limit` | `populate[path][limit]=10` | Limit number of populated docs |
988
+ | `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
989
+ | `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
612
990
 
613
991
  **Example - Complex populate:**
614
992
 
@@ -632,15 +1010,15 @@ const parsed = parser.parse(req.query);
632
1010
  Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
633
1011
 
634
1012
  ```typescript
635
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
1013
+ import { buildCrudSchemasFromModel } from "@classytic/mongokit";
636
1014
 
637
1015
  const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
638
1016
  fieldRules: {
639
- organizationId: { immutable: true }, // Can't update after create
640
- role: { systemManaged: true }, // Users can't set this
1017
+ organizationId: { immutable: true }, // Can't update after create
1018
+ role: { systemManaged: true }, // Users can't set this
641
1019
  createdAt: { systemManaged: true },
642
1020
  },
643
- strictAdditionalProperties: true, // Reject unknown fields
1021
+ strictAdditionalProperties: true, // Reject unknown fields
644
1022
  });
645
1023
 
646
1024
  // Generated schemas:
@@ -660,7 +1038,7 @@ import {
660
1038
  type IController,
661
1039
  type IRequestContext,
662
1040
  type IControllerResponse,
663
- } from '@classytic/mongokit';
1041
+ } from "@classytic/mongokit";
664
1042
 
665
1043
  class UserController implements IController<IUser> {
666
1044
  private repo = new Repository(UserModel);
@@ -695,7 +1073,7 @@ class UserController implements IController<IUser> {
695
1073
 
696
1074
  async delete(ctx: IRequestContext): Promise<IControllerResponse> {
697
1075
  await this.repo.delete(ctx.params.id);
698
- return { success: true, data: { message: 'Deleted' }, status: 200 };
1076
+ return { success: true, data: { message: "Deleted" }, status: 200 };
699
1077
  }
700
1078
  }
701
1079
  ```
@@ -703,29 +1081,41 @@ class UserController implements IController<IUser> {
703
1081
  ### Fastify Integration
704
1082
 
705
1083
  ```typescript
706
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
1084
+ import { buildCrudSchemasFromModel } from "@classytic/mongokit";
707
1085
 
708
1086
  const controller = new UserController();
709
1087
  const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
710
1088
 
711
1089
  // Routes with auto-validation and OpenAPI docs
712
- fastify.get('/users', { schema: { querystring: crudSchemas.listQuery } }, async (req, reply) => {
713
- const ctx = { query: req.query, body: {}, params: {}, user: req.user };
714
- const response = await controller.list(ctx);
715
- return reply.status(response.status).send(response);
716
- });
1090
+ fastify.get(
1091
+ "/users",
1092
+ { schema: { querystring: crudSchemas.listQuery } },
1093
+ async (req, reply) => {
1094
+ const ctx = { query: req.query, body: {}, params: {}, user: req.user };
1095
+ const response = await controller.list(ctx);
1096
+ return reply.status(response.status).send(response);
1097
+ },
1098
+ );
717
1099
 
718
- fastify.post('/users', { schema: { body: crudSchemas.createBody } }, async (req, reply) => {
719
- const ctx = { query: {}, body: req.body, params: {}, user: req.user };
720
- const response = await controller.create(ctx);
721
- return reply.status(response.status).send(response);
722
- });
1100
+ fastify.post(
1101
+ "/users",
1102
+ { schema: { body: crudSchemas.createBody } },
1103
+ async (req, reply) => {
1104
+ const ctx = { query: {}, body: req.body, params: {}, user: req.user };
1105
+ const response = await controller.create(ctx);
1106
+ return reply.status(response.status).send(response);
1107
+ },
1108
+ );
723
1109
 
724
- fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req, reply) => {
725
- const ctx = { query: {}, body: {}, params: req.params, user: req.user };
726
- const response = await controller.get(ctx);
727
- return reply.status(response.status).send(response);
728
- });
1110
+ fastify.get(
1111
+ "/users/:id",
1112
+ { schema: { params: crudSchemas.params } },
1113
+ async (req, reply) => {
1114
+ const ctx = { query: {}, body: {}, params: req.params, user: req.user };
1115
+ const response = await controller.get(ctx);
1116
+ return reply.status(response.status).send(response);
1117
+ },
1118
+ );
729
1119
  ```
730
1120
 
731
1121
  ### Express Integration
@@ -733,13 +1123,13 @@ fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req
733
1123
  ```typescript
734
1124
  const controller = new UserController();
735
1125
 
736
- app.get('/users', async (req, res) => {
1126
+ app.get("/users", async (req, res) => {
737
1127
  const ctx = { query: req.query, body: {}, params: {}, user: req.user };
738
1128
  const response = await controller.list(ctx);
739
1129
  res.status(response.status).json(response);
740
1130
  });
741
1131
 
742
- app.post('/users', async (req, res) => {
1132
+ app.post("/users", async (req, res) => {
743
1133
  const ctx = { query: {}, body: req.body, params: {}, user: req.user };
744
1134
  const response = await controller.create(ctx);
745
1135
  res.status(response.status).json(response);
@@ -749,7 +1139,11 @@ app.post('/users', async (req, res) => {
749
1139
  ## TypeScript
750
1140
 
751
1141
  ```typescript
752
- import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
1142
+ import {
1143
+ Repository,
1144
+ OffsetPaginationResult,
1145
+ KeysetPaginationResult,
1146
+ } from "@classytic/mongokit";
753
1147
 
754
1148
  interface IUser extends Document {
755
1149
  name: string;
@@ -761,10 +1155,10 @@ const repo = new Repository<IUser>(UserModel);
761
1155
  const result = await repo.getAll({ page: 1, limit: 20 });
762
1156
 
763
1157
  // Discriminated union - TypeScript knows the type
764
- if (result.method === 'offset') {
765
- console.log(result.total, result.pages); // Available
1158
+ if (result.method === "offset") {
1159
+ console.log(result.total, result.pages); // Available
766
1160
  }
767
- if (result.method === 'keyset') {
1161
+ if (result.method === "keyset") {
768
1162
  console.log(result.next, result.hasMore); // Available
769
1163
  }
770
1164
  ```
@@ -774,16 +1168,17 @@ if (result.method === 'keyset') {
774
1168
  Create custom repository classes with domain-specific methods:
775
1169
 
776
1170
  ```typescript
777
- import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
778
- import UserModel, { IUser } from './models/User.js';
1171
+ import {
1172
+ Repository,
1173
+ softDeletePlugin,
1174
+ timestampPlugin,
1175
+ } from "@classytic/mongokit";
1176
+ import UserModel, { IUser } from "./models/User.js";
779
1177
 
780
1178
  class UserRepository extends Repository<IUser> {
781
1179
  constructor() {
782
- super(UserModel, [
783
- timestampPlugin(),
784
- softDeletePlugin()
785
- ], {
786
- defaultLimit: 20
1180
+ super(UserModel, [timestampPlugin(), softDeletePlugin()], {
1181
+ defaultLimit: 20,
787
1182
  });
788
1183
  }
789
1184
 
@@ -794,19 +1189,19 @@ class UserRepository extends Repository<IUser> {
794
1189
 
795
1190
  async findActiveUsers() {
796
1191
  return this.getAll({
797
- filters: { status: 'active' },
798
- sort: { createdAt: -1 }
1192
+ filters: { status: "active" },
1193
+ sort: { createdAt: -1 },
799
1194
  });
800
1195
  }
801
1196
 
802
1197
  async deactivate(id: string) {
803
- return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
1198
+ return this.update(id, { status: "inactive", deactivatedAt: new Date() });
804
1199
  }
805
1200
  }
806
1201
 
807
1202
  // Usage
808
1203
  const userRepo = new UserRepository();
809
- const user = await userRepo.findByEmail('john@example.com');
1204
+ const user = await userRepo.findByEmail("john@example.com");
810
1205
  ```
811
1206
 
812
1207
  ### Overriding Methods
@@ -819,12 +1214,15 @@ class AuditedUserRepository extends Repository<IUser> {
819
1214
 
820
1215
  // Override create to add audit trail
821
1216
  async create(data: Partial<IUser>, options = {}) {
822
- const result = await super.create({
823
- ...data,
824
- createdBy: getCurrentUserId()
825
- }, options);
826
-
827
- await auditLog('user.created', result._id);
1217
+ const result = await super.create(
1218
+ {
1219
+ ...data,
1220
+ createdBy: getCurrentUserId(),
1221
+ },
1222
+ options,
1223
+ );
1224
+
1225
+ await auditLog("user.created", result._id);
828
1226
  return result;
829
1227
  }
830
1228
  }
@@ -835,10 +1233,10 @@ class AuditedUserRepository extends Repository<IUser> {
835
1233
  For simple cases without custom methods:
836
1234
 
837
1235
  ```javascript
838
- import { createRepository, timestampPlugin } from '@classytic/mongokit';
1236
+ import { createRepository, timestampPlugin } from "@classytic/mongokit";
839
1237
 
840
1238
  const userRepo = createRepository(UserModel, [timestampPlugin()], {
841
- defaultLimit: 20
1239
+ defaultLimit: 20,
842
1240
  });
843
1241
  ```
844
1242
 
@@ -849,7 +1247,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
849
1247
  - Uses its own event system (not Mongoose middleware)
850
1248
  - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
851
1249
  - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
852
- - All 547 tests pass on Mongoose 9
1250
+ - All 597 tests pass on Mongoose 9
853
1251
 
854
1252
  ## License
855
1253