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