@classytic/mongokit 3.1.0 → 3.1.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,465 +1,713 @@
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
+ ### MongoDB Operations Plugin
301
+
302
+ The `mongoOperationsPlugin` adds MongoDB-specific atomic operations like `increment`, `upsert`, `pushToArray`, etc.
303
+
304
+ #### Basic Usage (No TypeScript Autocomplete)
305
+
306
+ ```javascript
307
+ import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
308
+
309
+ const repo = new Repository(ProductModel, [
310
+ methodRegistryPlugin(), // Required first
311
+ mongoOperationsPlugin()
312
+ ]);
313
+
314
+ // Works at runtime but TypeScript doesn't provide autocomplete
315
+ await repo.increment(productId, 'views', 1);
316
+ await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
317
+ ```
318
+
319
+ #### With TypeScript Type Safety (Recommended)
320
+
321
+ For full TypeScript autocomplete and type checking, use the `MongoOperationsMethods` type:
322
+
323
+ ```typescript
324
+ import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
325
+ import type { MongoOperationsMethods } from '@classytic/mongokit';
326
+
327
+ // 1. Create your repository class
328
+ class ProductRepo extends Repository<IProduct> {
329
+ // Add custom methods here
330
+ async findBySku(sku: string) {
331
+ return this.getByQuery({ sku });
332
+ }
333
+ }
334
+
335
+ // 2. Create type helper for autocomplete
336
+ type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
337
+
338
+ // 3. Instantiate with type assertion
339
+ const repo = new ProductRepo(ProductModel, [
340
+ methodRegistryPlugin(),
341
+ mongoOperationsPlugin()
342
+ ]) as ProductRepoWithPlugins;
343
+
344
+ // 4. Now TypeScript provides full autocomplete and type checking!
345
+ await repo.increment(productId, 'views', 1); // Autocomplete works
346
+ await repo.upsert({ sku: 'ABC' }, { name: 'Product' }); // ✅ Type-safe
347
+ await repo.pushToArray(productId, 'tags', 'featured'); // Validated
348
+ await repo.findBySku('ABC'); // ✅ Custom methods too
349
+ ```
350
+
351
+ **Available operations:**
352
+ - `upsert(query, data, opts)` - Create or find document
353
+ - `increment(id, field, value, opts)` - Atomically increment field
354
+ - `decrement(id, field, value, opts)` - Atomically decrement field
355
+ - `pushToArray(id, field, value, opts)` - Add to array
356
+ - `pullFromArray(id, field, value, opts)` - Remove from array
357
+ - `addToSet(id, field, value, opts)` - Add unique value to array
358
+ - `setField(id, field, value, opts)` - Set field value
359
+ - `unsetField(id, fields, opts)` - Remove field(s)
360
+ - `renameField(id, oldName, newName, opts)` - Rename field
361
+ - `multiplyField(id, field, multiplier, opts)` - Multiply numeric field
362
+ - `setMin(id, field, value, opts)` - Set to min (if current > value)
363
+ - `setMax(id, field, value, opts)` - Set to max (if current < value)
364
+
365
+ ### Plugin Type Safety
366
+
367
+ Plugin methods are added at runtime. Use `WithPlugins<TDoc, TRepo>` for TypeScript autocomplete:
368
+
369
+ ```typescript
370
+ import type { WithPlugins } from '@classytic/mongokit';
371
+
372
+ class UserRepo extends Repository<IUser> {}
373
+
374
+ const repo = new UserRepo(Model, [
375
+ methodRegistryPlugin(),
376
+ mongoOperationsPlugin(),
377
+ // ... other plugins
378
+ ]) as WithPlugins<IUser, UserRepo>;
379
+
380
+ // Full TypeScript autocomplete!
381
+ await repo.increment(id, 'views', 1);
382
+ await repo.restore(id);
383
+ await repo.invalidateCache(id);
384
+ ```
385
+
386
+ **Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`
387
+
388
+ ## Event System
389
+
390
+ ```javascript
391
+ repo.on('before:create', async (context) => {
392
+ context.data.processedAt = new Date();
393
+ });
394
+
395
+ repo.on('after:create', ({ context, result }) => {
396
+ console.log('Created:', result);
397
+ });
398
+
399
+ repo.on('error:create', ({ context, error }) => {
400
+ console.error('Failed:', error);
401
+ });
402
+ ```
403
+
404
+ **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
405
+
406
+ ## Building REST APIs
407
+
408
+ 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.
409
+
410
+ ### IController Interface
411
+
412
+ Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
413
+
414
+ ```typescript
415
+ import type { IController, IRequestContext, IControllerResponse } from '@classytic/mongokit';
416
+
417
+ // IRequestContext - what your controller receives
418
+ interface IRequestContext {
419
+ query: Record<string, unknown>; // URL query params
420
+ body: Record<string, unknown>; // Request body
421
+ params: Record<string, string>; // Route params (:id)
422
+ user?: { id: string; role?: string }; // Auth user
423
+ context?: Record<string, unknown>; // Tenant ID, etc.
424
+ }
425
+
426
+ // IControllerResponse - what your controller returns
427
+ interface IControllerResponse<T> {
428
+ success: boolean;
429
+ data?: T;
430
+ error?: string;
431
+ status: number;
432
+ }
433
+
434
+ // IController - implement this interface
435
+ interface IController<TDoc> {
436
+ list(ctx: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
437
+ get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
438
+ create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
439
+ update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
440
+ delete(ctx: IRequestContext): Promise<IControllerResponse<{ message: string }>>;
441
+ }
442
+ ```
443
+
444
+ ### QueryParser
445
+
446
+ Converts HTTP query strings to MongoDB queries with built-in security:
447
+
448
+ ```typescript
449
+ import { QueryParser } from '@classytic/mongokit';
450
+
451
+ const parser = new QueryParser({
452
+ maxLimit: 100, // Prevent excessive queries
453
+ maxFilterDepth: 5, // Prevent nested injection
454
+ maxRegexLength: 100, // ReDoS protection
455
+ });
456
+
457
+ // Parse request query
458
+ const { filters, limit, page, sort, search } = parser.parse(req.query);
459
+ ```
460
+
461
+ **Supported query patterns:**
462
+ ```bash
463
+ # Filtering
464
+ GET /users?status=active&role=admin
465
+ GET /users?age[gte]=18&age[lte]=65
466
+ GET /users?role[in]=admin,user
467
+ GET /users?email[contains]=@gmail.com
468
+ GET /users?name[regex]=^John
469
+
470
+ # Pagination
471
+ GET /users?page=2&limit=50
472
+ GET /users?after=eyJfaWQiOi...&limit=20 # Cursor-based
473
+
474
+ # Sorting
475
+ GET /users?sort=-createdAt,name
476
+
477
+ # Search (requires text index)
478
+ GET /users?search=john
479
+ ```
480
+
481
+ **Security features:**
482
+ - Blocks `$where`, `$function`, `$accumulator`, `$expr` operators
483
+ - ReDoS protection for regex patterns
484
+ - Max filter depth enforcement
485
+ - Collection allowlists for lookups
486
+
487
+ ### JSON Schema Generation
488
+
489
+ Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
490
+
491
+ ```typescript
492
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
493
+
494
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
495
+ fieldRules: {
496
+ organizationId: { immutable: true }, // Can't update after create
497
+ role: { systemManaged: true }, // Users can't set this
498
+ createdAt: { systemManaged: true },
499
+ },
500
+ strictAdditionalProperties: true, // Reject unknown fields
501
+ });
502
+
503
+ // Generated schemas:
504
+ // crudSchemas.createBody - POST body validation
505
+ // crudSchemas.updateBody - PATCH body validation
506
+ // crudSchemas.params - Route params (:id)
507
+ // crudSchemas.listQuery - GET query validation
508
+ ```
509
+
510
+ ### Complete Controller Example
511
+
512
+ ```typescript
513
+ import {
514
+ Repository,
515
+ QueryParser,
516
+ buildCrudSchemasFromModel,
517
+ type IController,
518
+ type IRequestContext,
519
+ type IControllerResponse,
520
+ } from '@classytic/mongokit';
521
+
522
+ class UserController implements IController<IUser> {
523
+ private repo = new Repository(UserModel);
524
+ private parser = new QueryParser({ maxLimit: 100 });
525
+
526
+ async list(ctx: IRequestContext): Promise<IControllerResponse> {
527
+ const { filters, limit, page, sort } = this.parser.parse(ctx.query);
528
+
529
+ // Inject tenant filter
530
+ if (ctx.context?.organizationId) {
531
+ filters.organizationId = ctx.context.organizationId;
532
+ }
533
+
534
+ const result = await this.repo.getAll({ filters, limit, page, sort });
535
+ return { success: true, data: result, status: 200 };
536
+ }
537
+
538
+ async get(ctx: IRequestContext): Promise<IControllerResponse> {
539
+ const doc = await this.repo.getById(ctx.params.id);
540
+ return { success: true, data: doc, status: 200 };
541
+ }
542
+
543
+ async create(ctx: IRequestContext): Promise<IControllerResponse> {
544
+ const doc = await this.repo.create(ctx.body);
545
+ return { success: true, data: doc, status: 201 };
546
+ }
547
+
548
+ async update(ctx: IRequestContext): Promise<IControllerResponse> {
549
+ const doc = await this.repo.update(ctx.params.id, ctx.body);
550
+ return { success: true, data: doc, status: 200 };
551
+ }
552
+
553
+ async delete(ctx: IRequestContext): Promise<IControllerResponse> {
554
+ await this.repo.delete(ctx.params.id);
555
+ return { success: true, data: { message: 'Deleted' }, status: 200 };
556
+ }
557
+ }
558
+ ```
559
+
560
+ ### Fastify Integration
561
+
562
+ ```typescript
563
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
564
+
565
+ const controller = new UserController();
566
+ const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
567
+
568
+ // Routes with auto-validation and OpenAPI docs
569
+ fastify.get('/users', { schema: { querystring: crudSchemas.listQuery } }, async (req, reply) => {
570
+ const ctx = { query: req.query, body: {}, params: {}, user: req.user };
571
+ const response = await controller.list(ctx);
572
+ return reply.status(response.status).send(response);
573
+ });
574
+
575
+ fastify.post('/users', { schema: { body: crudSchemas.createBody } }, async (req, reply) => {
576
+ const ctx = { query: {}, body: req.body, params: {}, user: req.user };
577
+ const response = await controller.create(ctx);
578
+ return reply.status(response.status).send(response);
579
+ });
580
+
581
+ fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req, reply) => {
582
+ const ctx = { query: {}, body: {}, params: req.params, user: req.user };
583
+ const response = await controller.get(ctx);
584
+ return reply.status(response.status).send(response);
585
+ });
586
+ ```
587
+
588
+ ### Express Integration
589
+
590
+ ```typescript
591
+ const controller = new UserController();
592
+
593
+ app.get('/users', async (req, res) => {
594
+ const ctx = { query: req.query, body: {}, params: {}, user: req.user };
595
+ const response = await controller.list(ctx);
596
+ res.status(response.status).json(response);
597
+ });
598
+
599
+ app.post('/users', async (req, res) => {
600
+ const ctx = { query: {}, body: req.body, params: {}, user: req.user };
601
+ const response = await controller.create(ctx);
602
+ res.status(response.status).json(response);
603
+ });
604
+ ```
605
+
606
+ ## TypeScript
607
+
608
+ ```typescript
609
+ import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
610
+
611
+ interface IUser extends Document {
612
+ name: string;
613
+ email: string;
614
+ }
615
+
616
+ const repo = new Repository<IUser>(UserModel);
617
+
618
+ const result = await repo.getAll({ page: 1, limit: 20 });
619
+
620
+ // Discriminated union - TypeScript knows the type
621
+ if (result.method === 'offset') {
622
+ console.log(result.total, result.pages); // Available
623
+ }
624
+ if (result.method === 'keyset') {
625
+ console.log(result.next, result.hasMore); // Available
626
+ }
627
+ ```
628
+
629
+ ## Extending Repository
630
+
631
+ Create custom repository classes with domain-specific methods:
632
+
633
+ ```typescript
634
+ import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
635
+ import UserModel, { IUser } from './models/User.js';
636
+
637
+ class UserRepository extends Repository<IUser> {
638
+ constructor() {
639
+ super(UserModel, [
640
+ timestampPlugin(),
641
+ softDeletePlugin()
642
+ ], {
643
+ defaultLimit: 20
644
+ });
645
+ }
646
+
647
+ // Custom domain methods
648
+ async findByEmail(email: string) {
649
+ return this.getByQuery({ email });
650
+ }
651
+
652
+ async findActiveUsers() {
653
+ return this.getAll({
654
+ filters: { status: 'active' },
655
+ sort: { createdAt: -1 }
656
+ });
657
+ }
658
+
659
+ async deactivate(id: string) {
660
+ return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
661
+ }
662
+ }
663
+
664
+ // Usage
665
+ const userRepo = new UserRepository();
666
+ const user = await userRepo.findByEmail('john@example.com');
667
+ ```
668
+
669
+ ### Overriding Methods
670
+
671
+ ```typescript
672
+ class AuditedUserRepository extends Repository<IUser> {
673
+ constructor() {
674
+ super(UserModel);
675
+ }
676
+
677
+ // Override create to add audit trail
678
+ async create(data: Partial<IUser>, options = {}) {
679
+ const result = await super.create({
680
+ ...data,
681
+ createdBy: getCurrentUserId()
682
+ }, options);
683
+
684
+ await auditLog('user.created', result._id);
685
+ return result;
686
+ }
687
+ }
688
+ ```
689
+
690
+ ## Factory Function
691
+
692
+ For simple cases without custom methods:
693
+
694
+ ```javascript
695
+ import { createRepository, timestampPlugin } from '@classytic/mongokit';
696
+
697
+ const userRepo = createRepository(UserModel, [timestampPlugin()], {
698
+ defaultLimit: 20
699
+ });
700
+ ```
701
+
702
+ ## No Breaking Changes
703
+
704
+ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
705
+
706
+ - Uses its own event system (not Mongoose middleware)
707
+ - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
708
+ - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
709
+ - All 194 tests pass on both Mongoose 8 and 9
710
+
711
+ ## License
712
+
713
+ MIT