@classytic/mongokit 3.0.1 → 3.0.2

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
@@ -1,429 +1,111 @@
1
1
  # @classytic/mongokit
2
2
 
3
- [![Test](https://github.com/classytic/mongokit/actions/workflows/test.yml/badge.svg)](https://github.com/classytic/mongokit/actions/workflows/test.yml)
4
3
  [![npm version](https://badge.fury.io/js/@classytic%2Fmongokit.svg)](https://www.npmjs.com/package/@classytic/mongokit)
5
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
5
 
7
- > Production-grade MongoDB repositories with zero external dependencies
6
+ > Production-grade MongoDB repository pattern with zero external dependencies
8
7
 
9
- **Works with:** Express Fastify NestJS Next.js Koa Hapi Serverless
8
+ **Works with:** Express, Fastify, NestJS, Next.js, Koa, Hapi, Serverless
10
9
 
11
- - ✅ **Zero external dependencies** (only Mongoose peer dependency)
12
- - ✅ **Smart pagination** - auto-detects offset vs cursor-based
13
- - ✅ **HTTP utilities** - query parser & schema generator for controllers
14
- - ✅ **Event-driven** hooks for every operation
15
- - ✅ **Plugin architecture** for reusable behaviors
16
- - ✅ **TypeScript** first-class support with discriminated unions
17
- - ✅ **Optional caching** - Redis/Memcached with auto-invalidation
18
- - ✅ **Battle-tested** in production with 187 passing tests
10
+ ## Features
19
11
 
20
- ---
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
+ - **12 built-in plugins** - Caching, soft delete, validation, audit logs, and more
16
+ - **TypeScript first** - Full type safety with discriminated unions
17
+ - **194 passing tests** - Battle-tested and production-ready
21
18
 
22
- ## 📦 Installation
19
+ ## Installation
23
20
 
24
21
  ```bash
25
22
  npm install @classytic/mongokit mongoose
26
23
  ```
27
24
 
28
- > **Peer Dependencies:**
29
- > - `mongoose ^8.0.0 || ^9.0.0` (supports both Mongoose 8 and 9)
25
+ > Supports Mongoose `^8.0.0` and `^9.0.0`
30
26
 
31
- **Available imports:**
32
- ```javascript
33
- import { MongooseRepository } from '@classytic/mongokit'; // Core repository
34
- import { queryParser, buildCrudSchemasFromModel } from '@classytic/mongokit/utils'; // HTTP utilities
35
- ```
36
-
37
- **That's it.** No additional pagination, validation, or query parsing libraries needed.
38
-
39
- ---
40
-
41
- ## 🚀 Quick Start
42
-
43
- ### Basic Usage
27
+ ## Quick Start
44
28
 
45
29
  ```javascript
46
30
  import { Repository } from '@classytic/mongokit';
47
31
  import UserModel from './models/User.js';
48
32
 
49
- class UserRepository extends Repository {
50
- constructor() {
51
- super(UserModel);
52
- }
53
- }
54
-
55
- const userRepo = new UserRepository();
33
+ const userRepo = new Repository(UserModel);
56
34
 
57
35
  // Create
58
- const user = await userRepo.create({
59
- name: 'John',
60
- email: 'john@example.com'
61
- });
36
+ const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
62
37
 
63
- // Read - auto-detects pagination mode
64
- const users = await userRepo.getAll({
65
- page: 1,
66
- limit: 20
67
- });
38
+ // Read with auto-detected pagination
39
+ const users = await userRepo.getAll({ page: 1, limit: 20 });
68
40
 
69
41
  // Update
70
- await userRepo.update('user-id', { name: 'Jane' });
42
+ await userRepo.update(user._id, { name: 'Jane' });
71
43
 
72
44
  // Delete
73
- await userRepo.delete('user-id');
45
+ await userRepo.delete(user._id);
74
46
  ```
75
47
 
76
- ### Unified Pagination - One Method, Two Modes
48
+ ## Pagination
77
49
 
78
- The `getAll()` method automatically detects whether you want **offset** (page-based) or **keyset** (cursor-based) pagination:
50
+ `getAll()` automatically detects pagination mode based on parameters:
79
51
 
80
52
  ```javascript
81
- // Offset pagination (page-based) - for admin dashboards
82
- const page1 = await userRepo.getAll({
53
+ // Offset pagination (page-based) - for dashboards
54
+ const result = await repo.getAll({
83
55
  page: 1,
84
56
  limit: 20,
85
57
  filters: { status: 'active' },
86
58
  sort: { createdAt: -1 }
87
59
  });
88
- // → { method: 'offset', docs: [...], total: 1523, pages: 77, page: 1, ... }
60
+ // → { method: 'offset', docs, total, pages, hasNext, hasPrev }
89
61
 
90
62
  // Keyset pagination (cursor-based) - for infinite scroll
91
- const stream1 = await userRepo.getAll({
63
+ const stream = await repo.getAll({
92
64
  sort: { createdAt: -1 },
93
65
  limit: 20
94
66
  });
95
- // → { method: 'keyset', docs: [...], hasMore: true, next: 'eyJ2IjoxLCJ0Ij...' }
67
+ // → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
96
68
 
97
- // Load next page with cursor
98
- const stream2 = await userRepo.getAll({
99
- after: stream1.next,
69
+ // Next page with cursor
70
+ const next = await repo.getAll({
71
+ after: stream.next,
100
72
  sort: { createdAt: -1 },
101
73
  limit: 20
102
74
  });
103
75
  ```
104
76
 
105
- **Auto-detection logic:**
106
- 1. If `page` parameter provided **offset mode**
107
- 2. If `after` or `cursor` parameter provided **keyset mode**
108
- 3. If explicit `sort` provided without `page` → **keyset mode** (first page)
109
- 4. Otherwise**offset mode** (default, page 1)
110
-
111
- ---
112
-
113
- ## 🎯 Pagination Modes Explained
114
-
115
- ### Offset Pagination (Page-Based)
116
-
117
- Best for: Admin dashboards, page numbers, showing total counts
118
-
119
- ```javascript
120
- const result = await userRepo.getAll({
121
- page: 1,
122
- limit: 20,
123
- filters: { status: 'active' },
124
- sort: { createdAt: -1 }
125
- });
126
-
127
- console.log(result.method); // 'offset'
128
- console.log(result.docs); // Array of documents
129
- console.log(result.total); // Total count (e.g., 1523)
130
- console.log(result.pages); // Total pages (e.g., 77)
131
- console.log(result.page); // Current page (1)
132
- console.log(result.hasNext); // true
133
- console.log(result.hasPrev); // false
134
- ```
135
-
136
- **Performance:**
137
- - Time complexity: O(n) where n = page × limit
138
- - Works great for small-medium datasets
139
- - Warning triggered for pages > 100
140
-
141
- ### Keyset Pagination (Cursor-Based)
142
-
143
- Best for: Infinite scroll, real-time feeds, large datasets
144
-
145
- ```javascript
146
- const result = await userRepo.getAll({
147
- sort: { createdAt: -1 },
148
- limit: 20
149
- });
150
-
151
- console.log(result.method); // 'keyset'
152
- console.log(result.docs); // Array of documents
153
- console.log(result.hasMore); // true
154
- console.log(result.next); // 'eyJ2IjoxLCJ0IjoiZGF0ZSIsInYiO...'
155
-
156
- // Load next page
157
- const next = await userRepo.getAll({
158
- after: result.next,
159
- sort: { createdAt: -1 },
160
- limit: 20
161
- });
162
- ```
77
+ **Auto-detection rules:**
78
+ - `page` parameter → offset mode
79
+ - `after`/`cursor` parameter → keyset mode
80
+ - `sort` without `page` → keyset mode (first page)
81
+ - Default → offset mode (page 1)
163
82
 
164
- **Performance:**
165
- - Time complexity: O(1) regardless of position
166
- - Requires compound index: `{ sortField: 1, _id: 1 }`
167
- - Ideal for millions of documents
83
+ ### Required Indexes
168
84
 
169
- **Required Index:**
170
85
  ```javascript
171
- // For sort: { createdAt: -1 }
86
+ // For keyset pagination: sort field + _id
172
87
  PostSchema.index({ createdAt: -1, _id: -1 });
173
88
 
174
- // For sort: { publishedAt: -1, views: -1 }
175
- PostSchema.index({ publishedAt: -1, views: -1, _id: -1 });
176
- ```
177
-
178
- ---
179
-
180
- ## 💡 Real-World Examples
181
-
182
- ### Text Search + Infinite Scroll
183
-
184
- ```javascript
185
- // Define schema with text index
186
- const PostSchema = new mongoose.Schema({
187
- title: String,
188
- content: String,
189
- publishedAt: { type: Date, default: Date.now }
190
- });
191
-
192
- PostSchema.index({ title: 'text', content: 'text' });
193
- PostSchema.index({ publishedAt: -1, _id: -1 });
194
-
195
- // Search and paginate
196
- const postRepo = new Repository(PostModel);
197
-
198
- const page1 = await postRepo.getAll({
199
- search: 'JavaScript',
200
- sort: { publishedAt: -1 },
201
- limit: 20
202
- });
203
- // → Returns first 20 posts matching "JavaScript"
204
-
205
- // User scrolls down - load more
206
- const page2 = await postRepo.getAll({
207
- after: page1.next,
208
- search: 'JavaScript',
209
- sort: { publishedAt: -1 },
210
- limit: 20
211
- });
212
- // → Next 20 posts with same search query
213
- ```
214
-
215
- ### Admin Dashboard with Filters
216
-
217
- ```javascript
218
- const result = await userRepo.getAll({
219
- page: req.query.page || 1,
220
- limit: 50,
221
- filters: {
222
- status: 'active',
223
- role: { $in: ['admin', 'moderator'] }
224
- },
225
- sort: { lastLoginAt: -1 }
226
- });
227
-
228
- res.json({
229
- users: result.docs,
230
- pagination: {
231
- page: result.page,
232
- pages: result.pages,
233
- total: result.total,
234
- hasNext: result.hasNext,
235
- hasPrev: result.hasPrev
236
- }
237
- });
238
- ```
239
-
240
- ### Multi-Tenant Applications
241
-
242
- ```javascript
243
- class TenantUserRepository extends Repository {
244
- constructor() {
245
- super(UserModel, [], {
246
- defaultLimit: 20,
247
- maxLimit: 100
248
- });
249
- }
250
-
251
- async getAllForTenant(organizationId, params = {}) {
252
- return this.getAll({
253
- ...params,
254
- filters: {
255
- organizationId,
256
- ...params.filters
257
- }
258
- });
259
- }
260
- }
261
-
262
- // Use it
263
- const users = await tenantRepo.getAllForTenant('org-123', {
264
- page: 1,
265
- limit: 50,
266
- filters: { status: 'active' }
267
- });
268
- ```
269
-
270
- ### Switching Between Modes Seamlessly
271
-
272
- ```javascript
273
- // Admin view - needs page numbers and total count
274
- const adminView = await postRepo.getAll({
275
- page: 1,
276
- limit: 20,
277
- sort: { createdAt: -1 }
278
- });
279
- // → method: 'offset', total: 1523, pages: 77
280
-
281
- // Public feed - infinite scroll
282
- const feedView = await postRepo.getAll({
283
- sort: { createdAt: -1 },
284
- limit: 20
285
- });
286
- // → method: 'keyset', next: 'eyJ2IjoxLC...'
287
-
288
- // Both return same first 20 results!
289
- ```
290
-
291
- ---
292
-
293
- ## 🌐 HTTP Utilities for Controllers & Routes
294
-
295
- MongoKit provides utilities to quickly build production-ready controllers and routes for Express, Fastify, NestJS, and other frameworks.
296
-
297
- ### Query Parser
298
-
299
- Parse HTTP query strings into MongoDB filters automatically:
300
-
301
- ```javascript
302
- import { queryParser } from '@classytic/mongokit/utils';
303
-
304
- // Express/Fastify route
305
- app.get('/users', async (req, res) => {
306
- const { filters, limit, page, sort } = queryParser.parseQuery(req.query);
307
-
308
- const result = await userRepo.getAll({ filters, limit, page, sort });
309
- res.json(result);
310
- });
311
- ```
312
-
313
- **Supported query patterns:**
314
-
315
- ```bash
316
- # Simple filtering
317
- GET /users?email=john@example.com&role=admin
318
-
319
- # Operators
320
- GET /users?age[gte]=18&age[lte]=65 # Range queries
321
- GET /users?email[contains]=gmail # Text search
322
- GET /users?role[in]=admin,user # Multiple values
323
- GET /users?status[ne]=deleted # Not equal
324
-
325
- # Pagination
326
- GET /users?page=2&limit=50 # Offset pagination
327
- GET /users?after=eyJfaWQiOiI2M... # Cursor pagination
328
-
329
- # Sorting
330
- GET /users?sort=-createdAt,name # Multi-field sort (- = descending)
331
-
332
- # Combined
333
- GET /users?role=admin&createdAt[gte]=2024-01-01&sort=-createdAt&limit=20
334
- ```
335
-
336
- ### Schema Generator (Fastify/OpenAPI)
337
-
338
- Generate JSON schemas from Mongoose models with field rules:
339
-
340
- ```javascript
341
- import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
342
-
343
- const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
344
- strictAdditionalProperties: true, // Reject unknown fields
345
- fieldRules: {
346
- organizationId: { immutable: true }, // Cannot be updated
347
- status: { systemManaged: true }, // Omitted from create/update
348
- email: { optional: false }, // Required field
349
- },
350
- create: {
351
- omitFields: ['verifiedAt'], // Custom omissions
352
- },
353
- });
354
-
355
- // Use in Fastify routes
356
- fastify.post('/users', {
357
- schema: crudSchemas.create,
358
- }, async (request, reply) => {
359
- const user = await userRepo.create(request.body);
360
- return reply.status(201).send(user);
361
- });
362
-
363
- fastify.get('/users', {
364
- schema: crudSchemas.list,
365
- }, async (request, reply) => {
366
- const { filters, limit, page, sort } = queryParser.parseQuery(request.query);
367
- const result = await userRepo.getAll({ filters, limit, page, sort });
368
- return reply.send(result);
369
- });
89
+ // For multi-tenant: tenant + sort field + _id
90
+ UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
370
91
  ```
371
92
 
372
- **Generated schemas:**
373
- - `crudSchemas.create` - POST validation (body only)
374
- - `crudSchemas.update` - PATCH validation (body + params)
375
- - `crudSchemas.get` - GET by ID validation (params)
376
- - `crudSchemas.list` - GET list validation (query)
377
- - `crudSchemas.remove` - DELETE validation (params)
378
-
379
- **Field Rules:**
380
- - `immutable` - Field cannot be updated after creation (omitted from update schema)
381
- - `systemManaged` - System-only field (omitted from both create and update schemas)
382
- - `optional` - Remove from required array
383
-
384
- **See full example:** [`examples/fastify-controller-example.js`](examples/fastify-controller-example.js)
385
-
386
- ---
387
-
388
- ## 📘 Complete API Reference
93
+ ## API Reference
389
94
 
390
95
  ### CRUD Operations
391
96
 
392
- | Method | Description | Example |
393
- |--------|-------------|---------|
394
- | `create(data, opts)` | Create single document | `repo.create({ name: 'John' })` |
395
- | `createMany(data[], opts)` | Create multiple documents | `repo.createMany([{...}, {...}])` |
396
- | `getById(id, opts)` | Find by ID | `repo.getById('123')` |
397
- | `getByQuery(query, opts)` | Find one by query | `repo.getByQuery({ email: 'a@b.com' })` |
398
- | `getAll(params, opts)` | Paginated list | `repo.getAll({ page: 1, limit: 20 })` |
399
- | `getOrCreate(query, data, opts)` | Find or create | `repo.getOrCreate({ email }, { email, name })` |
400
- | `update(id, data, opts)` | Update document | `repo.update('123', { name: 'Jane' })` |
401
- | `delete(id, opts)` | Delete document | `repo.delete('123')` |
402
- | `count(query, opts)` | Count documents | `repo.count({ status: 'active' })` |
403
- | `exists(query, opts)` | Check existence | `repo.exists({ email: 'a@b.com' })` |
404
-
405
- ### getAll() Parameters
406
-
407
- ```javascript
408
- await repo.getAll({
409
- // Pagination mode (auto-detected)
410
- page: 1, // Offset mode: page number
411
- after: 'cursor...', // Keyset mode: cursor token
412
- cursor: 'cursor...', // Alias for 'after'
413
-
414
- // Common parameters
415
- limit: 20, // Documents per page
416
- filters: { ... }, // MongoDB query filters
417
- sort: { createdAt: -1 }, // Sort specification
418
- search: 'keyword', // Full-text search (requires text index)
419
-
420
- // Additional options (in options parameter)
421
- select: 'name email', // Field projection
422
- populate: 'author', // Population
423
- lean: true, // Return plain objects (default: true)
424
- session: session // Transaction session
425
- });
426
- ```
97
+ | Method | Description |
98
+ |--------|-------------|
99
+ | `create(data, opts)` | Create single document |
100
+ | `createMany(data[], opts)` | Create multiple documents |
101
+ | `getById(id, opts)` | Find by ID |
102
+ | `getByQuery(query, opts)` | Find one by query |
103
+ | `getAll(params, opts)` | Paginated list (auto-detects mode) |
104
+ | `getOrCreate(query, data, opts)` | Find or create |
105
+ | `update(id, data, opts)` | Update document |
106
+ | `delete(id, opts)` | Delete document |
107
+ | `count(query, opts)` | Count documents |
108
+ | `exists(query, opts)` | Check existence |
427
109
 
428
110
  ### Aggregation
429
111
 
@@ -436,10 +118,7 @@ const result = await repo.aggregate([
436
118
 
437
119
  // Paginated aggregation
438
120
  const result = await repo.aggregatePaginate({
439
- pipeline: [
440
- { $match: { status: 'active' } },
441
- { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }
442
- ],
121
+ pipeline: [...],
443
122
  page: 1,
444
123
  limit: 20
445
124
  });
@@ -454,222 +133,98 @@ const categories = await repo.distinct('category', { status: 'active' });
454
133
  await repo.withTransaction(async (session) => {
455
134
  await repo.create({ name: 'User 1' }, { session });
456
135
  await repo.create({ name: 'User 2' }, { session });
457
- // Auto-commits if no errors, auto-rollbacks on errors
136
+ // Auto-commits on success, auto-rollbacks on error
458
137
  });
459
138
  ```
460
139
 
461
- ---
462
-
463
- ## 🔧 Configuration
464
-
465
- ### Pagination Configuration
140
+ ## Configuration
466
141
 
467
142
  ```javascript
468
- import { Repository } from '@classytic/mongokit';
469
-
470
- const userRepo = new Repository(UserModel, [], {
471
- defaultLimit: 20, // Default documents per page
143
+ const repo = new Repository(UserModel, plugins, {
144
+ defaultLimit: 20, // Default docs per page
472
145
  maxLimit: 100, // Maximum allowed limit
473
- maxPage: 10000, // Maximum page number (offset mode)
146
+ maxPage: 10000, // Maximum page number
474
147
  deepPageThreshold: 100, // Warn when page exceeds this
475
- useEstimatedCount: false, // Use estimatedDocumentCount() for speed
148
+ useEstimatedCount: false, // Use fast estimated counts
476
149
  cursorVersion: 1 // Cursor format version
477
150
  });
478
151
  ```
479
152
 
480
- ### Estimated Counts (for large collections)
481
-
482
- For collections with millions of documents, counting can be slow. Use estimated counts:
483
-
484
- ```javascript
485
- const repo = new Repository(UserModel, [], {
486
- useEstimatedCount: true // O(1) metadata lookup instead of O(n) count
487
- });
488
-
489
- const result = await repo.getAll({ page: 1, limit: 20 });
490
- // Uses estimatedDocumentCount() - instant but approximate
491
- ```
492
-
493
- **Note:** Estimated counts ignore filters and sessions by design (reads metadata, not documents).
494
-
495
- ---
496
-
497
- ## 📊 Indexing Guide
498
-
499
- **Critical:** MongoDB only auto-indexes `_id`. You must create indexes for efficient pagination.
500
-
501
- ### Single-Tenant Applications
502
-
503
- ```javascript
504
- const PostSchema = new mongoose.Schema({
505
- title: String,
506
- publishedAt: { type: Date, default: Date.now }
507
- });
508
-
509
- // Required for keyset pagination
510
- PostSchema.index({ publishedAt: -1, _id: -1 });
511
- // ^^^^^^^^^^^^^^ ^^^^^^
512
- // Sort field Tie-breaker
513
- ```
514
-
515
- ### Multi-Tenant Applications
516
-
517
- ```javascript
518
- const UserSchema = new mongoose.Schema({
519
- organizationId: String,
520
- email: String,
521
- createdAt: { type: Date, default: Date.now }
522
- });
523
-
524
- // Required for multi-tenant keyset pagination
525
- UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
526
- // ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^
527
- // Tenant filter Sort field Tie-breaker
528
- ```
529
-
530
- ### Common Index Patterns
531
-
532
- ```javascript
533
- // Basic sorting
534
- Schema.index({ createdAt: -1, _id: -1 });
535
-
536
- // Multi-tenant
537
- Schema.index({ tenantId: 1, createdAt: -1, _id: -1 });
538
-
539
- // Multi-tenant + status filter
540
- Schema.index({ tenantId: 1, status: 1, createdAt: -1, _id: -1 });
541
-
542
- // Text search
543
- Schema.index({ title: 'text', content: 'text' });
544
- Schema.index({ createdAt: -1, _id: -1 }); // Still need this for sorting
545
-
546
- // Multi-field sort
547
- Schema.index({ priority: -1, createdAt: -1, _id: -1 });
548
- ```
549
-
550
- ### Performance Impact
551
-
552
- | Scenario | Without Index | With Index |
553
- |----------|--------------|------------|
554
- | 10K docs | ~50ms | ~5ms |
555
- | 1M docs | ~5000ms | ~5ms |
556
- | 100M docs | timeout | ~5ms |
153
+ ## Plugins
557
154
 
558
- **Rule:** Index = (tenant_field +) sort_field + _id
559
-
560
- ---
561
-
562
- ## 🔌 Built-in Plugins
563
-
564
- ### Field Filtering (Role-based Access)
565
-
566
- Control which fields are visible based on user roles:
567
-
568
- ```javascript
569
- import { Repository, fieldFilterPlugin } from '@classytic/mongokit';
570
-
571
- const fieldPreset = {
572
- public: ['id', 'name', 'email'],
573
- authenticated: ['phone', 'address'],
574
- admin: ['createdAt', 'updatedAt', 'internalNotes']
575
- };
576
-
577
- class UserRepository extends Repository {
578
- constructor() {
579
- super(UserModel, [fieldFilterPlugin(fieldPreset)]);
580
- }
581
- }
582
- ```
583
-
584
- ### Validation Chain
585
-
586
- Add custom validation rules:
155
+ ### Using Plugins
587
156
 
588
157
  ```javascript
589
158
  import {
590
159
  Repository,
591
- validationChainPlugin,
592
- requireField,
593
- uniqueField,
594
- immutableField
160
+ timestampPlugin,
161
+ softDeletePlugin,
162
+ cachePlugin,
163
+ createMemoryCache
595
164
  } from '@classytic/mongokit';
596
165
 
597
- class UserRepository extends Repository {
598
- constructor() {
599
- super(UserModel, [
600
- validationChainPlugin([
601
- requireField('email', ['create']),
602
- uniqueField('email', 'Email already exists'),
603
- immutableField('userId')
604
- ])
605
- ]);
606
- }
607
- }
166
+ const repo = new Repository(UserModel, [
167
+ timestampPlugin(),
168
+ softDeletePlugin(),
169
+ cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
170
+ ]);
608
171
  ```
609
172
 
610
- ### Soft Delete
611
-
612
- Mark records as deleted without actually removing them:
613
-
614
- ```javascript
615
- import { Repository, softDeletePlugin } from '@classytic/mongokit';
616
-
617
- class UserRepository extends Repository {
618
- constructor() {
619
- super(UserModel, [softDeletePlugin({ deletedField: 'deletedAt' })]);
620
- }
621
- }
173
+ ### Available Plugins
622
174
 
623
- // repo.delete(id) marks as deleted instead of removing
624
- // repo.getAll() → excludes deleted records
625
- // repo.getAll({ includeDeleted: true }) → includes deleted
626
- ```
627
-
628
- ### Audit Logging
175
+ | Plugin | Description |
176
+ |--------|-------------|
177
+ | `timestampPlugin()` | Auto-manage `createdAt`/`updatedAt` |
178
+ | `softDeletePlugin(opts)` | Mark as deleted instead of removing |
179
+ | `auditLogPlugin(logger)` | Log all CUD operations |
180
+ | `cachePlugin(opts)` | Redis/Memcached/memory caching with auto-invalidation |
181
+ | `validationChainPlugin(validators)` | Custom validation rules |
182
+ | `fieldFilterPlugin(preset)` | Role-based field visibility |
183
+ | `cascadePlugin(opts)` | Auto-delete related documents |
184
+ | `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
185
+ | `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
186
+ | `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany` |
187
+ | `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
188
+ | `subdocumentPlugin()` | Manage subdocument arrays |
629
189
 
630
- Log all create, update, and delete operations:
190
+ ### Soft Delete
631
191
 
632
192
  ```javascript
633
- import { Repository, auditLogPlugin } from '@classytic/mongokit';
634
- import logger from './logger.js';
635
-
636
- class UserRepository extends Repository {
637
- constructor() {
638
- super(UserModel, [auditLogPlugin(logger)]);
639
- }
640
- }
193
+ const repo = new Repository(UserModel, [
194
+ softDeletePlugin({ deletedField: 'deletedAt' })
195
+ ]);
641
196
 
642
- // All CUD operations automatically logged
197
+ await repo.delete(id); // Marks as deleted
198
+ await repo.getAll(); // Excludes deleted
199
+ await repo.getAll({ includeDeleted: true }); // Includes deleted
643
200
  ```
644
201
 
645
- ### Caching (Redis, Memcached, or In-Memory)
646
-
647
- Add caching with automatic invalidation on mutations:
202
+ ### Caching
648
203
 
649
204
  ```javascript
650
- import { Repository, cachePlugin, createMemoryCache } from '@classytic/mongokit';
205
+ import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
651
206
 
652
- const userRepo = new Repository(UserModel, [
207
+ const repo = new Repository(UserModel, [
653
208
  cachePlugin({
654
- adapter: createMemoryCache(), // or your Redis adapter
655
- ttl: 60, // 60 seconds default
656
- byIdTtl: 300, // 5 min for getById
657
- queryTtl: 30, // 30s for lists
209
+ adapter: createMemoryCache(), // or Redis adapter
210
+ ttl: 60, // Default TTL (seconds)
211
+ byIdTtl: 300, // TTL for getById
212
+ queryTtl: 30, // TTL for lists
658
213
  })
659
214
  ]);
660
215
 
661
216
  // Reads are cached automatically
662
- const user = await userRepo.getById(id); // cached on second call
217
+ const user = await repo.getById(id);
663
218
 
664
219
  // Skip cache for fresh data
665
- const fresh = await userRepo.getById(id, { skipCache: true });
220
+ const fresh = await repo.getById(id, { skipCache: true });
666
221
 
667
222
  // Mutations auto-invalidate cache
668
- await userRepo.update(id, { name: 'New' });
223
+ await repo.update(id, { name: 'New' });
669
224
 
670
- // Manual invalidation (microservices)
671
- await userRepo.invalidateCache(id); // single doc
672
- await userRepo.invalidateAllCache(); // full model
225
+ // Manual invalidation
226
+ await repo.invalidateCache(id);
227
+ await repo.invalidateAllCache();
673
228
  ```
674
229
 
675
230
  **Redis adapter example:**
@@ -678,412 +233,231 @@ const redisAdapter = {
678
233
  async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
679
234
  async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
680
235
  async del(key) { await redis.del(key); },
681
- async clear(pattern) { /* optional: bulk delete by pattern */ }
236
+ async clear(pattern) { /* optional bulk delete */ }
682
237
  };
683
238
  ```
684
239
 
685
- ### Cascade Delete
240
+ ### Validation Chain
241
+
242
+ ```javascript
243
+ import {
244
+ validationChainPlugin,
245
+ requireField,
246
+ uniqueField,
247
+ immutableField,
248
+ blockIf,
249
+ autoInject
250
+ } from '@classytic/mongokit';
251
+
252
+ const repo = new Repository(UserModel, [
253
+ validationChainPlugin([
254
+ requireField('email', ['create']),
255
+ uniqueField('email', 'Email already exists'),
256
+ immutableField('userId'),
257
+ blockIf('noAdminDelete', ['delete'],
258
+ (ctx) => ctx.data?.role === 'admin',
259
+ 'Cannot delete admin users'),
260
+ autoInject('slug', (ctx) => slugify(ctx.data?.name), ['create'])
261
+ ])
262
+ ]);
263
+ ```
686
264
 
687
- Automatically delete related documents when a parent is deleted:
265
+ ### Cascade Delete
688
266
 
689
267
  ```javascript
690
- import { Repository, cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
268
+ import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
691
269
 
692
- const productRepo = new Repository(ProductModel, [
693
- softDeletePlugin(), // optional - cascade respects soft delete behavior
270
+ const repo = new Repository(ProductModel, [
271
+ softDeletePlugin(),
694
272
  cascadePlugin({
695
273
  relations: [
696
274
  { model: 'StockEntry', foreignKey: 'product' },
697
- { model: 'StockMovement', foreignKey: 'product' },
275
+ { model: 'Review', foreignKey: 'product', softDelete: false }
698
276
  ],
699
- parallel: true, // default, runs cascade deletes in parallel
700
- logger: console, // optional logging
277
+ parallel: true,
278
+ logger: console
701
279
  })
702
280
  ]);
703
281
 
704
- // When product is deleted, all related StockEntry and StockMovement docs are also deleted
705
- await productRepo.delete(productId);
282
+ // Deleting product also deletes related StockEntry and Review docs
283
+ await repo.delete(productId);
706
284
  ```
707
285
 
708
- **Options:**
709
- - `relations` - Array of related models to cascade delete
710
- - `parallel` - Run cascade deletes in parallel (default: `true`)
711
- - `logger` - Optional logger for debugging
712
- - Per-relation `softDelete` - Override soft delete behavior per relation
713
-
714
- ### More Plugins
715
-
716
- - **`timestampPlugin()`** - Auto-manage `createdAt`/`updatedAt`
717
- - **`mongoOperationsPlugin()`** - Adds `increment`, `pushToArray`, `upsert`, etc.
718
- - **`batchOperationsPlugin()`** - Adds `updateMany`, `deleteMany`
719
- - **`aggregateHelpersPlugin()`** - Adds `groupBy`, `sum`, `average`, etc.
720
- - **`subdocumentPlugin()`** - Manage subdocument arrays easily
721
- - **`cascadePlugin()`** - Auto-delete related documents on parent delete
286
+ ### Field Filtering (RBAC)
722
287
 
723
- ---
288
+ ```javascript
289
+ import { fieldFilterPlugin } from '@classytic/mongokit';
724
290
 
725
- ## 🎨 Event System
291
+ const repo = new Repository(UserModel, [
292
+ fieldFilterPlugin({
293
+ public: ['id', 'name', 'avatar'],
294
+ authenticated: ['email', 'phone'],
295
+ admin: ['createdAt', 'internalNotes']
296
+ })
297
+ ]);
298
+ ```
726
299
 
727
- Every operation emits lifecycle events:
300
+ ## Event System
728
301
 
729
302
  ```javascript
730
303
  repo.on('before:create', async (context) => {
731
- console.log('About to create:', context.data);
732
- // Modify context.data if needed
733
304
  context.data.processedAt = new Date();
734
305
  });
735
306
 
736
307
  repo.on('after:create', ({ context, result }) => {
737
308
  console.log('Created:', result);
738
- // Send notification, update cache, etc.
739
309
  });
740
310
 
741
311
  repo.on('error:create', ({ context, error }) => {
742
- console.error('Failed to create:', error);
743
- // Log error, send alert, etc.
312
+ console.error('Failed:', error);
744
313
  });
745
314
  ```
746
315
 
747
- **Available Events:**
748
- - `before:create`, `after:create`, `error:create`
749
- - `before:update`, `after:update`, `error:update`
750
- - `before:delete`, `after:delete`, `error:delete`
751
- - `before:createMany`, `after:createMany`, `error:createMany`
752
- - `before:getAll`, `before:getById`, `before:getByQuery`
753
-
754
- ---
316
+ **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
755
317
 
756
- ## 🎯 Custom Plugins
318
+ ## HTTP Utilities
757
319
 
758
- Create your own plugins:
320
+ ### Query Parser
759
321
 
760
322
  ```javascript
761
- export const timestampPlugin = () => ({
762
- name: 'timestamp',
763
-
764
- apply(repo) {
765
- repo.on('before:create', (context) => {
766
- context.data.createdAt = new Date();
767
- context.data.updatedAt = new Date();
768
- });
323
+ import { queryParser } from '@classytic/mongokit/utils';
769
324
 
770
- repo.on('before:update', (context) => {
771
- context.data.updatedAt = new Date();
772
- });
773
- }
325
+ app.get('/users', async (req, res) => {
326
+ const { filters, limit, page, sort } = queryParser.parseQuery(req.query);
327
+ const result = await userRepo.getAll({ filters, limit, page, sort });
328
+ res.json(result);
774
329
  });
330
+ ```
775
331
 
776
- // Use it
777
- class UserRepository extends Repository {
778
- constructor() {
779
- super(UserModel, [timestampPlugin()]);
780
- }
781
- }
332
+ **Supported query patterns:**
333
+ ```bash
334
+ GET /users?email=john@example.com&role=admin
335
+ GET /users?age[gte]=18&age[lte]=65
336
+ GET /users?role[in]=admin,user
337
+ GET /users?sort=-createdAt,name&page=2&limit=50
782
338
  ```
783
339
 
784
- ### Combining Multiple Plugins
340
+ ### Schema Generator (Fastify/OpenAPI)
785
341
 
786
342
  ```javascript
787
- import {
788
- Repository,
789
- softDeletePlugin,
790
- auditLogPlugin,
791
- fieldFilterPlugin
792
- } from '@classytic/mongokit';
343
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
793
344
 
794
- class UserRepository extends Repository {
795
- constructor() {
796
- super(UserModel, [
797
- softDeletePlugin(),
798
- auditLogPlugin(logger),
799
- fieldFilterPlugin(userFieldPreset)
800
- ]);
345
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
346
+ fieldRules: {
347
+ organizationId: { immutable: true },
348
+ status: { systemManaged: true }
801
349
  }
802
- }
803
- ```
804
-
805
- ---
350
+ });
806
351
 
807
- ## 📚 TypeScript Support
352
+ fastify.post('/users', { schema: crudSchemas.create }, handler);
353
+ fastify.get('/users', { schema: crudSchemas.list }, handler);
354
+ ```
808
355
 
809
- Full TypeScript support with discriminated unions:
356
+ ## TypeScript
810
357
 
811
358
  ```typescript
812
- import {
813
- Repository,
814
- OffsetPaginationResult,
815
- KeysetPaginationResult
816
- } from '@classytic/mongokit';
817
- import { Document } from 'mongoose';
359
+ import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
818
360
 
819
361
  interface IUser extends Document {
820
362
  name: string;
821
363
  email: string;
822
- status: 'active' | 'inactive';
823
364
  }
824
365
 
825
- class UserRepository extends Repository {
826
- constructor() {
827
- super(UserModel);
828
- }
366
+ const repo = new Repository<IUser>(UserModel);
829
367
 
830
- async findActive(): Promise<IUser[]> {
831
- const result = await this.getAll({
832
- filters: { status: 'active' },
833
- page: 1,
834
- limit: 50
835
- });
836
-
837
- // TypeScript knows result is OffsetPaginationResult
838
- if (result.method === 'offset') {
839
- console.log(result.total); // ✅ Type-safe
840
- console.log(result.pages); // ✅ Type-safe
841
- // console.log(result.next); // ❌ Type error
842
- }
843
-
844
- return result.docs;
845
- }
846
-
847
- async getFeed(): Promise<IUser[]> {
848
- const result = await this.getAll({
849
- sort: { createdAt: -1 },
850
- limit: 20
851
- });
852
-
853
- // TypeScript knows result is KeysetPaginationResult
854
- if (result.method === 'keyset') {
855
- console.log(result.next); // ✅ Type-safe
856
- console.log(result.hasMore); // ✅ Type-safe
857
- // console.log(result.total); // ❌ Type error
858
- }
368
+ const result = await repo.getAll({ page: 1, limit: 20 });
859
369
 
860
- return result.docs;
861
- }
370
+ // Discriminated union - TypeScript knows the type
371
+ if (result.method === 'offset') {
372
+ console.log(result.total, result.pages); // Available
373
+ }
374
+ if (result.method === 'keyset') {
375
+ console.log(result.next, result.hasMore); // Available
862
376
  }
863
377
  ```
864
378
 
865
- ### Import Types
866
-
867
- ```typescript
868
- import type {
869
- PaginationConfig,
870
- OffsetPaginationOptions,
871
- KeysetPaginationOptions,
872
- AggregatePaginationOptions,
873
- OffsetPaginationResult,
874
- KeysetPaginationResult,
875
- AggregatePaginationResult
876
- } from '@classytic/mongokit';
877
- ```
878
-
879
- ---
880
-
881
- ## 🏎️ Performance Tips
882
-
883
- ### 1. Use Keyset Pagination for Large Datasets
884
-
885
- ```javascript
886
- // ❌ Slow for large datasets (millions of documents)
887
- await repo.getAll({ page: 1000, limit: 50 }); // O(50000)
888
-
889
- // ✅ Fast regardless of position
890
- await repo.getAll({ after: cursor, limit: 50 }); // O(1)
891
- ```
892
-
893
- ### 2. Create Required Indexes
894
-
895
- **IMPORTANT:** MongoDB only auto-indexes `_id`. You must manually create indexes for pagination.
896
-
897
- ```javascript
898
- // ✅ Single-Tenant: Sort field + _id
899
- PostSchema.index({ createdAt: -1, _id: -1 });
900
-
901
- // ✅ Multi-Tenant: Tenant field + Sort field + _id
902
- UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
903
-
904
- // ✅ Text Search: Text index
905
- PostSchema.index({ title: 'text', content: 'text' });
906
- ```
907
-
908
- **Without indexes = slow (full collection scan)**
909
- **With indexes = fast (O(1) index seek)**
379
+ ## Extending Repository
910
380
 
911
- ### 3. Use Estimated Counts for Large Collections
381
+ Create custom repository classes with domain-specific methods:
912
382
 
913
- ```javascript
914
- const repo = new Repository(UserModel, [], {
915
- useEstimatedCount: true // Instant counts for >10M documents
916
- });
917
- ```
383
+ ```typescript
384
+ import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
385
+ import UserModel, { IUser } from './models/User.js';
918
386
 
919
- ### 4. Use Lean Queries (Enabled by Default)
387
+ class UserRepository extends Repository<IUser> {
388
+ constructor() {
389
+ super(UserModel, [
390
+ timestampPlugin(),
391
+ softDeletePlugin()
392
+ ], {
393
+ defaultLimit: 20
394
+ });
395
+ }
920
396
 
921
- ```javascript
922
- // Lean is true by default - returns plain objects
923
- const result = await repo.getAll({ page: 1 });
397
+ // Custom domain methods
398
+ async findByEmail(email: string) {
399
+ return this.getByQuery({ email });
400
+ }
924
401
 
925
- // Disable for Mongoose documents (if you need methods)
926
- const result = await repo.getAll({ page: 1 }, { lean: false });
927
- ```
402
+ async findActiveUsers() {
403
+ return this.getAll({
404
+ filters: { status: 'active' },
405
+ sort: { createdAt: -1 }
406
+ });
407
+ }
928
408
 
929
- ### 5. Limit $facet Results in Aggregation
409
+ async deactivate(id: string) {
410
+ return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
411
+ }
412
+ }
930
413
 
931
- ```javascript
932
- // ⚠️ Warning triggered automatically at limit > 1000
933
- await repo.aggregatePaginate({
934
- pipeline: [...],
935
- limit: 2000 // Warning: $facet results must be <16MB
936
- });
414
+ // Usage
415
+ const userRepo = new UserRepository();
416
+ const user = await userRepo.findByEmail('john@example.com');
937
417
  ```
938
418
 
939
- ---
419
+ ### Overriding Methods
940
420
 
941
- ## 🔄 Migration Guide
942
-
943
- ### From mongoose-paginate-v2
421
+ ```typescript
422
+ class AuditedUserRepository extends Repository<IUser> {
423
+ constructor() {
424
+ super(UserModel);
425
+ }
944
426
 
945
- ```javascript
946
- // Before
947
- import mongoosePaginate from 'mongoose-paginate-v2';
948
- UserSchema.plugin(mongoosePaginate);
949
- const result = await UserModel.paginate({ status: 'active' }, { page: 1, limit: 10 });
427
+ // Override create to add audit trail
428
+ async create(data: Partial<IUser>, options = {}) {
429
+ const result = await super.create({
430
+ ...data,
431
+ createdBy: getCurrentUserId()
432
+ }, options);
950
433
 
951
- // After
952
- import { Repository } from '@classytic/mongokit';
953
- const userRepo = new Repository(UserModel);
954
- const result = await userRepo.getAll({
955
- filters: { status: 'active' },
956
- page: 1,
957
- limit: 10
958
- });
434
+ await auditLog('user.created', result._id);
435
+ return result;
436
+ }
437
+ }
959
438
  ```
960
439
 
961
- ### From Prisma
962
-
963
- ```javascript
964
- // Before (Prisma)
965
- const users = await prisma.user.findMany({
966
- where: { status: 'active' },
967
- skip: 20,
968
- take: 10
969
- });
970
-
971
- // After (MongoKit)
972
- const result = await userRepo.getAll({
973
- filters: { status: 'active' },
974
- page: 3,
975
- limit: 10
976
- });
977
- const users = result.docs;
978
- ```
440
+ ## Factory Function
979
441
 
980
- ### From TypeORM
442
+ For simple cases without custom methods:
981
443
 
982
444
  ```javascript
983
- // Before (TypeORM)
984
- const [users, total] = await userRepository.findAndCount({
985
- where: { status: 'active' },
986
- skip: 20,
987
- take: 10
988
- });
445
+ import { createRepository, timestampPlugin } from '@classytic/mongokit';
989
446
 
990
- // After (MongoKit)
991
- const result = await userRepo.getAll({
992
- filters: { status: 'active' },
993
- page: 3,
994
- limit: 10
447
+ const userRepo = createRepository(UserModel, [timestampPlugin()], {
448
+ defaultLimit: 20
995
449
  });
996
- const users = result.docs;
997
- const total = result.total;
998
- ```
999
-
1000
- ---
1001
-
1002
- ## 🌟 Why MongoKit?
1003
-
1004
- ### vs. Mongoose Directly
1005
- - ✅ Consistent API across all models
1006
- - ✅ Built-in pagination (offset + cursor) with zero dependencies
1007
- - ✅ Multi-tenancy without repetitive code
1008
- - ✅ Event hooks for cross-cutting concerns
1009
- - ✅ Plugin system for reusable behaviors
1010
-
1011
- ### vs. mongoose-paginate-v2
1012
- - ✅ Zero external dependencies (no mongoose-paginate-v2 needed)
1013
- - ✅ Cursor-based pagination for infinite scroll
1014
- - ✅ Unified API that auto-detects pagination mode
1015
- - ✅ Native MongoDB implementation ($facet, cursors)
1016
- - ✅ Better TypeScript support
1017
-
1018
- ### vs. TypeORM / Prisma
1019
- - ✅ Lighter weight (works with Mongoose)
1020
- - ✅ Event-driven architecture
1021
- - ✅ More flexible plugin system
1022
- - ✅ No migration needed if using Mongoose
1023
- - ✅ Framework-agnostic
1024
-
1025
- ### vs. Raw Repository Pattern
1026
- - ✅ Battle-tested implementation (187 passing tests)
1027
- - ✅ 12 built-in plugins ready to use
1028
- - ✅ Comprehensive documentation
1029
- - ✅ TypeScript discriminated unions
1030
- - ✅ Active maintenance
1031
-
1032
- ---
1033
-
1034
- ## 🧪 Testing
1035
-
1036
- ```bash
1037
- npm test
1038
450
  ```
1039
451
 
1040
- **Test Coverage:**
1041
- - 189 tests (187 passing, 2 skipped - require replica set)
1042
- - CRUD operations
1043
- - Offset pagination
1044
- - Keyset pagination
1045
- - Aggregation pagination
1046
- - Caching (hit/miss, invalidation)
1047
- - Cascade delete (hard & soft delete)
1048
- - Multi-tenancy
1049
- - Text search + infinite scroll
1050
- - Real-world scenarios
1051
-
1052
- ---
1053
-
1054
- ## 📖 Examples
1055
-
1056
- Check out the [examples](./examples) directory for:
1057
- - Express REST API
1058
- - Fastify REST API
1059
- - Next.js API routes
1060
- - Multi-tenant SaaS
1061
- - Infinite scroll feed
1062
- - Admin dashboard
1063
-
1064
- ---
1065
-
1066
- ## 🤝 Contributing
1067
-
1068
- Contributions are welcome! Please check out our [contributing guide](CONTRIBUTING.md).
1069
-
1070
- ---
1071
-
1072
- ## 📄 License
1073
-
1074
- MIT © [Classytic](https://github.com/classytic)
1075
-
1076
- ---
1077
-
1078
- ## 🔗 Links
452
+ ## No Breaking Changes
1079
453
 
1080
- - [GitHub Repository](https://github.com/classytic/mongokit)
1081
- - [npm Package](https://www.npmjs.com/package/@classytic/mongokit)
1082
- - [Documentation](https://github.com/classytic/mongokit#readme)
1083
- - [Issue Tracker](https://github.com/classytic/mongokit/issues)
454
+ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
1084
455
 
1085
- ---
456
+ - Uses its own event system (not Mongoose middleware)
457
+ - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
458
+ - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
459
+ - All 194 tests pass on both Mongoose 8 and 9
1086
460
 
1087
- **Built with ❤️ by developers, for developers.**
461
+ ## License
1088
462
 
1089
- Zero dependencies. Zero compromises. Production-grade MongoDB pagination.
463
+ MIT