@classytic/mongokit 3.2.0 → 3.2.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 +470 -193
- package/dist/actions/index.d.mts +9 -0
- package/dist/actions/index.mjs +15 -0
- package/dist/aggregate-BAi4Do-X.mjs +767 -0
- package/dist/aggregate-CCHI7F51.d.mts +269 -0
- package/dist/ai/index.d.mts +125 -0
- package/dist/ai/index.mjs +203 -0
- package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/create-BuO6xt0v.mjs +55 -0
- package/dist/custom-id.plugin-B_zIs6gE.mjs +1818 -0
- package/dist/custom-id.plugin-BzZI4gnE.d.mts +893 -0
- package/dist/index.d.mts +1012 -0
- package/dist/index.mjs +1906 -0
- package/dist/limits-DsNeCx4D.mjs +299 -0
- package/dist/logger-D8ily-PP.mjs +51 -0
- package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
- package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
- package/dist/pagination/PaginationEngine.d.mts +93 -0
- package/dist/pagination/PaginationEngine.mjs +196 -0
- package/dist/plugins/index.d.mts +3 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/types-D-gploPr.d.mts +1241 -0
- package/dist/utils/{index.d.ts → index.d.mts} +14 -21
- package/dist/utils/index.mjs +5 -0
- package/package.json +21 -21
- package/dist/actions/index.d.ts +0 -3
- package/dist/actions/index.js +0 -5
- package/dist/ai/index.d.ts +0 -175
- package/dist/ai/index.js +0 -206
- package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
- package/dist/chunks/chunk-44KXLGPO.js +0 -388
- package/dist/chunks/chunk-DEVXDBRL.js +0 -1226
- package/dist/chunks/chunk-I7CWNAJB.js +0 -46
- package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
- package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
- package/dist/chunks/chunk-URLJFIR7.js +0 -22
- package/dist/chunks/chunk-VWKIKZYF.js +0 -737
- package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
- package/dist/index-BDn5fSTE.d.ts +0 -516
- package/dist/index.d.ts +0 -1422
- package/dist/index.js +0 -1893
- package/dist/pagination/PaginationEngine.d.ts +0 -117
- package/dist/pagination/PaginationEngine.js +0 -3
- package/dist/plugins/index.d.ts +0 -922
- package/dist/plugins/index.js +0 -6
- package/dist/types-Jni1KgkP.d.ts +0 -780
- package/dist/utils/index.js +0 -5
package/README.md
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
12
|
- **Zero dependencies** - Only Mongoose as peer dependency
|
|
13
|
-
- **
|
|
14
|
-
- **Event-driven** - Pre/post hooks for all operations
|
|
15
|
-
- **
|
|
13
|
+
- **Explicit + smart pagination** - Explicit `mode` control or auto-detection; offset, keyset, and aggregate
|
|
14
|
+
- **Event-driven** - Pre/post hooks for all operations (granular scalability hooks)
|
|
15
|
+
- **16 built-in plugins** - Caching, soft delete, validation, multi-tenant, custom IDs, observability, Elasticsearch, and more
|
|
16
|
+
- **Distributed cache safety** - List cache versions stored in the adapter (Redis) for multi-pod correctness
|
|
17
|
+
- **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
|
|
16
18
|
- **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
|
|
17
19
|
- **TypeScript first** - Full type safety with discriminated unions
|
|
18
|
-
- **
|
|
20
|
+
- **604 passing tests** - Battle-tested and production-ready
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
21
23
|
|
|
@@ -28,19 +30,19 @@ npm install @classytic/mongokit mongoose
|
|
|
28
30
|
## Quick Start
|
|
29
31
|
|
|
30
32
|
```javascript
|
|
31
|
-
import { Repository } from
|
|
32
|
-
import UserModel from
|
|
33
|
+
import { Repository } from "@classytic/mongokit";
|
|
34
|
+
import UserModel from "./models/User.js";
|
|
33
35
|
|
|
34
36
|
const userRepo = new Repository(UserModel);
|
|
35
37
|
|
|
36
38
|
// Create
|
|
37
|
-
const user = await userRepo.create({ name:
|
|
39
|
+
const user = await userRepo.create({ name: "John", email: "john@example.com" });
|
|
38
40
|
|
|
39
41
|
// Read with auto-detected pagination
|
|
40
42
|
const users = await userRepo.getAll({ page: 1, limit: 20 });
|
|
41
43
|
|
|
42
44
|
// Update
|
|
43
|
-
await userRepo.update(user._id, { name:
|
|
45
|
+
await userRepo.update(user._id, { name: "Jane" });
|
|
44
46
|
|
|
45
47
|
// Delete
|
|
46
48
|
await userRepo.delete(user._id);
|
|
@@ -48,22 +50,27 @@ await userRepo.delete(user._id);
|
|
|
48
50
|
|
|
49
51
|
## Pagination
|
|
50
52
|
|
|
51
|
-
`getAll()`
|
|
53
|
+
`getAll()` takes an **explicit `mode`** or auto-detects based on parameters:
|
|
52
54
|
|
|
53
55
|
```javascript
|
|
54
|
-
// Offset pagination (
|
|
56
|
+
// EXPLICIT: Offset pagination (recommended for dashboards, admin panels)
|
|
55
57
|
const result = await repo.getAll({
|
|
58
|
+
mode: "offset", // explicit — no ambiguity
|
|
56
59
|
page: 1,
|
|
57
60
|
limit: 20,
|
|
58
|
-
filters: { status:
|
|
59
|
-
sort: { createdAt: -1 }
|
|
61
|
+
filters: { status: "active" },
|
|
62
|
+
sort: { createdAt: -1 },
|
|
63
|
+
countStrategy: "exact", // 'exact' | 'estimated' | 'none'
|
|
64
|
+
hint: { createdAt: -1 }, // index hint for query governance
|
|
65
|
+
maxTimeMS: 2000, // kill slow queries
|
|
60
66
|
});
|
|
61
67
|
// → { method: 'offset', docs, total, pages, hasNext, hasPrev }
|
|
62
68
|
|
|
63
|
-
// Keyset pagination (
|
|
69
|
+
// EXPLICIT: Keyset pagination (recommended for feeds, infinite scroll)
|
|
64
70
|
const stream = await repo.getAll({
|
|
71
|
+
mode: "keyset",
|
|
65
72
|
sort: { createdAt: -1 },
|
|
66
|
-
limit: 20
|
|
73
|
+
limit: 20,
|
|
67
74
|
});
|
|
68
75
|
// → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
|
|
69
76
|
|
|
@@ -71,20 +78,37 @@ const stream = await repo.getAll({
|
|
|
71
78
|
const next = await repo.getAll({
|
|
72
79
|
after: stream.next,
|
|
73
80
|
sort: { createdAt: -1 },
|
|
74
|
-
limit: 20
|
|
81
|
+
limit: 20,
|
|
75
82
|
});
|
|
83
|
+
|
|
84
|
+
// AUTO-DETECTION (backwards compatible, no mode required)
|
|
85
|
+
// page parameter → offset mode
|
|
86
|
+
// after/cursor parameter → keyset mode
|
|
87
|
+
// sort without page → keyset mode (first page)
|
|
88
|
+
// nothing/filters only → offset mode (page 1)
|
|
76
89
|
```
|
|
77
90
|
|
|
78
|
-
**Auto-detection rules:**
|
|
79
|
-
|
|
80
|
-
- `
|
|
81
|
-
- `
|
|
82
|
-
-
|
|
91
|
+
**Auto-detection rules (when `mode` is omitted):**
|
|
92
|
+
|
|
93
|
+
- `page` present → **offset** mode
|
|
94
|
+
- `after` or `cursor` present → **keyset** mode
|
|
95
|
+
- Non-default `sort` provided without `page` → **keyset** mode
|
|
96
|
+
- Nothing / filters only → **offset** mode (page 1)
|
|
97
|
+
|
|
98
|
+
> ⚠️ **Recommended:** Always pass `mode` explicitly in new code to make intent clear and avoid surprising behavior when query params change.
|
|
99
|
+
|
|
100
|
+
### Performance Options
|
|
101
|
+
|
|
102
|
+
| Option | Type | Description |
|
|
103
|
+
| --------------- | ------------------------------ | ------------------------------------------------------------------- |
|
|
104
|
+
| `hint` | `string \| object` | Force a specific index — prevents collection scans on large tables |
|
|
105
|
+
| `maxTimeMS` | `number` | Kill query if it takes longer than N ms (prevent runaway queries) |
|
|
106
|
+
| `countStrategy` | `'exact'\|'estimated'\|'none'` | Control cost of total-count query — use `'estimated'` for 10M+ rows |
|
|
83
107
|
|
|
84
108
|
### Required Indexes
|
|
85
109
|
|
|
86
110
|
```javascript
|
|
87
|
-
// For keyset pagination: sort field + _id
|
|
111
|
+
// For keyset pagination: sort field + _id (compound)
|
|
88
112
|
PostSchema.index({ createdAt: -1, _id: -1 });
|
|
89
113
|
|
|
90
114
|
// For multi-tenant: tenant + sort field + _id
|
|
@@ -95,18 +119,20 @@ UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
|
|
|
95
119
|
|
|
96
120
|
### CRUD Operations
|
|
97
121
|
|
|
98
|
-
| Method
|
|
99
|
-
|
|
100
|
-
| `create(data, opts)`
|
|
101
|
-
| `createMany(data[], opts)`
|
|
102
|
-
| `getById(id, opts)`
|
|
103
|
-
| `getByQuery(query, opts)`
|
|
104
|
-
| `getAll(params, opts)`
|
|
105
|
-
| `getOrCreate(query, data, opts)` | Find or create
|
|
106
|
-
| `update(id, data, opts)`
|
|
107
|
-
| `delete(id, opts)`
|
|
108
|
-
| `count(query, opts)`
|
|
109
|
-
| `exists(query, opts)`
|
|
122
|
+
| Method | Description |
|
|
123
|
+
| -------------------------------- | ---------------------------------- |
|
|
124
|
+
| `create(data, opts)` | Create single document |
|
|
125
|
+
| `createMany(data[], opts)` | Create multiple documents |
|
|
126
|
+
| `getById(id, opts)` | Find by ID |
|
|
127
|
+
| `getByQuery(query, opts)` | Find one by query |
|
|
128
|
+
| `getAll(params, opts)` | Paginated list (auto-detects mode) |
|
|
129
|
+
| `getOrCreate(query, data, opts)` | Find or create |
|
|
130
|
+
| `update(id, data, opts)` | Update document |
|
|
131
|
+
| `delete(id, opts)` | Delete document |
|
|
132
|
+
| `count(query, opts)` | Count documents |
|
|
133
|
+
| `exists(query, opts)` | Check existence |
|
|
134
|
+
|
|
135
|
+
> **Note:** All read operations (`getById`, `getByQuery`, `getAll`, `count`, `exists`, `aggregate`, etc.) accept a `readPreference` option in the `opts` parameter (e.g., `readPreference: 'secondaryPreferred'`) to support scaling reads across replica sets.
|
|
110
136
|
|
|
111
137
|
### Aggregation
|
|
112
138
|
|
|
@@ -132,8 +158,8 @@ const categories = await repo.distinct('category', { status: 'active' });
|
|
|
132
158
|
|
|
133
159
|
```javascript
|
|
134
160
|
await repo.withTransaction(async (session) => {
|
|
135
|
-
await repo.create({ name:
|
|
136
|
-
await repo.create({ name:
|
|
161
|
+
await repo.create({ name: "User 1" }, { session });
|
|
162
|
+
await repo.create({ name: "User 2" }, { session });
|
|
137
163
|
// Auto-commits on success, auto-rollbacks on error
|
|
138
164
|
});
|
|
139
165
|
```
|
|
@@ -142,12 +168,12 @@ await repo.withTransaction(async (session) => {
|
|
|
142
168
|
|
|
143
169
|
```javascript
|
|
144
170
|
const repo = new Repository(UserModel, plugins, {
|
|
145
|
-
defaultLimit: 20,
|
|
146
|
-
maxLimit: 100,
|
|
147
|
-
maxPage: 10000,
|
|
148
|
-
deepPageThreshold: 100,
|
|
149
|
-
useEstimatedCount: false,
|
|
150
|
-
cursorVersion: 1
|
|
171
|
+
defaultLimit: 20, // Default docs per page
|
|
172
|
+
maxLimit: 100, // Maximum allowed limit
|
|
173
|
+
maxPage: 10000, // Maximum page number
|
|
174
|
+
deepPageThreshold: 100, // Warn when page exceeds this
|
|
175
|
+
useEstimatedCount: false, // Use fast estimated counts
|
|
176
|
+
cursorVersion: 1, // Cursor format version
|
|
151
177
|
});
|
|
152
178
|
```
|
|
153
179
|
|
|
@@ -161,59 +187,61 @@ import {
|
|
|
161
187
|
timestampPlugin,
|
|
162
188
|
softDeletePlugin,
|
|
163
189
|
cachePlugin,
|
|
164
|
-
createMemoryCache
|
|
165
|
-
} from
|
|
190
|
+
createMemoryCache,
|
|
191
|
+
} from "@classytic/mongokit";
|
|
166
192
|
|
|
167
193
|
const repo = new Repository(UserModel, [
|
|
168
194
|
timestampPlugin(),
|
|
169
195
|
softDeletePlugin(),
|
|
170
|
-
cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
|
|
196
|
+
cachePlugin({ adapter: createMemoryCache(), ttl: 60 }),
|
|
171
197
|
]);
|
|
172
198
|
```
|
|
173
199
|
|
|
174
200
|
### Available Plugins
|
|
175
201
|
|
|
176
|
-
| Plugin
|
|
177
|
-
|
|
178
|
-
| `timestampPlugin()`
|
|
179
|
-
| `softDeletePlugin(opts)`
|
|
180
|
-
| `auditLogPlugin(logger)`
|
|
181
|
-
| `cachePlugin(opts)`
|
|
182
|
-
| `validationChainPlugin(validators)` | Custom validation rules
|
|
183
|
-
| `fieldFilterPlugin(preset)`
|
|
184
|
-
| `cascadePlugin(opts)`
|
|
185
|
-
| `methodRegistryPlugin()`
|
|
186
|
-
| `mongoOperationsPlugin()`
|
|
187
|
-
| `batchOperationsPlugin()`
|
|
188
|
-
| `aggregateHelpersPlugin()`
|
|
189
|
-
| `subdocumentPlugin()`
|
|
190
|
-
| `multiTenantPlugin(opts)`
|
|
191
|
-
| `
|
|
202
|
+
| Plugin | Description |
|
|
203
|
+
| ----------------------------------- | --------------------------------------------------------- |
|
|
204
|
+
| `timestampPlugin()` | Auto-manage `createdAt`/`updatedAt` |
|
|
205
|
+
| `softDeletePlugin(opts)` | Mark as deleted instead of removing |
|
|
206
|
+
| `auditLogPlugin(logger)` | Log all CUD operations |
|
|
207
|
+
| `cachePlugin(opts)` | Redis/Memcached/memory caching with auto-invalidation |
|
|
208
|
+
| `validationChainPlugin(validators)` | Custom validation rules |
|
|
209
|
+
| `fieldFilterPlugin(preset)` | Role-based field visibility |
|
|
210
|
+
| `cascadePlugin(opts)` | Auto-delete related documents |
|
|
211
|
+
| `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
|
|
212
|
+
| `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
|
|
213
|
+
| `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany` |
|
|
214
|
+
| `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
|
|
215
|
+
| `subdocumentPlugin()` | Manage subdocument arrays |
|
|
216
|
+
| `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
|
|
217
|
+
| `customIdPlugin(opts)` | Auto-generate sequential/random IDs with atomic counters |
|
|
218
|
+
| `elasticSearchPlugin(opts)` | Delegate text/semantic search to Elasticsearch/OpenSearch |
|
|
219
|
+
| `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
|
|
192
220
|
|
|
193
221
|
### Soft Delete
|
|
194
222
|
|
|
195
223
|
```javascript
|
|
196
224
|
const repo = new Repository(UserModel, [
|
|
197
|
-
softDeletePlugin({ deletedField:
|
|
225
|
+
softDeletePlugin({ deletedField: "deletedAt" }),
|
|
198
226
|
]);
|
|
199
227
|
|
|
200
|
-
await repo.delete(id);
|
|
201
|
-
await repo.getAll();
|
|
202
|
-
await repo.getAll({ includeDeleted: true });
|
|
228
|
+
await repo.delete(id); // Marks as deleted
|
|
229
|
+
await repo.getAll(); // Excludes deleted
|
|
230
|
+
await repo.getAll({ includeDeleted: true }); // Includes deleted
|
|
203
231
|
```
|
|
204
232
|
|
|
205
233
|
### Caching
|
|
206
234
|
|
|
207
235
|
```javascript
|
|
208
|
-
import { cachePlugin, createMemoryCache } from
|
|
236
|
+
import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
|
|
209
237
|
|
|
210
238
|
const repo = new Repository(UserModel, [
|
|
211
239
|
cachePlugin({
|
|
212
|
-
adapter: createMemoryCache(),
|
|
213
|
-
ttl: 60,
|
|
214
|
-
byIdTtl: 300,
|
|
215
|
-
queryTtl: 30,
|
|
216
|
-
})
|
|
240
|
+
adapter: createMemoryCache(), // or Redis adapter
|
|
241
|
+
ttl: 60, // Default TTL (seconds)
|
|
242
|
+
byIdTtl: 300, // TTL for getById
|
|
243
|
+
queryTtl: 30, // TTL for lists
|
|
244
|
+
}),
|
|
217
245
|
]);
|
|
218
246
|
|
|
219
247
|
// Reads are cached automatically
|
|
@@ -223,7 +251,7 @@ const user = await repo.getById(id);
|
|
|
223
251
|
const fresh = await repo.getById(id, { skipCache: true });
|
|
224
252
|
|
|
225
253
|
// Mutations auto-invalidate cache
|
|
226
|
-
await repo.update(id, { name:
|
|
254
|
+
await repo.update(id, { name: "New" });
|
|
227
255
|
|
|
228
256
|
// Manual invalidation
|
|
229
257
|
await repo.invalidateCache(id);
|
|
@@ -231,12 +259,21 @@ await repo.invalidateAllCache();
|
|
|
231
259
|
```
|
|
232
260
|
|
|
233
261
|
**Redis adapter example:**
|
|
262
|
+
|
|
234
263
|
```javascript
|
|
235
264
|
const redisAdapter = {
|
|
236
|
-
async get(key) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async
|
|
265
|
+
async get(key) {
|
|
266
|
+
return JSON.parse((await redis.get(key)) || "null");
|
|
267
|
+
},
|
|
268
|
+
async set(key, value, ttl) {
|
|
269
|
+
await redis.setex(key, ttl, JSON.stringify(value));
|
|
270
|
+
},
|
|
271
|
+
async del(key) {
|
|
272
|
+
await redis.del(key);
|
|
273
|
+
},
|
|
274
|
+
async clear(pattern) {
|
|
275
|
+
/* optional bulk delete */
|
|
276
|
+
},
|
|
240
277
|
};
|
|
241
278
|
```
|
|
242
279
|
|
|
@@ -249,37 +286,40 @@ import {
|
|
|
249
286
|
uniqueField,
|
|
250
287
|
immutableField,
|
|
251
288
|
blockIf,
|
|
252
|
-
autoInject
|
|
253
|
-
} from
|
|
289
|
+
autoInject,
|
|
290
|
+
} from "@classytic/mongokit";
|
|
254
291
|
|
|
255
292
|
const repo = new Repository(UserModel, [
|
|
256
293
|
validationChainPlugin([
|
|
257
|
-
requireField(
|
|
258
|
-
uniqueField(
|
|
259
|
-
immutableField(
|
|
260
|
-
blockIf(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
294
|
+
requireField("email", ["create"]),
|
|
295
|
+
uniqueField("email", "Email already exists"),
|
|
296
|
+
immutableField("userId"),
|
|
297
|
+
blockIf(
|
|
298
|
+
"noAdminDelete",
|
|
299
|
+
["delete"],
|
|
300
|
+
(ctx) => ctx.data?.role === "admin",
|
|
301
|
+
"Cannot delete admin users",
|
|
302
|
+
),
|
|
303
|
+
autoInject("slug", (ctx) => slugify(ctx.data?.name), ["create"]),
|
|
304
|
+
]),
|
|
265
305
|
]);
|
|
266
306
|
```
|
|
267
307
|
|
|
268
308
|
### Cascade Delete
|
|
269
309
|
|
|
270
310
|
```javascript
|
|
271
|
-
import { cascadePlugin, softDeletePlugin } from
|
|
311
|
+
import { cascadePlugin, softDeletePlugin } from "@classytic/mongokit";
|
|
272
312
|
|
|
273
313
|
const repo = new Repository(ProductModel, [
|
|
274
314
|
softDeletePlugin(),
|
|
275
315
|
cascadePlugin({
|
|
276
316
|
relations: [
|
|
277
|
-
{ model:
|
|
278
|
-
{ model:
|
|
317
|
+
{ model: "StockEntry", foreignKey: "product" },
|
|
318
|
+
{ model: "Review", foreignKey: "product", softDelete: false },
|
|
279
319
|
],
|
|
280
320
|
parallel: true,
|
|
281
|
-
logger: console
|
|
282
|
-
})
|
|
321
|
+
logger: console,
|
|
322
|
+
}),
|
|
283
323
|
]);
|
|
284
324
|
|
|
285
325
|
// Deleting product also deletes related StockEntry and Review docs
|
|
@@ -289,40 +329,40 @@ await repo.delete(productId);
|
|
|
289
329
|
### Field Filtering (RBAC)
|
|
290
330
|
|
|
291
331
|
```javascript
|
|
292
|
-
import { fieldFilterPlugin } from
|
|
332
|
+
import { fieldFilterPlugin } from "@classytic/mongokit";
|
|
293
333
|
|
|
294
334
|
const repo = new Repository(UserModel, [
|
|
295
335
|
fieldFilterPlugin({
|
|
296
|
-
public: [
|
|
297
|
-
authenticated: [
|
|
298
|
-
admin: [
|
|
299
|
-
})
|
|
336
|
+
public: ["id", "name", "avatar"],
|
|
337
|
+
authenticated: ["email", "phone"],
|
|
338
|
+
admin: ["createdAt", "internalNotes"],
|
|
339
|
+
}),
|
|
300
340
|
]);
|
|
301
341
|
```
|
|
302
342
|
|
|
303
343
|
### Multi-Tenant
|
|
304
344
|
|
|
305
345
|
```javascript
|
|
306
|
-
import { multiTenantPlugin } from
|
|
346
|
+
import { multiTenantPlugin } from "@classytic/mongokit";
|
|
307
347
|
|
|
308
348
|
const repo = new Repository(UserModel, [
|
|
309
349
|
multiTenantPlugin({
|
|
310
|
-
tenantField:
|
|
311
|
-
contextKey:
|
|
350
|
+
tenantField: "organizationId",
|
|
351
|
+
contextKey: "organizationId", // reads from context
|
|
312
352
|
required: true,
|
|
313
|
-
})
|
|
353
|
+
}),
|
|
314
354
|
]);
|
|
315
355
|
|
|
316
356
|
// All operations are automatically scoped to the tenant
|
|
317
|
-
const users = await repo.getAll({ organizationId:
|
|
318
|
-
await repo.update(userId, { name:
|
|
357
|
+
const users = await repo.getAll({ organizationId: "org_123" });
|
|
358
|
+
await repo.update(userId, { name: "New" }, { organizationId: "org_123" });
|
|
319
359
|
// Cross-tenant update/delete is blocked — returns "not found"
|
|
320
360
|
```
|
|
321
361
|
|
|
322
362
|
### Observability
|
|
323
363
|
|
|
324
364
|
```javascript
|
|
325
|
-
import { observabilityPlugin } from
|
|
365
|
+
import { observabilityPlugin } from "@classytic/mongokit";
|
|
326
366
|
|
|
327
367
|
const repo = new Repository(UserModel, [
|
|
328
368
|
observabilityPlugin({
|
|
@@ -330,11 +370,154 @@ const repo = new Repository(UserModel, [
|
|
|
330
370
|
// Send to DataDog, New Relic, OpenTelemetry, etc.
|
|
331
371
|
statsd.histogram(`mongokit.${metric.operation}`, metric.duration);
|
|
332
372
|
},
|
|
333
|
-
slowThresholdMs: 200,
|
|
334
|
-
})
|
|
373
|
+
slowThresholdMs: 200, // log operations slower than 200ms
|
|
374
|
+
}),
|
|
375
|
+
]);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Custom ID Generation
|
|
379
|
+
|
|
380
|
+
Generate human-readable sequential IDs (e.g., `INV-0001`, `BILL-2026-02-0001`) using atomic MongoDB counters — safe under concurrency with zero duplicates.
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import {
|
|
384
|
+
Repository,
|
|
385
|
+
customIdPlugin,
|
|
386
|
+
sequentialId,
|
|
387
|
+
dateSequentialId,
|
|
388
|
+
prefixedId,
|
|
389
|
+
} from "@classytic/mongokit";
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### Sequential Counter
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const invoiceRepo = new Repository(InvoiceModel, [
|
|
396
|
+
customIdPlugin({
|
|
397
|
+
field: "invoiceNumber",
|
|
398
|
+
generator: sequentialId({
|
|
399
|
+
prefix: "INV",
|
|
400
|
+
model: InvoiceModel,
|
|
401
|
+
}),
|
|
402
|
+
}),
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
const inv1 = await invoiceRepo.create({ amount: 100 });
|
|
406
|
+
// inv1.invoiceNumber → "INV-0001"
|
|
407
|
+
|
|
408
|
+
const inv2 = await invoiceRepo.create({ amount: 200 });
|
|
409
|
+
// inv2.invoiceNumber → "INV-0002"
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Options:**
|
|
413
|
+
|
|
414
|
+
| Option | Default | Description |
|
|
415
|
+
| ------------ | ------------ | ---------------------------------------------------- |
|
|
416
|
+
| `prefix` | _(required)_ | Prefix string (e.g., `'INV'`, `'ORD'`) |
|
|
417
|
+
| `model` | _(required)_ | Mongoose model (counter key derived from model name) |
|
|
418
|
+
| `padding` | `4` | Number of digits (`4` → `0001`) |
|
|
419
|
+
| `separator` | `'-'` | Separator between prefix and number |
|
|
420
|
+
| `counterKey` | model name | Custom counter key to avoid collisions |
|
|
421
|
+
|
|
422
|
+
#### Date-Partitioned Counter
|
|
423
|
+
|
|
424
|
+
Counter resets per period — ideal for invoice/bill numbering:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
const billRepo = new Repository(BillModel, [
|
|
428
|
+
customIdPlugin({
|
|
429
|
+
field: "billNumber",
|
|
430
|
+
generator: dateSequentialId({
|
|
431
|
+
prefix: "BILL",
|
|
432
|
+
model: BillModel,
|
|
433
|
+
partition: "monthly", // resets each month
|
|
434
|
+
}),
|
|
435
|
+
}),
|
|
436
|
+
]);
|
|
437
|
+
|
|
438
|
+
const bill = await billRepo.create({ total: 250 });
|
|
439
|
+
// bill.billNumber → "BILL-2026-02-0001"
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**Partition modes:**
|
|
443
|
+
|
|
444
|
+
- `'yearly'` → `BILL-2026-0001` (resets every January)
|
|
445
|
+
- `'monthly'` → `BILL-2026-02-0001` (resets every month)
|
|
446
|
+
- `'daily'` → `BILL-2026-02-20-0001` (resets every day)
|
|
447
|
+
|
|
448
|
+
#### Prefixed Random ID
|
|
449
|
+
|
|
450
|
+
No database round-trip — purely in-memory random suffix:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
const orderRepo = new Repository(OrderModel, [
|
|
454
|
+
customIdPlugin({
|
|
455
|
+
field: "orderRef",
|
|
456
|
+
generator: prefixedId({ prefix: "ORD", length: 10 }),
|
|
457
|
+
}),
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
const order = await orderRepo.create({ total: 99 });
|
|
461
|
+
// order.orderRef → "ORD_a7b3xk9m2p"
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
#### Custom Generator
|
|
465
|
+
|
|
466
|
+
Write your own generator function for full control:
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
const repo = new Repository(OrderModel, [
|
|
470
|
+
customIdPlugin({
|
|
471
|
+
field: "orderRef",
|
|
472
|
+
generator: async (context) => {
|
|
473
|
+
const region = context.data?.region || "US";
|
|
474
|
+
const seq = await getNextSequence("orders");
|
|
475
|
+
return `ORD-${region}-${String(seq).padStart(4, "0")}`;
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
335
478
|
]);
|
|
479
|
+
// → "ORD-US-0001", "ORD-EU-0002", ...
|
|
336
480
|
```
|
|
337
481
|
|
|
482
|
+
#### Plugin Options
|
|
483
|
+
|
|
484
|
+
| Option | Default | Description |
|
|
485
|
+
| --------------------- | ------------ | -------------------------------------------- |
|
|
486
|
+
| `field` | `'customId'` | Document field to store the generated ID |
|
|
487
|
+
| `generator` | _(required)_ | Function returning the ID (sync or async) |
|
|
488
|
+
| `generateOnlyIfEmpty` | `true` | Skip generation if field already has a value |
|
|
489
|
+
|
|
490
|
+
#### Batch Creation
|
|
491
|
+
|
|
492
|
+
Works with `createMany` — each document gets its own sequential ID:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
const docs = await invoiceRepo.createMany([
|
|
496
|
+
{ amount: 10 },
|
|
497
|
+
{ amount: 20, invoiceNumber: "MANUAL-001" }, // skipped (already has ID)
|
|
498
|
+
{ amount: 30 },
|
|
499
|
+
]);
|
|
500
|
+
// docs[0].invoiceNumber → "INV-0001"
|
|
501
|
+
// docs[1].invoiceNumber → "MANUAL-001" (preserved)
|
|
502
|
+
// docs[2].invoiceNumber → "INV-0002"
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
#### Atomic Counter API
|
|
506
|
+
|
|
507
|
+
The `getNextSequence` helper is exported for use in custom generators:
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
import { getNextSequence } from "@classytic/mongokit";
|
|
511
|
+
|
|
512
|
+
const seq = await getNextSequence("my-counter"); // → 1, 2, 3, ...
|
|
513
|
+
const batch = await getNextSequence("my-counter", 5); // → jumps by 5
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Counters are stored in the `_mongokit_counters` collection using MongoDB's atomic `findOneAndUpdate` + `$inc` — guaranteed unique under any level of concurrency.
|
|
517
|
+
|
|
518
|
+
> **Note:** Counters are monotonically increasing and never decrement on document deletion.
|
|
519
|
+
> This is standard behavior for business documents (invoices, bills, receipts) — you never reuse a number.
|
|
520
|
+
|
|
338
521
|
### Vector Search (Atlas)
|
|
339
522
|
|
|
340
523
|
```javascript
|
|
@@ -367,10 +550,65 @@ const results = await repo.searchSimilar({ query: [0.1, 0.2, ...], limit: 5 });
|
|
|
367
550
|
const vector = await repo.embed('some text');
|
|
368
551
|
```
|
|
369
552
|
|
|
553
|
+
### Elasticsearch / OpenSearch Plugin
|
|
554
|
+
|
|
555
|
+
Delegates heavy text and semantic search to an external search engine while fetching full documents from MongoDB. Keeps your OLTP (transactional) MongoDB operations fast by separating search I/O.
|
|
556
|
+
|
|
557
|
+
**Architecture:** Query ES/OpenSearch → get IDs + relevance scores → fetch full docs from MongoDB → return in ES ranking order.
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
import {
|
|
561
|
+
Repository,
|
|
562
|
+
methodRegistryPlugin,
|
|
563
|
+
elasticSearchPlugin,
|
|
564
|
+
} from "@classytic/mongokit";
|
|
565
|
+
import { Client } from "@elastic/elasticsearch"; // or '@opensearch-project/opensearch'
|
|
566
|
+
|
|
567
|
+
const esClient = new Client({ node: "http://localhost:9200" });
|
|
568
|
+
|
|
569
|
+
const productRepo = new Repository(ProductModel, [
|
|
570
|
+
methodRegistryPlugin(), // Required first
|
|
571
|
+
elasticSearchPlugin({
|
|
572
|
+
client: esClient,
|
|
573
|
+
index: "products",
|
|
574
|
+
idField: "_id", // field in ES doc that maps to MongoDB _id
|
|
575
|
+
}),
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
// Perform semantic/full-text search
|
|
579
|
+
const results = await productRepo.search(
|
|
580
|
+
{ match: { description: "wireless headphones" } },
|
|
581
|
+
{
|
|
582
|
+
limit: 20, // capped to 1000 max (safety bound)
|
|
583
|
+
from: 0,
|
|
584
|
+
mongoOptions: {
|
|
585
|
+
select: "name price description",
|
|
586
|
+
lean: true,
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
// results.docs - MongoDB documents in ES ranking order
|
|
592
|
+
// results.docs[*]._score - ES relevance score (preserved, including 0)
|
|
593
|
+
// results.total - total hits count from ES
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Why this exists:**
|
|
597
|
+
|
|
598
|
+
- `$text` in MongoDB requires a text index and is not scalable for fuzzy/semantic search
|
|
599
|
+
- ES/OpenSearch provides BM25, vector search, semantic search, analyzers, facets
|
|
600
|
+
- This plugin bridges both: ES rank + MongoDB's transactional documents
|
|
601
|
+
|
|
602
|
+
**Bounds enforcement:**
|
|
603
|
+
|
|
604
|
+
- `limit` is clamped to `[1, 1000]` — prevents runaway ES queries
|
|
605
|
+
- `from` is clamped to `>= 0` — prevents negative offsets
|
|
606
|
+
- Returns `{ docs: [], total: 0 }` immediately if ES returns no hits
|
|
607
|
+
|
|
370
608
|
### Logging
|
|
371
609
|
|
|
372
610
|
```javascript
|
|
373
|
-
import { configureLogger } from
|
|
611
|
+
import { configureLogger } from "@classytic/mongokit";
|
|
374
612
|
|
|
375
613
|
// Silence all internal warnings
|
|
376
614
|
configureLogger(false);
|
|
@@ -389,16 +627,20 @@ The `mongoOperationsPlugin` adds MongoDB-specific atomic operations like `increm
|
|
|
389
627
|
#### Basic Usage (No TypeScript Autocomplete)
|
|
390
628
|
|
|
391
629
|
```javascript
|
|
392
|
-
import {
|
|
630
|
+
import {
|
|
631
|
+
Repository,
|
|
632
|
+
methodRegistryPlugin,
|
|
633
|
+
mongoOperationsPlugin,
|
|
634
|
+
} from "@classytic/mongokit";
|
|
393
635
|
|
|
394
636
|
const repo = new Repository(ProductModel, [
|
|
395
|
-
methodRegistryPlugin(),
|
|
396
|
-
mongoOperationsPlugin()
|
|
637
|
+
methodRegistryPlugin(), // Required first
|
|
638
|
+
mongoOperationsPlugin(),
|
|
397
639
|
]);
|
|
398
640
|
|
|
399
641
|
// Works at runtime but TypeScript doesn't provide autocomplete
|
|
400
|
-
await repo.increment(productId,
|
|
401
|
-
await repo.upsert({ sku:
|
|
642
|
+
await repo.increment(productId, "views", 1);
|
|
643
|
+
await repo.upsert({ sku: "ABC" }, { name: "Product", price: 99 });
|
|
402
644
|
```
|
|
403
645
|
|
|
404
646
|
#### With TypeScript Type Safety (Recommended)
|
|
@@ -406,8 +648,12 @@ await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
|
406
648
|
For full TypeScript autocomplete and type checking, use the `MongoOperationsMethods` type:
|
|
407
649
|
|
|
408
650
|
```typescript
|
|
409
|
-
import {
|
|
410
|
-
|
|
651
|
+
import {
|
|
652
|
+
Repository,
|
|
653
|
+
methodRegistryPlugin,
|
|
654
|
+
mongoOperationsPlugin,
|
|
655
|
+
} from "@classytic/mongokit";
|
|
656
|
+
import type { MongoOperationsMethods } from "@classytic/mongokit";
|
|
411
657
|
|
|
412
658
|
// 1. Create your repository class
|
|
413
659
|
class ProductRepo extends Repository<IProduct> {
|
|
@@ -423,17 +669,18 @@ type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
|
423
669
|
// 3. Instantiate with type assertion
|
|
424
670
|
const repo = new ProductRepo(ProductModel, [
|
|
425
671
|
methodRegistryPlugin(),
|
|
426
|
-
mongoOperationsPlugin()
|
|
672
|
+
mongoOperationsPlugin(),
|
|
427
673
|
]) as ProductRepoWithPlugins;
|
|
428
674
|
|
|
429
675
|
// 4. Now TypeScript provides full autocomplete and type checking!
|
|
430
|
-
await repo.increment(productId,
|
|
431
|
-
await repo.upsert({ sku:
|
|
432
|
-
await repo.pushToArray(productId,
|
|
433
|
-
await repo.findBySku(
|
|
676
|
+
await repo.increment(productId, "views", 1); // ✅ Autocomplete works
|
|
677
|
+
await repo.upsert({ sku: "ABC" }, { name: "Product" }); // ✅ Type-safe
|
|
678
|
+
await repo.pushToArray(productId, "tags", "featured"); // ✅ Validated
|
|
679
|
+
await repo.findBySku("ABC"); // ✅ Custom methods too
|
|
434
680
|
```
|
|
435
681
|
|
|
436
682
|
**Available operations:**
|
|
683
|
+
|
|
437
684
|
- `upsert(query, data, opts)` - Create or find document
|
|
438
685
|
- `increment(id, field, value, opts)` - Atomically increment field
|
|
439
686
|
- `decrement(id, field, value, opts)` - Atomically decrement field
|
|
@@ -452,7 +699,7 @@ await repo.findBySku('ABC'); // ✅ Custom methods too
|
|
|
452
699
|
Plugin methods are added at runtime. Use `WithPlugins<TDoc, TRepo>` for TypeScript autocomplete:
|
|
453
700
|
|
|
454
701
|
```typescript
|
|
455
|
-
import type { WithPlugins } from
|
|
702
|
+
import type { WithPlugins } from "@classytic/mongokit";
|
|
456
703
|
|
|
457
704
|
class UserRepo extends Repository<IUser> {}
|
|
458
705
|
|
|
@@ -463,7 +710,7 @@ const repo = new UserRepo(Model, [
|
|
|
463
710
|
]) as WithPlugins<IUser, UserRepo>;
|
|
464
711
|
|
|
465
712
|
// Full TypeScript autocomplete!
|
|
466
|
-
await repo.increment(id,
|
|
713
|
+
await repo.increment(id, "views", 1);
|
|
467
714
|
await repo.restore(id);
|
|
468
715
|
await repo.invalidateCache(id);
|
|
469
716
|
```
|
|
@@ -473,16 +720,16 @@ await repo.invalidateCache(id);
|
|
|
473
720
|
## Event System
|
|
474
721
|
|
|
475
722
|
```javascript
|
|
476
|
-
repo.on(
|
|
723
|
+
repo.on("before:create", async (context) => {
|
|
477
724
|
context.data.processedAt = new Date();
|
|
478
725
|
});
|
|
479
726
|
|
|
480
|
-
repo.on(
|
|
481
|
-
console.log(
|
|
727
|
+
repo.on("after:create", ({ context, result }) => {
|
|
728
|
+
console.log("Created:", result);
|
|
482
729
|
});
|
|
483
730
|
|
|
484
|
-
repo.on(
|
|
485
|
-
console.error(
|
|
731
|
+
repo.on("error:create", ({ context, error }) => {
|
|
732
|
+
console.error("Failed:", error);
|
|
486
733
|
});
|
|
487
734
|
```
|
|
488
735
|
|
|
@@ -497,15 +744,19 @@ MongoKit provides a complete toolkit for building REST APIs: QueryParser for req
|
|
|
497
744
|
Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
|
|
498
745
|
|
|
499
746
|
```typescript
|
|
500
|
-
import type {
|
|
747
|
+
import type {
|
|
748
|
+
IController,
|
|
749
|
+
IRequestContext,
|
|
750
|
+
IControllerResponse,
|
|
751
|
+
} from "@classytic/mongokit";
|
|
501
752
|
|
|
502
753
|
// IRequestContext - what your controller receives
|
|
503
754
|
interface IRequestContext {
|
|
504
|
-
query: Record<string, unknown>;
|
|
505
|
-
body: Record<string, unknown>;
|
|
506
|
-
params: Record<string, string>;
|
|
507
|
-
user?: { id: string; role?: string };
|
|
508
|
-
context?: Record<string, unknown>;
|
|
755
|
+
query: Record<string, unknown>; // URL query params
|
|
756
|
+
body: Record<string, unknown>; // Request body
|
|
757
|
+
params: Record<string, string>; // Route params (:id)
|
|
758
|
+
user?: { id: string; role?: string }; // Auth user
|
|
759
|
+
context?: Record<string, unknown>; // Tenant ID, etc.
|
|
509
760
|
}
|
|
510
761
|
|
|
511
762
|
// IControllerResponse - what your controller returns
|
|
@@ -518,11 +769,15 @@ interface IControllerResponse<T> {
|
|
|
518
769
|
|
|
519
770
|
// IController - implement this interface
|
|
520
771
|
interface IController<TDoc> {
|
|
521
|
-
list(
|
|
772
|
+
list(
|
|
773
|
+
ctx: IRequestContext,
|
|
774
|
+
): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
522
775
|
get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
523
776
|
create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
524
777
|
update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
525
|
-
delete(
|
|
778
|
+
delete(
|
|
779
|
+
ctx: IRequestContext,
|
|
780
|
+
): Promise<IControllerResponse<{ message: string }>>;
|
|
526
781
|
}
|
|
527
782
|
```
|
|
528
783
|
|
|
@@ -531,12 +786,12 @@ interface IController<TDoc> {
|
|
|
531
786
|
Converts HTTP query strings to MongoDB queries with built-in security:
|
|
532
787
|
|
|
533
788
|
```typescript
|
|
534
|
-
import { QueryParser } from
|
|
789
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
535
790
|
|
|
536
791
|
const parser = new QueryParser({
|
|
537
|
-
maxLimit: 100,
|
|
538
|
-
maxFilterDepth: 5,
|
|
539
|
-
maxRegexLength: 100,
|
|
792
|
+
maxLimit: 100, // Prevent excessive queries
|
|
793
|
+
maxFilterDepth: 5, // Prevent nested injection
|
|
794
|
+
maxRegexLength: 100, // ReDoS protection
|
|
540
795
|
});
|
|
541
796
|
|
|
542
797
|
// Parse request query
|
|
@@ -544,6 +799,7 @@ const { filters, limit, page, sort, search } = parser.parse(req.query);
|
|
|
544
799
|
```
|
|
545
800
|
|
|
546
801
|
**Supported query patterns:**
|
|
802
|
+
|
|
547
803
|
```bash
|
|
548
804
|
# Filtering
|
|
549
805
|
GET /users?status=active&role=admin
|
|
@@ -574,6 +830,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
|
|
|
574
830
|
```
|
|
575
831
|
|
|
576
832
|
**Security features:**
|
|
833
|
+
|
|
577
834
|
- Blocks `$where`, `$function`, `$accumulator`, `$expr` operators
|
|
578
835
|
- ReDoS protection for regex patterns
|
|
579
836
|
- Max filter depth enforcement
|
|
@@ -586,7 +843,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
|
|
|
586
843
|
QueryParser supports Mongoose populate options via URL query parameters:
|
|
587
844
|
|
|
588
845
|
```typescript
|
|
589
|
-
import { QueryParser } from
|
|
846
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
590
847
|
|
|
591
848
|
const parser = new QueryParser();
|
|
592
849
|
|
|
@@ -596,19 +853,19 @@ const parsed = parser.parse(req.query);
|
|
|
596
853
|
// Use with Repository
|
|
597
854
|
const posts = await postRepo.getAll(
|
|
598
855
|
{ filters: parsed.filters, page: parsed.page, limit: parsed.limit },
|
|
599
|
-
{ populateOptions: parsed.populateOptions }
|
|
856
|
+
{ populateOptions: parsed.populateOptions },
|
|
600
857
|
);
|
|
601
858
|
```
|
|
602
859
|
|
|
603
860
|
**Supported populate options:**
|
|
604
861
|
|
|
605
|
-
| Option
|
|
606
|
-
|
|
607
|
-
| `select`
|
|
608
|
-
| `match`
|
|
609
|
-
| `limit`
|
|
610
|
-
| `sort`
|
|
611
|
-
| `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5)
|
|
862
|
+
| Option | URL Syntax | Description |
|
|
863
|
+
| ---------- | ------------------------------------------------ | ----------------------------------------------- |
|
|
864
|
+
| `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
|
|
865
|
+
| `match` | `populate[path][match][field]=value` | Filter populated documents |
|
|
866
|
+
| `limit` | `populate[path][limit]=10` | Limit number of populated docs |
|
|
867
|
+
| `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
|
|
868
|
+
| `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
|
|
612
869
|
|
|
613
870
|
**Example - Complex populate:**
|
|
614
871
|
|
|
@@ -632,15 +889,15 @@ const parsed = parser.parse(req.query);
|
|
|
632
889
|
Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
|
|
633
890
|
|
|
634
891
|
```typescript
|
|
635
|
-
import { buildCrudSchemasFromModel } from
|
|
892
|
+
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
636
893
|
|
|
637
894
|
const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
|
|
638
895
|
fieldRules: {
|
|
639
|
-
organizationId: { immutable: true },
|
|
640
|
-
role: { systemManaged: true },
|
|
896
|
+
organizationId: { immutable: true }, // Can't update after create
|
|
897
|
+
role: { systemManaged: true }, // Users can't set this
|
|
641
898
|
createdAt: { systemManaged: true },
|
|
642
899
|
},
|
|
643
|
-
strictAdditionalProperties: true,
|
|
900
|
+
strictAdditionalProperties: true, // Reject unknown fields
|
|
644
901
|
});
|
|
645
902
|
|
|
646
903
|
// Generated schemas:
|
|
@@ -660,7 +917,7 @@ import {
|
|
|
660
917
|
type IController,
|
|
661
918
|
type IRequestContext,
|
|
662
919
|
type IControllerResponse,
|
|
663
|
-
} from
|
|
920
|
+
} from "@classytic/mongokit";
|
|
664
921
|
|
|
665
922
|
class UserController implements IController<IUser> {
|
|
666
923
|
private repo = new Repository(UserModel);
|
|
@@ -695,7 +952,7 @@ class UserController implements IController<IUser> {
|
|
|
695
952
|
|
|
696
953
|
async delete(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
697
954
|
await this.repo.delete(ctx.params.id);
|
|
698
|
-
return { success: true, data: { message:
|
|
955
|
+
return { success: true, data: { message: "Deleted" }, status: 200 };
|
|
699
956
|
}
|
|
700
957
|
}
|
|
701
958
|
```
|
|
@@ -703,29 +960,41 @@ class UserController implements IController<IUser> {
|
|
|
703
960
|
### Fastify Integration
|
|
704
961
|
|
|
705
962
|
```typescript
|
|
706
|
-
import { buildCrudSchemasFromModel } from
|
|
963
|
+
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
707
964
|
|
|
708
965
|
const controller = new UserController();
|
|
709
966
|
const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
|
|
710
967
|
|
|
711
968
|
// Routes with auto-validation and OpenAPI docs
|
|
712
|
-
fastify.get(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
969
|
+
fastify.get(
|
|
970
|
+
"/users",
|
|
971
|
+
{ schema: { querystring: crudSchemas.listQuery } },
|
|
972
|
+
async (req, reply) => {
|
|
973
|
+
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
974
|
+
const response = await controller.list(ctx);
|
|
975
|
+
return reply.status(response.status).send(response);
|
|
976
|
+
},
|
|
977
|
+
);
|
|
717
978
|
|
|
718
|
-
fastify.post(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
979
|
+
fastify.post(
|
|
980
|
+
"/users",
|
|
981
|
+
{ schema: { body: crudSchemas.createBody } },
|
|
982
|
+
async (req, reply) => {
|
|
983
|
+
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
984
|
+
const response = await controller.create(ctx);
|
|
985
|
+
return reply.status(response.status).send(response);
|
|
986
|
+
},
|
|
987
|
+
);
|
|
723
988
|
|
|
724
|
-
fastify.get(
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
989
|
+
fastify.get(
|
|
990
|
+
"/users/:id",
|
|
991
|
+
{ schema: { params: crudSchemas.params } },
|
|
992
|
+
async (req, reply) => {
|
|
993
|
+
const ctx = { query: {}, body: {}, params: req.params, user: req.user };
|
|
994
|
+
const response = await controller.get(ctx);
|
|
995
|
+
return reply.status(response.status).send(response);
|
|
996
|
+
},
|
|
997
|
+
);
|
|
729
998
|
```
|
|
730
999
|
|
|
731
1000
|
### Express Integration
|
|
@@ -733,13 +1002,13 @@ fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req
|
|
|
733
1002
|
```typescript
|
|
734
1003
|
const controller = new UserController();
|
|
735
1004
|
|
|
736
|
-
app.get(
|
|
1005
|
+
app.get("/users", async (req, res) => {
|
|
737
1006
|
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
738
1007
|
const response = await controller.list(ctx);
|
|
739
1008
|
res.status(response.status).json(response);
|
|
740
1009
|
});
|
|
741
1010
|
|
|
742
|
-
app.post(
|
|
1011
|
+
app.post("/users", async (req, res) => {
|
|
743
1012
|
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
744
1013
|
const response = await controller.create(ctx);
|
|
745
1014
|
res.status(response.status).json(response);
|
|
@@ -749,7 +1018,11 @@ app.post('/users', async (req, res) => {
|
|
|
749
1018
|
## TypeScript
|
|
750
1019
|
|
|
751
1020
|
```typescript
|
|
752
|
-
import {
|
|
1021
|
+
import {
|
|
1022
|
+
Repository,
|
|
1023
|
+
OffsetPaginationResult,
|
|
1024
|
+
KeysetPaginationResult,
|
|
1025
|
+
} from "@classytic/mongokit";
|
|
753
1026
|
|
|
754
1027
|
interface IUser extends Document {
|
|
755
1028
|
name: string;
|
|
@@ -761,10 +1034,10 @@ const repo = new Repository<IUser>(UserModel);
|
|
|
761
1034
|
const result = await repo.getAll({ page: 1, limit: 20 });
|
|
762
1035
|
|
|
763
1036
|
// Discriminated union - TypeScript knows the type
|
|
764
|
-
if (result.method ===
|
|
765
|
-
console.log(result.total, result.pages);
|
|
1037
|
+
if (result.method === "offset") {
|
|
1038
|
+
console.log(result.total, result.pages); // Available
|
|
766
1039
|
}
|
|
767
|
-
if (result.method ===
|
|
1040
|
+
if (result.method === "keyset") {
|
|
768
1041
|
console.log(result.next, result.hasMore); // Available
|
|
769
1042
|
}
|
|
770
1043
|
```
|
|
@@ -774,16 +1047,17 @@ if (result.method === 'keyset') {
|
|
|
774
1047
|
Create custom repository classes with domain-specific methods:
|
|
775
1048
|
|
|
776
1049
|
```typescript
|
|
777
|
-
import {
|
|
778
|
-
|
|
1050
|
+
import {
|
|
1051
|
+
Repository,
|
|
1052
|
+
softDeletePlugin,
|
|
1053
|
+
timestampPlugin,
|
|
1054
|
+
} from "@classytic/mongokit";
|
|
1055
|
+
import UserModel, { IUser } from "./models/User.js";
|
|
779
1056
|
|
|
780
1057
|
class UserRepository extends Repository<IUser> {
|
|
781
1058
|
constructor() {
|
|
782
|
-
super(UserModel, [
|
|
783
|
-
|
|
784
|
-
softDeletePlugin()
|
|
785
|
-
], {
|
|
786
|
-
defaultLimit: 20
|
|
1059
|
+
super(UserModel, [timestampPlugin(), softDeletePlugin()], {
|
|
1060
|
+
defaultLimit: 20,
|
|
787
1061
|
});
|
|
788
1062
|
}
|
|
789
1063
|
|
|
@@ -794,19 +1068,19 @@ class UserRepository extends Repository<IUser> {
|
|
|
794
1068
|
|
|
795
1069
|
async findActiveUsers() {
|
|
796
1070
|
return this.getAll({
|
|
797
|
-
filters: { status:
|
|
798
|
-
sort: { createdAt: -1 }
|
|
1071
|
+
filters: { status: "active" },
|
|
1072
|
+
sort: { createdAt: -1 },
|
|
799
1073
|
});
|
|
800
1074
|
}
|
|
801
1075
|
|
|
802
1076
|
async deactivate(id: string) {
|
|
803
|
-
return this.update(id, { status:
|
|
1077
|
+
return this.update(id, { status: "inactive", deactivatedAt: new Date() });
|
|
804
1078
|
}
|
|
805
1079
|
}
|
|
806
1080
|
|
|
807
1081
|
// Usage
|
|
808
1082
|
const userRepo = new UserRepository();
|
|
809
|
-
const user = await userRepo.findByEmail(
|
|
1083
|
+
const user = await userRepo.findByEmail("john@example.com");
|
|
810
1084
|
```
|
|
811
1085
|
|
|
812
1086
|
### Overriding Methods
|
|
@@ -819,12 +1093,15 @@ class AuditedUserRepository extends Repository<IUser> {
|
|
|
819
1093
|
|
|
820
1094
|
// Override create to add audit trail
|
|
821
1095
|
async create(data: Partial<IUser>, options = {}) {
|
|
822
|
-
const result = await super.create(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1096
|
+
const result = await super.create(
|
|
1097
|
+
{
|
|
1098
|
+
...data,
|
|
1099
|
+
createdBy: getCurrentUserId(),
|
|
1100
|
+
},
|
|
1101
|
+
options,
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
await auditLog("user.created", result._id);
|
|
828
1105
|
return result;
|
|
829
1106
|
}
|
|
830
1107
|
}
|
|
@@ -835,10 +1112,10 @@ class AuditedUserRepository extends Repository<IUser> {
|
|
|
835
1112
|
For simple cases without custom methods:
|
|
836
1113
|
|
|
837
1114
|
```javascript
|
|
838
|
-
import { createRepository, timestampPlugin } from
|
|
1115
|
+
import { createRepository, timestampPlugin } from "@classytic/mongokit";
|
|
839
1116
|
|
|
840
1117
|
const userRepo = createRepository(UserModel, [timestampPlugin()], {
|
|
841
|
-
defaultLimit: 20
|
|
1118
|
+
defaultLimit: 20,
|
|
842
1119
|
});
|
|
843
1120
|
```
|
|
844
1121
|
|
|
@@ -849,7 +1126,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
|
|
|
849
1126
|
- Uses its own event system (not Mongoose middleware)
|
|
850
1127
|
- Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
|
|
851
1128
|
- Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
|
|
852
|
-
- All
|
|
1129
|
+
- All 604 tests pass on Mongoose 9
|
|
853
1130
|
|
|
854
1131
|
## License
|
|
855
1132
|
|