@classytic/mongokit 3.0.0 → 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 182 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
- ---
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)
112
82
 
113
- ## 🎯 Pagination Modes Explained
114
-
115
- ### Offset Pagination (Page-Based)
116
-
117
- Best for: Admin dashboards, page numbers, showing total counts
83
+ ### Required Indexes
118
84
 
119
85
  ```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
- ```
163
-
164
- **Performance:**
165
- - Time complexity: O(1) regardless of position
166
- - Requires compound index: `{ sortField: 1, _id: 1 }`
167
- - Ideal for millions of documents
168
-
169
- **Required Index:**
170
- ```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,605 +133,331 @@ 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)
153
+ ## Plugins
481
154
 
482
- For collections with millions of documents, counting can be slow. Use estimated counts:
155
+ ### Using Plugins
483
156
 
484
157
  ```javascript
485
- const repo = new Repository(UserModel, [], {
486
- useEstimatedCount: true // O(1) metadata lookup instead of O(n) count
487
- });
158
+ import {
159
+ Repository,
160
+ timestampPlugin,
161
+ softDeletePlugin,
162
+ cachePlugin,
163
+ createMemoryCache
164
+ } from '@classytic/mongokit';
488
165
 
489
- const result = await repo.getAll({ page: 1, limit: 20 });
490
- // Uses estimatedDocumentCount() - instant but approximate
166
+ const repo = new Repository(UserModel, [
167
+ timestampPlugin(),
168
+ softDeletePlugin(),
169
+ cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
170
+ ]);
491
171
  ```
492
172
 
493
- **Note:** Estimated counts ignore filters and sessions by design (reads metadata, not documents).
173
+ ### Available Plugins
494
174
 
495
- ---
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 |
496
189
 
497
- ## 📊 Indexing Guide
498
-
499
- **Critical:** MongoDB only auto-indexes `_id`. You must create indexes for efficient pagination.
500
-
501
- ### Single-Tenant Applications
190
+ ### Soft Delete
502
191
 
503
192
  ```javascript
504
- const PostSchema = new mongoose.Schema({
505
- title: String,
506
- publishedAt: { type: Date, default: Date.now }
507
- });
193
+ const repo = new Repository(UserModel, [
194
+ softDeletePlugin({ deletedField: 'deletedAt' })
195
+ ]);
508
196
 
509
- // Required for keyset pagination
510
- PostSchema.index({ publishedAt: -1, _id: -1 });
511
- // ^^^^^^^^^^^^^^ ^^^^^^
512
- // Sort field Tie-breaker
197
+ await repo.delete(id); // Marks as deleted
198
+ await repo.getAll(); // Excludes deleted
199
+ await repo.getAll({ includeDeleted: true }); // Includes deleted
513
200
  ```
514
201
 
515
- ### Multi-Tenant Applications
202
+ ### Caching
516
203
 
517
204
  ```javascript
518
- const UserSchema = new mongoose.Schema({
519
- organizationId: String,
520
- email: String,
521
- createdAt: { type: Date, default: Date.now }
522
- });
205
+ import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
523
206
 
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 });
207
+ const repo = new Repository(UserModel, [
208
+ cachePlugin({
209
+ adapter: createMemoryCache(), // or Redis adapter
210
+ ttl: 60, // Default TTL (seconds)
211
+ byIdTtl: 300, // TTL for getById
212
+ queryTtl: 30, // TTL for lists
213
+ })
214
+ ]);
535
215
 
536
- // Multi-tenant
537
- Schema.index({ tenantId: 1, createdAt: -1, _id: -1 });
216
+ // Reads are cached automatically
217
+ const user = await repo.getById(id);
538
218
 
539
- // Multi-tenant + status filter
540
- Schema.index({ tenantId: 1, status: 1, createdAt: -1, _id: -1 });
219
+ // Skip cache for fresh data
220
+ const fresh = await repo.getById(id, { skipCache: true });
541
221
 
542
- // Text search
543
- Schema.index({ title: 'text', content: 'text' });
544
- Schema.index({ createdAt: -1, _id: -1 }); // Still need this for sorting
222
+ // Mutations auto-invalidate cache
223
+ await repo.update(id, { name: 'New' });
545
224
 
546
- // Multi-field sort
547
- Schema.index({ priority: -1, createdAt: -1, _id: -1 });
225
+ // Manual invalidation
226
+ await repo.invalidateCache(id);
227
+ await repo.invalidateAllCache();
548
228
  ```
549
229
 
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 |
557
-
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
-
230
+ **Redis adapter example:**
568
231
  ```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']
232
+ const redisAdapter = {
233
+ async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
234
+ async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
235
+ async del(key) { await redis.del(key); },
236
+ async clear(pattern) { /* optional bulk delete */ }
575
237
  };
576
-
577
- class UserRepository extends Repository {
578
- constructor() {
579
- super(UserModel, [fieldFilterPlugin(fieldPreset)]);
580
- }
581
- }
582
238
  ```
583
239
 
584
240
  ### Validation Chain
585
241
 
586
- Add custom validation rules:
587
-
588
242
  ```javascript
589
243
  import {
590
- Repository,
591
244
  validationChainPlugin,
592
245
  requireField,
593
246
  uniqueField,
594
- immutableField
247
+ immutableField,
248
+ blockIf,
249
+ autoInject
595
250
  } from '@classytic/mongokit';
596
251
 
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
- }
608
- ```
609
-
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
- }
622
-
623
- // repo.delete(id) → marks as deleted instead of removing
624
- // repo.getAll() → excludes deleted records
625
- // repo.getAll({ includeDeleted: true }) → includes deleted
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
+ ]);
626
263
  ```
627
264
 
628
- ### Audit Logging
629
-
630
- Log all create, update, and delete operations:
265
+ ### Cascade Delete
631
266
 
632
267
  ```javascript
633
- import { Repository, auditLogPlugin } from '@classytic/mongokit';
634
- import logger from './logger.js';
268
+ import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
635
269
 
636
- class UserRepository extends Repository {
637
- constructor() {
638
- super(UserModel, [auditLogPlugin(logger)]);
639
- }
640
- }
270
+ const repo = new Repository(ProductModel, [
271
+ softDeletePlugin(),
272
+ cascadePlugin({
273
+ relations: [
274
+ { model: 'StockEntry', foreignKey: 'product' },
275
+ { model: 'Review', foreignKey: 'product', softDelete: false }
276
+ ],
277
+ parallel: true,
278
+ logger: console
279
+ })
280
+ ]);
641
281
 
642
- // All CUD operations automatically logged
282
+ // Deleting product also deletes related StockEntry and Review docs
283
+ await repo.delete(productId);
643
284
  ```
644
285
 
645
- ### Caching (Redis, Memcached, or In-Memory)
646
-
647
- Add caching with automatic invalidation on mutations:
286
+ ### Field Filtering (RBAC)
648
287
 
649
288
  ```javascript
650
- import { Repository, cachePlugin, createMemoryCache } from '@classytic/mongokit';
289
+ import { fieldFilterPlugin } from '@classytic/mongokit';
651
290
 
652
- const userRepo = new Repository(UserModel, [
653
- 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
291
+ const repo = new Repository(UserModel, [
292
+ fieldFilterPlugin({
293
+ public: ['id', 'name', 'avatar'],
294
+ authenticated: ['email', 'phone'],
295
+ admin: ['createdAt', 'internalNotes']
658
296
  })
659
297
  ]);
660
-
661
- // Reads are cached automatically
662
- const user = await userRepo.getById(id); // cached on second call
663
-
664
- // Skip cache for fresh data
665
- const fresh = await userRepo.getById(id, { skipCache: true });
666
-
667
- // Mutations auto-invalidate cache
668
- await userRepo.update(id, { name: 'New' });
669
-
670
- // Manual invalidation (microservices)
671
- await userRepo.invalidateCache(id); // single doc
672
- await userRepo.invalidateAllCache(); // full model
673
- ```
674
-
675
- **Redis adapter example:**
676
- ```javascript
677
- const redisAdapter = {
678
- async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
679
- async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
680
- async del(key) { await redis.del(key); },
681
- async clear(pattern) { /* optional: bulk delete by pattern */ }
682
- };
683
298
  ```
684
299
 
685
- ### More Plugins
686
-
687
- - **`timestampPlugin()`** - Auto-manage `createdAt`/`updatedAt`
688
- - **`mongoOperationsPlugin()`** - Adds `increment`, `pushToArray`, `upsert`, etc.
689
- - **`batchOperationsPlugin()`** - Adds `updateMany`, `deleteMany`
690
- - **`aggregateHelpersPlugin()`** - Adds `groupBy`, `sum`, `average`, etc.
691
- - **`subdocumentPlugin()`** - Manage subdocument arrays easily
692
-
693
- ---
694
-
695
- ## 🎨 Event System
696
-
697
- Every operation emits lifecycle events:
300
+ ## Event System
698
301
 
699
302
  ```javascript
700
303
  repo.on('before:create', async (context) => {
701
- console.log('About to create:', context.data);
702
- // Modify context.data if needed
703
304
  context.data.processedAt = new Date();
704
305
  });
705
306
 
706
307
  repo.on('after:create', ({ context, result }) => {
707
308
  console.log('Created:', result);
708
- // Send notification, update cache, etc.
709
309
  });
710
310
 
711
311
  repo.on('error:create', ({ context, error }) => {
712
- console.error('Failed to create:', error);
713
- // Log error, send alert, etc.
312
+ console.error('Failed:', error);
714
313
  });
715
314
  ```
716
315
 
717
- **Available Events:**
718
- - `before:create`, `after:create`, `error:create`
719
- - `before:update`, `after:update`, `error:update`
720
- - `before:delete`, `after:delete`, `error:delete`
721
- - `before:createMany`, `after:createMany`, `error:createMany`
722
- - `before:getAll`, `before:getById`, `before:getByQuery`
723
-
724
- ---
316
+ **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
725
317
 
726
- ## 🎯 Custom Plugins
318
+ ## HTTP Utilities
727
319
 
728
- Create your own plugins:
320
+ ### Query Parser
729
321
 
730
322
  ```javascript
731
- export const timestampPlugin = () => ({
732
- name: 'timestamp',
733
-
734
- apply(repo) {
735
- repo.on('before:create', (context) => {
736
- context.data.createdAt = new Date();
737
- context.data.updatedAt = new Date();
738
- });
323
+ import { queryParser } from '@classytic/mongokit/utils';
739
324
 
740
- repo.on('before:update', (context) => {
741
- context.data.updatedAt = new Date();
742
- });
743
- }
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);
744
329
  });
330
+ ```
745
331
 
746
- // Use it
747
- class UserRepository extends Repository {
748
- constructor() {
749
- super(UserModel, [timestampPlugin()]);
750
- }
751
- }
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
752
338
  ```
753
339
 
754
- ### Combining Multiple Plugins
340
+ ### Schema Generator (Fastify/OpenAPI)
755
341
 
756
342
  ```javascript
757
- import {
758
- Repository,
759
- softDeletePlugin,
760
- auditLogPlugin,
761
- fieldFilterPlugin
762
- } from '@classytic/mongokit';
343
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
763
344
 
764
- class UserRepository extends Repository {
765
- constructor() {
766
- super(UserModel, [
767
- softDeletePlugin(),
768
- auditLogPlugin(logger),
769
- fieldFilterPlugin(userFieldPreset)
770
- ]);
345
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
346
+ fieldRules: {
347
+ organizationId: { immutable: true },
348
+ status: { systemManaged: true }
771
349
  }
772
- }
773
- ```
774
-
775
- ---
350
+ });
776
351
 
777
- ## 📚 TypeScript Support
352
+ fastify.post('/users', { schema: crudSchemas.create }, handler);
353
+ fastify.get('/users', { schema: crudSchemas.list }, handler);
354
+ ```
778
355
 
779
- Full TypeScript support with discriminated unions:
356
+ ## TypeScript
780
357
 
781
358
  ```typescript
782
- import {
783
- Repository,
784
- OffsetPaginationResult,
785
- KeysetPaginationResult
786
- } from '@classytic/mongokit';
787
- import { Document } from 'mongoose';
359
+ import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
788
360
 
789
361
  interface IUser extends Document {
790
362
  name: string;
791
363
  email: string;
792
- status: 'active' | 'inactive';
793
364
  }
794
365
 
795
- class UserRepository extends Repository {
796
- constructor() {
797
- super(UserModel);
798
- }
799
-
800
- async findActive(): Promise<IUser[]> {
801
- const result = await this.getAll({
802
- filters: { status: 'active' },
803
- page: 1,
804
- limit: 50
805
- });
806
-
807
- // TypeScript knows result is OffsetPaginationResult
808
- if (result.method === 'offset') {
809
- console.log(result.total); // ✅ Type-safe
810
- console.log(result.pages); // ✅ Type-safe
811
- // console.log(result.next); // ❌ Type error
812
- }
366
+ const repo = new Repository<IUser>(UserModel);
813
367
 
814
- return result.docs;
815
- }
816
-
817
- async getFeed(): Promise<IUser[]> {
818
- const result = await this.getAll({
819
- sort: { createdAt: -1 },
820
- limit: 20
821
- });
822
-
823
- // TypeScript knows result is KeysetPaginationResult
824
- if (result.method === 'keyset') {
825
- console.log(result.next); // ✅ Type-safe
826
- console.log(result.hasMore); // ✅ Type-safe
827
- // console.log(result.total); // ❌ Type error
828
- }
368
+ const result = await repo.getAll({ page: 1, limit: 20 });
829
369
 
830
- return result.docs;
831
- }
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
832
376
  }
833
377
  ```
834
378
 
835
- ### Import Types
836
-
837
- ```typescript
838
- import type {
839
- PaginationConfig,
840
- OffsetPaginationOptions,
841
- KeysetPaginationOptions,
842
- AggregatePaginationOptions,
843
- OffsetPaginationResult,
844
- KeysetPaginationResult,
845
- AggregatePaginationResult
846
- } from '@classytic/mongokit';
847
- ```
848
-
849
- ---
850
-
851
- ## 🏎️ Performance Tips
852
-
853
- ### 1. Use Keyset Pagination for Large Datasets
854
-
855
- ```javascript
856
- // ❌ Slow for large datasets (millions of documents)
857
- await repo.getAll({ page: 1000, limit: 50 }); // O(50000)
858
-
859
- // ✅ Fast regardless of position
860
- await repo.getAll({ after: cursor, limit: 50 }); // O(1)
861
- ```
862
-
863
- ### 2. Create Required Indexes
864
-
865
- **IMPORTANT:** MongoDB only auto-indexes `_id`. You must manually create indexes for pagination.
866
-
867
- ```javascript
868
- // ✅ Single-Tenant: Sort field + _id
869
- PostSchema.index({ createdAt: -1, _id: -1 });
870
-
871
- // ✅ Multi-Tenant: Tenant field + Sort field + _id
872
- UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
873
-
874
- // ✅ Text Search: Text index
875
- PostSchema.index({ title: 'text', content: 'text' });
876
- ```
877
-
878
- **Without indexes = slow (full collection scan)**
879
- **With indexes = fast (O(1) index seek)**
379
+ ## Extending Repository
880
380
 
881
- ### 3. Use Estimated Counts for Large Collections
381
+ Create custom repository classes with domain-specific methods:
882
382
 
883
- ```javascript
884
- const repo = new Repository(UserModel, [], {
885
- useEstimatedCount: true // Instant counts for >10M documents
886
- });
887
- ```
383
+ ```typescript
384
+ import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
385
+ import UserModel, { IUser } from './models/User.js';
888
386
 
889
- ### 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
+ }
890
396
 
891
- ```javascript
892
- // Lean is true by default - returns plain objects
893
- const result = await repo.getAll({ page: 1 });
397
+ // Custom domain methods
398
+ async findByEmail(email: string) {
399
+ return this.getByQuery({ email });
400
+ }
894
401
 
895
- // Disable for Mongoose documents (if you need methods)
896
- const result = await repo.getAll({ page: 1 }, { lean: false });
897
- ```
402
+ async findActiveUsers() {
403
+ return this.getAll({
404
+ filters: { status: 'active' },
405
+ sort: { createdAt: -1 }
406
+ });
407
+ }
898
408
 
899
- ### 5. Limit $facet Results in Aggregation
409
+ async deactivate(id: string) {
410
+ return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
411
+ }
412
+ }
900
413
 
901
- ```javascript
902
- // ⚠️ Warning triggered automatically at limit > 1000
903
- await repo.aggregatePaginate({
904
- pipeline: [...],
905
- limit: 2000 // Warning: $facet results must be <16MB
906
- });
414
+ // Usage
415
+ const userRepo = new UserRepository();
416
+ const user = await userRepo.findByEmail('john@example.com');
907
417
  ```
908
418
 
909
- ---
910
-
911
- ## 🔄 Migration Guide
419
+ ### Overriding Methods
912
420
 
913
- ### From mongoose-paginate-v2
421
+ ```typescript
422
+ class AuditedUserRepository extends Repository<IUser> {
423
+ constructor() {
424
+ super(UserModel);
425
+ }
914
426
 
915
- ```javascript
916
- // Before
917
- import mongoosePaginate from 'mongoose-paginate-v2';
918
- UserSchema.plugin(mongoosePaginate);
919
- 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);
920
433
 
921
- // After
922
- import { Repository } from '@classytic/mongokit';
923
- const userRepo = new Repository(UserModel);
924
- const result = await userRepo.getAll({
925
- filters: { status: 'active' },
926
- page: 1,
927
- limit: 10
928
- });
434
+ await auditLog('user.created', result._id);
435
+ return result;
436
+ }
437
+ }
929
438
  ```
930
439
 
931
- ### From Prisma
440
+ ## Factory Function
932
441
 
933
- ```javascript
934
- // Before (Prisma)
935
- const users = await prisma.user.findMany({
936
- where: { status: 'active' },
937
- skip: 20,
938
- take: 10
939
- });
940
-
941
- // After (MongoKit)
942
- const result = await userRepo.getAll({
943
- filters: { status: 'active' },
944
- page: 3,
945
- limit: 10
946
- });
947
- const users = result.docs;
948
- ```
949
-
950
- ### From TypeORM
442
+ For simple cases without custom methods:
951
443
 
952
444
  ```javascript
953
- // Before (TypeORM)
954
- const [users, total] = await userRepository.findAndCount({
955
- where: { status: 'active' },
956
- skip: 20,
957
- take: 10
958
- });
445
+ import { createRepository, timestampPlugin } from '@classytic/mongokit';
959
446
 
960
- // After (MongoKit)
961
- const result = await userRepo.getAll({
962
- filters: { status: 'active' },
963
- page: 3,
964
- limit: 10
447
+ const userRepo = createRepository(UserModel, [timestampPlugin()], {
448
+ defaultLimit: 20
965
449
  });
966
- const users = result.docs;
967
- const total = result.total;
968
- ```
969
-
970
- ---
971
-
972
- ## 🌟 Why MongoKit?
973
-
974
- ### vs. Mongoose Directly
975
- - ✅ Consistent API across all models
976
- - ✅ Built-in pagination (offset + cursor) with zero dependencies
977
- - ✅ Multi-tenancy without repetitive code
978
- - ✅ Event hooks for cross-cutting concerns
979
- - ✅ Plugin system for reusable behaviors
980
-
981
- ### vs. mongoose-paginate-v2
982
- - ✅ Zero external dependencies (no mongoose-paginate-v2 needed)
983
- - ✅ Cursor-based pagination for infinite scroll
984
- - ✅ Unified API that auto-detects pagination mode
985
- - ✅ Native MongoDB implementation ($facet, cursors)
986
- - ✅ Better TypeScript support
987
-
988
- ### vs. TypeORM / Prisma
989
- - ✅ Lighter weight (works with Mongoose)
990
- - ✅ Event-driven architecture
991
- - ✅ More flexible plugin system
992
- - ✅ No migration needed if using Mongoose
993
- - ✅ Framework-agnostic
994
-
995
- ### vs. Raw Repository Pattern
996
- - ✅ Battle-tested implementation (68 passing tests)
997
- - ✅ 11 built-in plugins ready to use
998
- - ✅ Comprehensive documentation
999
- - ✅ TypeScript discriminated unions
1000
- - ✅ Active maintenance
1001
-
1002
- ---
1003
-
1004
- ## 🧪 Testing
1005
-
1006
- ```bash
1007
- npm test
1008
450
  ```
1009
451
 
1010
- **Test Coverage:**
1011
- - 184 tests (182 passing, 2 skipped - require replica set)
1012
- - CRUD operations
1013
- - Offset pagination
1014
- - Keyset pagination
1015
- - Aggregation pagination
1016
- - Caching (hit/miss, invalidation)
1017
- - Multi-tenancy
1018
- - Text search + infinite scroll
1019
- - Real-world scenarios
1020
-
1021
- ---
1022
-
1023
- ## 📖 Examples
1024
-
1025
- Check out the [examples](./examples) directory for:
1026
- - Express REST API
1027
- - Fastify REST API
1028
- - Next.js API routes
1029
- - Multi-tenant SaaS
1030
- - Infinite scroll feed
1031
- - Admin dashboard
1032
-
1033
- ---
1034
-
1035
- ## 🤝 Contributing
1036
-
1037
- Contributions are welcome! Please check out our [contributing guide](CONTRIBUTING.md).
1038
-
1039
- ---
1040
-
1041
- ## 📄 License
1042
-
1043
- MIT © [Classytic](https://github.com/classytic)
1044
-
1045
- ---
1046
-
1047
- ## 🔗 Links
452
+ ## No Breaking Changes
1048
453
 
1049
- - [GitHub Repository](https://github.com/classytic/mongokit)
1050
- - [npm Package](https://www.npmjs.com/package/@classytic/mongokit)
1051
- - [Documentation](https://github.com/classytic/mongokit#readme)
1052
- - [Issue Tracker](https://github.com/classytic/mongokit/issues)
454
+ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
1053
455
 
1054
- ---
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
1055
460
 
1056
- **Built with ❤️ by developers, for developers.**
461
+ ## License
1057
462
 
1058
- Zero dependencies. Zero compromises. Production-grade MongoDB pagination.
463
+ MIT