@classytic/mongokit 3.1.0 → 3.1.1

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,465 +1,625 @@
1
- # @classytic/mongokit
2
-
3
- [![npm version](https://badge.fury.io/js/@classytic%2Fmongokit.svg)](https://www.npmjs.com/package/@classytic/mongokit)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
-
6
- > Production-grade MongoDB repository pattern with zero external dependencies
7
-
8
- **Works with:** Express, Fastify, NestJS, Next.js, Koa, Hapi, Serverless
9
-
10
- ## Features
11
-
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
18
-
19
- ## Installation
20
-
21
- ```bash
22
- npm install @classytic/mongokit mongoose
23
- ```
24
-
25
- > Supports Mongoose `^8.0.0` and `^9.0.0`
26
-
27
- ## Quick Start
28
-
29
- ```javascript
30
- import { Repository } from '@classytic/mongokit';
31
- import UserModel from './models/User.js';
32
-
33
- const userRepo = new Repository(UserModel);
34
-
35
- // Create
36
- const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
37
-
38
- // Read with auto-detected pagination
39
- const users = await userRepo.getAll({ page: 1, limit: 20 });
40
-
41
- // Update
42
- await userRepo.update(user._id, { name: 'Jane' });
43
-
44
- // Delete
45
- await userRepo.delete(user._id);
46
- ```
47
-
48
- ## Pagination
49
-
50
- `getAll()` automatically detects pagination mode based on parameters:
51
-
52
- ```javascript
53
- // Offset pagination (page-based) - for dashboards
54
- const result = await repo.getAll({
55
- page: 1,
56
- limit: 20,
57
- filters: { status: 'active' },
58
- sort: { createdAt: -1 }
59
- });
60
- // → { method: 'offset', docs, total, pages, hasNext, hasPrev }
61
-
62
- // Keyset pagination (cursor-based) - for infinite scroll
63
- const stream = await repo.getAll({
64
- sort: { createdAt: -1 },
65
- limit: 20
66
- });
67
- // → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
68
-
69
- // Next page with cursor
70
- const next = await repo.getAll({
71
- after: stream.next,
72
- sort: { createdAt: -1 },
73
- limit: 20
74
- });
75
- ```
76
-
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)
82
-
83
- ### Required Indexes
84
-
85
- ```javascript
86
- // For keyset pagination: sort field + _id
87
- PostSchema.index({ createdAt: -1, _id: -1 });
88
-
89
- // For multi-tenant: tenant + sort field + _id
90
- UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
91
- ```
92
-
93
- ## API Reference
94
-
95
- ### CRUD Operations
96
-
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 |
109
-
110
- ### Aggregation
111
-
112
- ```javascript
113
- // Basic aggregation
114
- const result = await repo.aggregate([
115
- { $match: { status: 'active' } },
116
- { $group: { _id: '$category', total: { $sum: 1 } } }
117
- ]);
118
-
119
- // Paginated aggregation
120
- const result = await repo.aggregatePaginate({
121
- pipeline: [...],
122
- page: 1,
123
- limit: 20
124
- });
125
-
126
- // Distinct values
127
- const categories = await repo.distinct('category', { status: 'active' });
128
- ```
129
-
130
- ### Transactions
131
-
132
- ```javascript
133
- await repo.withTransaction(async (session) => {
134
- await repo.create({ name: 'User 1' }, { session });
135
- await repo.create({ name: 'User 2' }, { session });
136
- // Auto-commits on success, auto-rollbacks on error
137
- });
138
- ```
139
-
140
- ## Configuration
141
-
142
- ```javascript
143
- const repo = new Repository(UserModel, plugins, {
144
- defaultLimit: 20, // Default docs per page
145
- maxLimit: 100, // Maximum allowed limit
146
- maxPage: 10000, // Maximum page number
147
- deepPageThreshold: 100, // Warn when page exceeds this
148
- useEstimatedCount: false, // Use fast estimated counts
149
- cursorVersion: 1 // Cursor format version
150
- });
151
- ```
152
-
153
- ## Plugins
154
-
155
- ### Using Plugins
156
-
157
- ```javascript
158
- import {
159
- Repository,
160
- timestampPlugin,
161
- softDeletePlugin,
162
- cachePlugin,
163
- createMemoryCache
164
- } from '@classytic/mongokit';
165
-
166
- const repo = new Repository(UserModel, [
167
- timestampPlugin(),
168
- softDeletePlugin(),
169
- cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
170
- ]);
171
- ```
172
-
173
- ### Available Plugins
174
-
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 |
189
-
190
- ### Soft Delete
191
-
192
- ```javascript
193
- const repo = new Repository(UserModel, [
194
- softDeletePlugin({ deletedField: 'deletedAt' })
195
- ]);
196
-
197
- await repo.delete(id); // Marks as deleted
198
- await repo.getAll(); // Excludes deleted
199
- await repo.getAll({ includeDeleted: true }); // Includes deleted
200
- ```
201
-
202
- ### Caching
203
-
204
- ```javascript
205
- import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
206
-
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
- ]);
215
-
216
- // Reads are cached automatically
217
- const user = await repo.getById(id);
218
-
219
- // Skip cache for fresh data
220
- const fresh = await repo.getById(id, { skipCache: true });
221
-
222
- // Mutations auto-invalidate cache
223
- await repo.update(id, { name: 'New' });
224
-
225
- // Manual invalidation
226
- await repo.invalidateCache(id);
227
- await repo.invalidateAllCache();
228
- ```
229
-
230
- **Redis adapter example:**
231
- ```javascript
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 */ }
237
- };
238
- ```
239
-
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
- ```
264
-
265
- ### Cascade Delete
266
-
267
- ```javascript
268
- import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
269
-
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
- ]);
281
-
282
- // Deleting product also deletes related StockEntry and Review docs
283
- await repo.delete(productId);
284
- ```
285
-
286
- ### Field Filtering (RBAC)
287
-
288
- ```javascript
289
- import { fieldFilterPlugin } from '@classytic/mongokit';
290
-
291
- const repo = new Repository(UserModel, [
292
- fieldFilterPlugin({
293
- public: ['id', 'name', 'avatar'],
294
- authenticated: ['email', 'phone'],
295
- admin: ['createdAt', 'internalNotes']
296
- })
297
- ]);
298
- ```
299
-
300
- ## Event System
301
-
302
- ```javascript
303
- repo.on('before:create', async (context) => {
304
- context.data.processedAt = new Date();
305
- });
306
-
307
- repo.on('after:create', ({ context, result }) => {
308
- console.log('Created:', result);
309
- });
310
-
311
- repo.on('error:create', ({ context, error }) => {
312
- console.error('Failed:', error);
313
- });
314
- ```
315
-
316
- **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
317
-
318
- ## HTTP Utilities
319
-
320
- ### Query Parser
321
-
322
- ```javascript
323
- import { QueryParser } from '@classytic/mongokit';
324
-
325
- const queryParser = new QueryParser();
326
-
327
- app.get('/users', async (req, res) => {
328
- const { filters, limit, page, sort } = queryParser.parse(req.query);
329
- const result = await userRepo.getAll({ filters, limit, page, sort });
330
- res.json(result);
331
- });
332
- ```
333
-
334
- **Supported query patterns:**
335
- ```bash
336
- GET /users?email=john@example.com&role=admin
337
- GET /users?age[gte]=18&age[lte]=65
338
- GET /users?role[in]=admin,user
339
- GET /users?sort=-createdAt,name&page=2&limit=50
340
- ```
341
-
342
- ### Schema Generator (Fastify/OpenAPI)
343
-
344
- ```javascript
345
- import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
346
-
347
- const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
348
- fieldRules: {
349
- organizationId: { immutable: true },
350
- status: { systemManaged: true }
351
- }
352
- });
353
-
354
- fastify.post('/users', { schema: crudSchemas.create }, handler);
355
- fastify.get('/users', { schema: crudSchemas.list }, handler);
356
- ```
357
-
358
- ## TypeScript
359
-
360
- ```typescript
361
- import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
362
-
363
- interface IUser extends Document {
364
- name: string;
365
- email: string;
366
- }
367
-
368
- const repo = new Repository<IUser>(UserModel);
369
-
370
- const result = await repo.getAll({ page: 1, limit: 20 });
371
-
372
- // Discriminated union - TypeScript knows the type
373
- if (result.method === 'offset') {
374
- console.log(result.total, result.pages); // Available
375
- }
376
- if (result.method === 'keyset') {
377
- console.log(result.next, result.hasMore); // Available
378
- }
379
- ```
380
-
381
- ## Extending Repository
382
-
383
- Create custom repository classes with domain-specific methods:
384
-
385
- ```typescript
386
- import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
387
- import UserModel, { IUser } from './models/User.js';
388
-
389
- class UserRepository extends Repository<IUser> {
390
- constructor() {
391
- super(UserModel, [
392
- timestampPlugin(),
393
- softDeletePlugin()
394
- ], {
395
- defaultLimit: 20
396
- });
397
- }
398
-
399
- // Custom domain methods
400
- async findByEmail(email: string) {
401
- return this.getByQuery({ email });
402
- }
403
-
404
- async findActiveUsers() {
405
- return this.getAll({
406
- filters: { status: 'active' },
407
- sort: { createdAt: -1 }
408
- });
409
- }
410
-
411
- async deactivate(id: string) {
412
- return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
413
- }
414
- }
415
-
416
- // Usage
417
- const userRepo = new UserRepository();
418
- const user = await userRepo.findByEmail('john@example.com');
419
- ```
420
-
421
- ### Overriding Methods
422
-
423
- ```typescript
424
- class AuditedUserRepository extends Repository<IUser> {
425
- constructor() {
426
- super(UserModel);
427
- }
428
-
429
- // Override create to add audit trail
430
- async create(data: Partial<IUser>, options = {}) {
431
- const result = await super.create({
432
- ...data,
433
- createdBy: getCurrentUserId()
434
- }, options);
435
-
436
- await auditLog('user.created', result._id);
437
- return result;
438
- }
439
- }
440
- ```
441
-
442
- ## Factory Function
443
-
444
- For simple cases without custom methods:
445
-
446
- ```javascript
447
- import { createRepository, timestampPlugin } from '@classytic/mongokit';
448
-
449
- const userRepo = createRepository(UserModel, [timestampPlugin()], {
450
- defaultLimit: 20
451
- });
452
- ```
453
-
454
- ## No Breaking Changes
455
-
456
- Extending Repository works exactly the same with Mongoose 8 and 9. The package:
457
-
458
- - Uses its own event system (not Mongoose middleware)
459
- - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
460
- - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
461
- - All 194 tests pass on both Mongoose 8 and 9
462
-
463
- ## License
464
-
465
- MIT
1
+ # @classytic/mongokit
2
+
3
+ [![npm version](https://badge.fury.io/js/@classytic%2Fmongokit.svg)](https://www.npmjs.com/package/@classytic/mongokit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ > Production-grade MongoDB repository pattern with zero external dependencies
7
+
8
+ **Works with:** Express, Fastify, NestJS, Next.js, Koa, Hapi, Serverless
9
+
10
+ ## Features
11
+
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
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @classytic/mongokit mongoose
23
+ ```
24
+
25
+ > Supports Mongoose `^8.0.0` and `^9.0.0`
26
+
27
+ ## Quick Start
28
+
29
+ ```javascript
30
+ import { Repository } from '@classytic/mongokit';
31
+ import UserModel from './models/User.js';
32
+
33
+ const userRepo = new Repository(UserModel);
34
+
35
+ // Create
36
+ const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
37
+
38
+ // Read with auto-detected pagination
39
+ const users = await userRepo.getAll({ page: 1, limit: 20 });
40
+
41
+ // Update
42
+ await userRepo.update(user._id, { name: 'Jane' });
43
+
44
+ // Delete
45
+ await userRepo.delete(user._id);
46
+ ```
47
+
48
+ ## Pagination
49
+
50
+ `getAll()` automatically detects pagination mode based on parameters:
51
+
52
+ ```javascript
53
+ // Offset pagination (page-based) - for dashboards
54
+ const result = await repo.getAll({
55
+ page: 1,
56
+ limit: 20,
57
+ filters: { status: 'active' },
58
+ sort: { createdAt: -1 }
59
+ });
60
+ // → { method: 'offset', docs, total, pages, hasNext, hasPrev }
61
+
62
+ // Keyset pagination (cursor-based) - for infinite scroll
63
+ const stream = await repo.getAll({
64
+ sort: { createdAt: -1 },
65
+ limit: 20
66
+ });
67
+ // → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
68
+
69
+ // Next page with cursor
70
+ const next = await repo.getAll({
71
+ after: stream.next,
72
+ sort: { createdAt: -1 },
73
+ limit: 20
74
+ });
75
+ ```
76
+
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)
82
+
83
+ ### Required Indexes
84
+
85
+ ```javascript
86
+ // For keyset pagination: sort field + _id
87
+ PostSchema.index({ createdAt: -1, _id: -1 });
88
+
89
+ // For multi-tenant: tenant + sort field + _id
90
+ UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
91
+ ```
92
+
93
+ ## API Reference
94
+
95
+ ### CRUD Operations
96
+
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 |
109
+
110
+ ### Aggregation
111
+
112
+ ```javascript
113
+ // Basic aggregation
114
+ const result = await repo.aggregate([
115
+ { $match: { status: 'active' } },
116
+ { $group: { _id: '$category', total: { $sum: 1 } } }
117
+ ]);
118
+
119
+ // Paginated aggregation
120
+ const result = await repo.aggregatePaginate({
121
+ pipeline: [...],
122
+ page: 1,
123
+ limit: 20
124
+ });
125
+
126
+ // Distinct values
127
+ const categories = await repo.distinct('category', { status: 'active' });
128
+ ```
129
+
130
+ ### Transactions
131
+
132
+ ```javascript
133
+ await repo.withTransaction(async (session) => {
134
+ await repo.create({ name: 'User 1' }, { session });
135
+ await repo.create({ name: 'User 2' }, { session });
136
+ // Auto-commits on success, auto-rollbacks on error
137
+ });
138
+ ```
139
+
140
+ ## Configuration
141
+
142
+ ```javascript
143
+ const repo = new Repository(UserModel, plugins, {
144
+ defaultLimit: 20, // Default docs per page
145
+ maxLimit: 100, // Maximum allowed limit
146
+ maxPage: 10000, // Maximum page number
147
+ deepPageThreshold: 100, // Warn when page exceeds this
148
+ useEstimatedCount: false, // Use fast estimated counts
149
+ cursorVersion: 1 // Cursor format version
150
+ });
151
+ ```
152
+
153
+ ## Plugins
154
+
155
+ ### Using Plugins
156
+
157
+ ```javascript
158
+ import {
159
+ Repository,
160
+ timestampPlugin,
161
+ softDeletePlugin,
162
+ cachePlugin,
163
+ createMemoryCache
164
+ } from '@classytic/mongokit';
165
+
166
+ const repo = new Repository(UserModel, [
167
+ timestampPlugin(),
168
+ softDeletePlugin(),
169
+ cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
170
+ ]);
171
+ ```
172
+
173
+ ### Available Plugins
174
+
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 |
189
+
190
+ ### Soft Delete
191
+
192
+ ```javascript
193
+ const repo = new Repository(UserModel, [
194
+ softDeletePlugin({ deletedField: 'deletedAt' })
195
+ ]);
196
+
197
+ await repo.delete(id); // Marks as deleted
198
+ await repo.getAll(); // Excludes deleted
199
+ await repo.getAll({ includeDeleted: true }); // Includes deleted
200
+ ```
201
+
202
+ ### Caching
203
+
204
+ ```javascript
205
+ import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
206
+
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
+ ]);
215
+
216
+ // Reads are cached automatically
217
+ const user = await repo.getById(id);
218
+
219
+ // Skip cache for fresh data
220
+ const fresh = await repo.getById(id, { skipCache: true });
221
+
222
+ // Mutations auto-invalidate cache
223
+ await repo.update(id, { name: 'New' });
224
+
225
+ // Manual invalidation
226
+ await repo.invalidateCache(id);
227
+ await repo.invalidateAllCache();
228
+ ```
229
+
230
+ **Redis adapter example:**
231
+ ```javascript
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 */ }
237
+ };
238
+ ```
239
+
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
+ ```
264
+
265
+ ### Cascade Delete
266
+
267
+ ```javascript
268
+ import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
269
+
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
+ ]);
281
+
282
+ // Deleting product also deletes related StockEntry and Review docs
283
+ await repo.delete(productId);
284
+ ```
285
+
286
+ ### Field Filtering (RBAC)
287
+
288
+ ```javascript
289
+ import { fieldFilterPlugin } from '@classytic/mongokit';
290
+
291
+ const repo = new Repository(UserModel, [
292
+ fieldFilterPlugin({
293
+ public: ['id', 'name', 'avatar'],
294
+ authenticated: ['email', 'phone'],
295
+ admin: ['createdAt', 'internalNotes']
296
+ })
297
+ ]);
298
+ ```
299
+
300
+ ## Event System
301
+
302
+ ```javascript
303
+ repo.on('before:create', async (context) => {
304
+ context.data.processedAt = new Date();
305
+ });
306
+
307
+ repo.on('after:create', ({ context, result }) => {
308
+ console.log('Created:', result);
309
+ });
310
+
311
+ repo.on('error:create', ({ context, error }) => {
312
+ console.error('Failed:', error);
313
+ });
314
+ ```
315
+
316
+ **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
317
+
318
+ ## Building REST APIs
319
+
320
+ MongoKit provides a complete toolkit for building REST APIs: QueryParser for request handling, JSON Schema generation for validation/docs, and IController interface for framework-agnostic controllers.
321
+
322
+ ### IController Interface
323
+
324
+ Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
325
+
326
+ ```typescript
327
+ import type { IController, IRequestContext, IControllerResponse } from '@classytic/mongokit';
328
+
329
+ // IRequestContext - what your controller receives
330
+ interface IRequestContext {
331
+ query: Record<string, unknown>; // URL query params
332
+ body: Record<string, unknown>; // Request body
333
+ params: Record<string, string>; // Route params (:id)
334
+ user?: { id: string; role?: string }; // Auth user
335
+ context?: Record<string, unknown>; // Tenant ID, etc.
336
+ }
337
+
338
+ // IControllerResponse - what your controller returns
339
+ interface IControllerResponse<T> {
340
+ success: boolean;
341
+ data?: T;
342
+ error?: string;
343
+ status: number;
344
+ }
345
+
346
+ // IController - implement this interface
347
+ interface IController<TDoc> {
348
+ list(ctx: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
349
+ get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
350
+ create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
351
+ update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
352
+ delete(ctx: IRequestContext): Promise<IControllerResponse<{ message: string }>>;
353
+ }
354
+ ```
355
+
356
+ ### QueryParser
357
+
358
+ Converts HTTP query strings to MongoDB queries with built-in security:
359
+
360
+ ```typescript
361
+ import { QueryParser } from '@classytic/mongokit';
362
+
363
+ const parser = new QueryParser({
364
+ maxLimit: 100, // Prevent excessive queries
365
+ maxFilterDepth: 5, // Prevent nested injection
366
+ maxRegexLength: 100, // ReDoS protection
367
+ });
368
+
369
+ // Parse request query
370
+ const { filters, limit, page, sort, search } = parser.parse(req.query);
371
+ ```
372
+
373
+ **Supported query patterns:**
374
+ ```bash
375
+ # Filtering
376
+ GET /users?status=active&role=admin
377
+ GET /users?age[gte]=18&age[lte]=65
378
+ GET /users?role[in]=admin,user
379
+ GET /users?email[contains]=@gmail.com
380
+ GET /users?name[regex]=^John
381
+
382
+ # Pagination
383
+ GET /users?page=2&limit=50
384
+ GET /users?after=eyJfaWQiOi...&limit=20 # Cursor-based
385
+
386
+ # Sorting
387
+ GET /users?sort=-createdAt,name
388
+
389
+ # Search (requires text index)
390
+ GET /users?search=john
391
+ ```
392
+
393
+ **Security features:**
394
+ - Blocks `$where`, `$function`, `$accumulator`, `$expr` operators
395
+ - ReDoS protection for regex patterns
396
+ - Max filter depth enforcement
397
+ - Collection allowlists for lookups
398
+
399
+ ### JSON Schema Generation
400
+
401
+ Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
402
+
403
+ ```typescript
404
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
405
+
406
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
407
+ fieldRules: {
408
+ organizationId: { immutable: true }, // Can't update after create
409
+ role: { systemManaged: true }, // Users can't set this
410
+ createdAt: { systemManaged: true },
411
+ },
412
+ strictAdditionalProperties: true, // Reject unknown fields
413
+ });
414
+
415
+ // Generated schemas:
416
+ // crudSchemas.createBody - POST body validation
417
+ // crudSchemas.updateBody - PATCH body validation
418
+ // crudSchemas.params - Route params (:id)
419
+ // crudSchemas.listQuery - GET query validation
420
+ ```
421
+
422
+ ### Complete Controller Example
423
+
424
+ ```typescript
425
+ import {
426
+ Repository,
427
+ QueryParser,
428
+ buildCrudSchemasFromModel,
429
+ type IController,
430
+ type IRequestContext,
431
+ type IControllerResponse,
432
+ } from '@classytic/mongokit';
433
+
434
+ class UserController implements IController<IUser> {
435
+ private repo = new Repository(UserModel);
436
+ private parser = new QueryParser({ maxLimit: 100 });
437
+
438
+ async list(ctx: IRequestContext): Promise<IControllerResponse> {
439
+ const { filters, limit, page, sort } = this.parser.parse(ctx.query);
440
+
441
+ // Inject tenant filter
442
+ if (ctx.context?.organizationId) {
443
+ filters.organizationId = ctx.context.organizationId;
444
+ }
445
+
446
+ const result = await this.repo.getAll({ filters, limit, page, sort });
447
+ return { success: true, data: result, status: 200 };
448
+ }
449
+
450
+ async get(ctx: IRequestContext): Promise<IControllerResponse> {
451
+ const doc = await this.repo.getById(ctx.params.id);
452
+ return { success: true, data: doc, status: 200 };
453
+ }
454
+
455
+ async create(ctx: IRequestContext): Promise<IControllerResponse> {
456
+ const doc = await this.repo.create(ctx.body);
457
+ return { success: true, data: doc, status: 201 };
458
+ }
459
+
460
+ async update(ctx: IRequestContext): Promise<IControllerResponse> {
461
+ const doc = await this.repo.update(ctx.params.id, ctx.body);
462
+ return { success: true, data: doc, status: 200 };
463
+ }
464
+
465
+ async delete(ctx: IRequestContext): Promise<IControllerResponse> {
466
+ await this.repo.delete(ctx.params.id);
467
+ return { success: true, data: { message: 'Deleted' }, status: 200 };
468
+ }
469
+ }
470
+ ```
471
+
472
+ ### Fastify Integration
473
+
474
+ ```typescript
475
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
476
+
477
+ const controller = new UserController();
478
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
479
+
480
+ // Routes with auto-validation and OpenAPI docs
481
+ fastify.get('/users', { schema: { querystring: crudSchemas.listQuery } }, async (req, reply) => {
482
+ const ctx = { query: req.query, body: {}, params: {}, user: req.user };
483
+ const response = await controller.list(ctx);
484
+ return reply.status(response.status).send(response);
485
+ });
486
+
487
+ fastify.post('/users', { schema: { body: crudSchemas.createBody } }, async (req, reply) => {
488
+ const ctx = { query: {}, body: req.body, params: {}, user: req.user };
489
+ const response = await controller.create(ctx);
490
+ return reply.status(response.status).send(response);
491
+ });
492
+
493
+ fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req, reply) => {
494
+ const ctx = { query: {}, body: {}, params: req.params, user: req.user };
495
+ const response = await controller.get(ctx);
496
+ return reply.status(response.status).send(response);
497
+ });
498
+ ```
499
+
500
+ ### Express Integration
501
+
502
+ ```typescript
503
+ const controller = new UserController();
504
+
505
+ app.get('/users', async (req, res) => {
506
+ const ctx = { query: req.query, body: {}, params: {}, user: req.user };
507
+ const response = await controller.list(ctx);
508
+ res.status(response.status).json(response);
509
+ });
510
+
511
+ app.post('/users', async (req, res) => {
512
+ const ctx = { query: {}, body: req.body, params: {}, user: req.user };
513
+ const response = await controller.create(ctx);
514
+ res.status(response.status).json(response);
515
+ });
516
+ ```
517
+
518
+ ## TypeScript
519
+
520
+ ```typescript
521
+ import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
522
+
523
+ interface IUser extends Document {
524
+ name: string;
525
+ email: string;
526
+ }
527
+
528
+ const repo = new Repository<IUser>(UserModel);
529
+
530
+ const result = await repo.getAll({ page: 1, limit: 20 });
531
+
532
+ // Discriminated union - TypeScript knows the type
533
+ if (result.method === 'offset') {
534
+ console.log(result.total, result.pages); // Available
535
+ }
536
+ if (result.method === 'keyset') {
537
+ console.log(result.next, result.hasMore); // Available
538
+ }
539
+ ```
540
+
541
+ ## Extending Repository
542
+
543
+ Create custom repository classes with domain-specific methods:
544
+
545
+ ```typescript
546
+ import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
547
+ import UserModel, { IUser } from './models/User.js';
548
+
549
+ class UserRepository extends Repository<IUser> {
550
+ constructor() {
551
+ super(UserModel, [
552
+ timestampPlugin(),
553
+ softDeletePlugin()
554
+ ], {
555
+ defaultLimit: 20
556
+ });
557
+ }
558
+
559
+ // Custom domain methods
560
+ async findByEmail(email: string) {
561
+ return this.getByQuery({ email });
562
+ }
563
+
564
+ async findActiveUsers() {
565
+ return this.getAll({
566
+ filters: { status: 'active' },
567
+ sort: { createdAt: -1 }
568
+ });
569
+ }
570
+
571
+ async deactivate(id: string) {
572
+ return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
573
+ }
574
+ }
575
+
576
+ // Usage
577
+ const userRepo = new UserRepository();
578
+ const user = await userRepo.findByEmail('john@example.com');
579
+ ```
580
+
581
+ ### Overriding Methods
582
+
583
+ ```typescript
584
+ class AuditedUserRepository extends Repository<IUser> {
585
+ constructor() {
586
+ super(UserModel);
587
+ }
588
+
589
+ // Override create to add audit trail
590
+ async create(data: Partial<IUser>, options = {}) {
591
+ const result = await super.create({
592
+ ...data,
593
+ createdBy: getCurrentUserId()
594
+ }, options);
595
+
596
+ await auditLog('user.created', result._id);
597
+ return result;
598
+ }
599
+ }
600
+ ```
601
+
602
+ ## Factory Function
603
+
604
+ For simple cases without custom methods:
605
+
606
+ ```javascript
607
+ import { createRepository, timestampPlugin } from '@classytic/mongokit';
608
+
609
+ const userRepo = createRepository(UserModel, [timestampPlugin()], {
610
+ defaultLimit: 20
611
+ });
612
+ ```
613
+
614
+ ## No Breaking Changes
615
+
616
+ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
617
+
618
+ - Uses its own event system (not Mongoose middleware)
619
+ - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
620
+ - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
621
+ - All 194 tests pass on both Mongoose 8 and 9
622
+
623
+ ## License
624
+
625
+ MIT