@classytic/mongokit 3.2.1 → 3.2.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 +592 -194
- 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-BmK0SjR9.d.mts +1039 -0
- package/dist/custom-id.plugin-m0VW6yYm.mjs +2169 -0
- package/dist/index.d.mts +1049 -0
- package/dist/index.mjs +2052 -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-5G42WJHC.js +0 -737
- package/dist/chunks/chunk-B64F5ZWE.js +0 -1226
- package/dist/chunks/chunk-GZBKEPVE.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-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
|
+
- **17 built-in plugins** - Caching, soft delete, audit trail, 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
|
+
- **592 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,62 @@ 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
|
+
| `auditTrailPlugin(opts)` | DB-persisted audit trail with change tracking and TTL |
|
|
220
|
+
| `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
|
|
192
221
|
|
|
193
222
|
### Soft Delete
|
|
194
223
|
|
|
195
224
|
```javascript
|
|
196
225
|
const repo = new Repository(UserModel, [
|
|
197
|
-
softDeletePlugin({ deletedField:
|
|
226
|
+
softDeletePlugin({ deletedField: "deletedAt" }),
|
|
198
227
|
]);
|
|
199
228
|
|
|
200
|
-
await repo.delete(id);
|
|
201
|
-
await repo.getAll();
|
|
202
|
-
await repo.getAll({ includeDeleted: true });
|
|
229
|
+
await repo.delete(id); // Marks as deleted
|
|
230
|
+
await repo.getAll(); // Excludes deleted
|
|
231
|
+
await repo.getAll({ includeDeleted: true }); // Includes deleted
|
|
203
232
|
```
|
|
204
233
|
|
|
205
234
|
### Caching
|
|
206
235
|
|
|
207
236
|
```javascript
|
|
208
|
-
import { cachePlugin, createMemoryCache } from
|
|
237
|
+
import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
|
|
209
238
|
|
|
210
239
|
const repo = new Repository(UserModel, [
|
|
211
240
|
cachePlugin({
|
|
212
|
-
adapter: createMemoryCache(),
|
|
213
|
-
ttl: 60,
|
|
214
|
-
byIdTtl: 300,
|
|
215
|
-
queryTtl: 30,
|
|
216
|
-
})
|
|
241
|
+
adapter: createMemoryCache(), // or Redis adapter
|
|
242
|
+
ttl: 60, // Default TTL (seconds)
|
|
243
|
+
byIdTtl: 300, // TTL for getById
|
|
244
|
+
queryTtl: 30, // TTL for lists
|
|
245
|
+
}),
|
|
217
246
|
]);
|
|
218
247
|
|
|
219
248
|
// Reads are cached automatically
|
|
@@ -223,7 +252,7 @@ const user = await repo.getById(id);
|
|
|
223
252
|
const fresh = await repo.getById(id, { skipCache: true });
|
|
224
253
|
|
|
225
254
|
// Mutations auto-invalidate cache
|
|
226
|
-
await repo.update(id, { name:
|
|
255
|
+
await repo.update(id, { name: "New" });
|
|
227
256
|
|
|
228
257
|
// Manual invalidation
|
|
229
258
|
await repo.invalidateCache(id);
|
|
@@ -231,12 +260,21 @@ await repo.invalidateAllCache();
|
|
|
231
260
|
```
|
|
232
261
|
|
|
233
262
|
**Redis adapter example:**
|
|
263
|
+
|
|
234
264
|
```javascript
|
|
235
265
|
const redisAdapter = {
|
|
236
|
-
async get(key) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async
|
|
266
|
+
async get(key) {
|
|
267
|
+
return JSON.parse((await redis.get(key)) || "null");
|
|
268
|
+
},
|
|
269
|
+
async set(key, value, ttl) {
|
|
270
|
+
await redis.setex(key, ttl, JSON.stringify(value));
|
|
271
|
+
},
|
|
272
|
+
async del(key) {
|
|
273
|
+
await redis.del(key);
|
|
274
|
+
},
|
|
275
|
+
async clear(pattern) {
|
|
276
|
+
/* optional bulk delete */
|
|
277
|
+
},
|
|
240
278
|
};
|
|
241
279
|
```
|
|
242
280
|
|
|
@@ -249,37 +287,40 @@ import {
|
|
|
249
287
|
uniqueField,
|
|
250
288
|
immutableField,
|
|
251
289
|
blockIf,
|
|
252
|
-
autoInject
|
|
253
|
-
} from
|
|
290
|
+
autoInject,
|
|
291
|
+
} from "@classytic/mongokit";
|
|
254
292
|
|
|
255
293
|
const repo = new Repository(UserModel, [
|
|
256
294
|
validationChainPlugin([
|
|
257
|
-
requireField(
|
|
258
|
-
uniqueField(
|
|
259
|
-
immutableField(
|
|
260
|
-
blockIf(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
295
|
+
requireField("email", ["create"]),
|
|
296
|
+
uniqueField("email", "Email already exists"),
|
|
297
|
+
immutableField("userId"),
|
|
298
|
+
blockIf(
|
|
299
|
+
"noAdminDelete",
|
|
300
|
+
["delete"],
|
|
301
|
+
(ctx) => ctx.data?.role === "admin",
|
|
302
|
+
"Cannot delete admin users",
|
|
303
|
+
),
|
|
304
|
+
autoInject("slug", (ctx) => slugify(ctx.data?.name), ["create"]),
|
|
305
|
+
]),
|
|
265
306
|
]);
|
|
266
307
|
```
|
|
267
308
|
|
|
268
309
|
### Cascade Delete
|
|
269
310
|
|
|
270
311
|
```javascript
|
|
271
|
-
import { cascadePlugin, softDeletePlugin } from
|
|
312
|
+
import { cascadePlugin, softDeletePlugin } from "@classytic/mongokit";
|
|
272
313
|
|
|
273
314
|
const repo = new Repository(ProductModel, [
|
|
274
315
|
softDeletePlugin(),
|
|
275
316
|
cascadePlugin({
|
|
276
317
|
relations: [
|
|
277
|
-
{ model:
|
|
278
|
-
{ model:
|
|
318
|
+
{ model: "StockEntry", foreignKey: "product" },
|
|
319
|
+
{ model: "Review", foreignKey: "product", softDelete: false },
|
|
279
320
|
],
|
|
280
321
|
parallel: true,
|
|
281
|
-
logger: console
|
|
282
|
-
})
|
|
322
|
+
logger: console,
|
|
323
|
+
}),
|
|
283
324
|
]);
|
|
284
325
|
|
|
285
326
|
// Deleting product also deletes related StockEntry and Review docs
|
|
@@ -289,40 +330,160 @@ await repo.delete(productId);
|
|
|
289
330
|
### Field Filtering (RBAC)
|
|
290
331
|
|
|
291
332
|
```javascript
|
|
292
|
-
import { fieldFilterPlugin } from
|
|
333
|
+
import { fieldFilterPlugin } from "@classytic/mongokit";
|
|
293
334
|
|
|
294
335
|
const repo = new Repository(UserModel, [
|
|
295
336
|
fieldFilterPlugin({
|
|
296
|
-
public: [
|
|
297
|
-
authenticated: [
|
|
298
|
-
admin: [
|
|
299
|
-
})
|
|
337
|
+
public: ["id", "name", "avatar"],
|
|
338
|
+
authenticated: ["email", "phone"],
|
|
339
|
+
admin: ["createdAt", "internalNotes"],
|
|
340
|
+
}),
|
|
300
341
|
]);
|
|
301
342
|
```
|
|
302
343
|
|
|
303
344
|
### Multi-Tenant
|
|
304
345
|
|
|
305
346
|
```javascript
|
|
306
|
-
import { multiTenantPlugin } from
|
|
347
|
+
import { multiTenantPlugin } from "@classytic/mongokit";
|
|
307
348
|
|
|
308
349
|
const repo = new Repository(UserModel, [
|
|
309
350
|
multiTenantPlugin({
|
|
310
|
-
tenantField:
|
|
311
|
-
contextKey:
|
|
351
|
+
tenantField: "organizationId",
|
|
352
|
+
contextKey: "organizationId", // reads from context
|
|
312
353
|
required: true,
|
|
313
|
-
})
|
|
354
|
+
}),
|
|
314
355
|
]);
|
|
315
356
|
|
|
316
357
|
// All operations are automatically scoped to the tenant
|
|
317
|
-
const users = await repo.getAll({ organizationId:
|
|
318
|
-
await repo.update(userId, { name:
|
|
358
|
+
const users = await repo.getAll({ organizationId: "org_123" });
|
|
359
|
+
await repo.update(userId, { name: "New" }, { organizationId: "org_123" });
|
|
319
360
|
// Cross-tenant update/delete is blocked — returns "not found"
|
|
320
361
|
```
|
|
321
362
|
|
|
363
|
+
### Audit Trail (DB-Persisted)
|
|
364
|
+
|
|
365
|
+
The `auditTrailPlugin` persists operation audit entries to a shared MongoDB collection. Unlike `auditLogPlugin` (which logs to an external logger), this stores a queryable audit trail in the database with automatic TTL cleanup.
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import {
|
|
369
|
+
Repository,
|
|
370
|
+
methodRegistryPlugin,
|
|
371
|
+
auditTrailPlugin,
|
|
372
|
+
} from "@classytic/mongokit";
|
|
373
|
+
|
|
374
|
+
const repo = new Repository(JobModel, [
|
|
375
|
+
methodRegistryPlugin(),
|
|
376
|
+
auditTrailPlugin({
|
|
377
|
+
operations: ["create", "update", "delete"], // Which ops to track
|
|
378
|
+
trackChanges: true, // Field-level before/after diff on updates
|
|
379
|
+
trackDocument: false, // Full doc snapshot on create (heavy)
|
|
380
|
+
ttlDays: 90, // Auto-purge after 90 days (MongoDB TTL index)
|
|
381
|
+
excludeFields: ["password", "token"], // Redact sensitive fields
|
|
382
|
+
metadata: (context) => ({
|
|
383
|
+
// Custom metadata per entry
|
|
384
|
+
ip: context.req?.ip,
|
|
385
|
+
userAgent: context.req?.headers?.["user-agent"],
|
|
386
|
+
}),
|
|
387
|
+
}),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
// Query audit trail for a specific document (requires methodRegistryPlugin)
|
|
391
|
+
const trail = await repo.getAuditTrail(documentId, {
|
|
392
|
+
page: 1,
|
|
393
|
+
limit: 20,
|
|
394
|
+
operation: "update", // Optional filter
|
|
395
|
+
});
|
|
396
|
+
// → { docs, page, limit, total, pages, hasNext, hasPrev }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**What gets stored:**
|
|
400
|
+
|
|
401
|
+
```javascript
|
|
402
|
+
{
|
|
403
|
+
model: 'Job',
|
|
404
|
+
operation: 'update',
|
|
405
|
+
documentId: ObjectId('...'),
|
|
406
|
+
userId: ObjectId('...'),
|
|
407
|
+
orgId: ObjectId('...'),
|
|
408
|
+
changes: {
|
|
409
|
+
title: { from: 'Old Title', to: 'New Title' },
|
|
410
|
+
salary: { from: 50000, to: 65000 },
|
|
411
|
+
},
|
|
412
|
+
metadata: { ip: '192.168.1.1' },
|
|
413
|
+
timestamp: ISODate('2026-02-26T...'),
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Standalone queries** (admin dashboards, audit APIs — no repo needed):
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
import { AuditTrailQuery } from "@classytic/mongokit";
|
|
421
|
+
|
|
422
|
+
const auditQuery = new AuditTrailQuery(); // 'audit_trails' collection
|
|
423
|
+
|
|
424
|
+
// All audits for an org
|
|
425
|
+
const orgAudits = await auditQuery.getOrgTrail(orgId);
|
|
426
|
+
|
|
427
|
+
// All actions by a user
|
|
428
|
+
const userAudits = await auditQuery.getUserTrail(userId);
|
|
429
|
+
|
|
430
|
+
// History of a specific document
|
|
431
|
+
const docHistory = await auditQuery.getDocumentTrail("Job", jobId);
|
|
432
|
+
|
|
433
|
+
// Custom query with date range
|
|
434
|
+
const recent = await auditQuery.query({
|
|
435
|
+
orgId,
|
|
436
|
+
operation: "delete",
|
|
437
|
+
from: new Date("2025-01-01"),
|
|
438
|
+
to: new Date(),
|
|
439
|
+
page: 1,
|
|
440
|
+
limit: 50,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Direct model access for anything custom
|
|
444
|
+
const model = auditQuery.getModel();
|
|
445
|
+
const deleteCount = await model.countDocuments({ operation: "delete" });
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Key design decisions:**
|
|
449
|
+
|
|
450
|
+
- **Fire & forget** — audit writes are async and never block or fail the main operation
|
|
451
|
+
- **Shared collection** — one `audit_trails` collection for all models (filtered by `model` field)
|
|
452
|
+
- **TTL index** — MongoDB auto-deletes old entries, no cron needed
|
|
453
|
+
- **Change diff** — compares before/after on updates, stores only changed fields
|
|
454
|
+
|
|
455
|
+
**Plugin options:**
|
|
456
|
+
|
|
457
|
+
| Option | Default | Description |
|
|
458
|
+
| --------------- | -------------------------------- | -------------------------------------- |
|
|
459
|
+
| `operations` | `['create', 'update', 'delete']` | Which operations to audit |
|
|
460
|
+
| `trackChanges` | `true` | Store before/after diff on updates |
|
|
461
|
+
| `trackDocument` | `false` | Store full document snapshot on create |
|
|
462
|
+
| `ttlDays` | `undefined` (keep forever) | Auto-purge after N days |
|
|
463
|
+
| `collectionName`| `'audit_trails'` | MongoDB collection name |
|
|
464
|
+
| `excludeFields` | `[]` | Fields to redact from diffs/snapshots |
|
|
465
|
+
| `metadata` | `undefined` | Callback to inject custom metadata |
|
|
466
|
+
|
|
467
|
+
**TypeScript type safety:**
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
import type { AuditTrailMethods } from "@classytic/mongokit";
|
|
471
|
+
|
|
472
|
+
type JobRepoWithAudit = JobRepo & AuditTrailMethods;
|
|
473
|
+
|
|
474
|
+
const repo = new JobRepo(JobModel, [
|
|
475
|
+
methodRegistryPlugin(),
|
|
476
|
+
auditTrailPlugin({ ttlDays: 90 }),
|
|
477
|
+
]) as JobRepoWithAudit;
|
|
478
|
+
|
|
479
|
+
// Full autocomplete for getAuditTrail
|
|
480
|
+
const trail = await repo.getAuditTrail(jobId, { operation: "update" });
|
|
481
|
+
```
|
|
482
|
+
|
|
322
483
|
### Observability
|
|
323
484
|
|
|
324
485
|
```javascript
|
|
325
|
-
import { observabilityPlugin } from
|
|
486
|
+
import { observabilityPlugin } from "@classytic/mongokit";
|
|
326
487
|
|
|
327
488
|
const repo = new Repository(UserModel, [
|
|
328
489
|
observabilityPlugin({
|
|
@@ -330,11 +491,154 @@ const repo = new Repository(UserModel, [
|
|
|
330
491
|
// Send to DataDog, New Relic, OpenTelemetry, etc.
|
|
331
492
|
statsd.histogram(`mongokit.${metric.operation}`, metric.duration);
|
|
332
493
|
},
|
|
333
|
-
slowThresholdMs: 200,
|
|
334
|
-
})
|
|
494
|
+
slowThresholdMs: 200, // log operations slower than 200ms
|
|
495
|
+
}),
|
|
496
|
+
]);
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Custom ID Generation
|
|
500
|
+
|
|
501
|
+
Generate human-readable sequential IDs (e.g., `INV-0001`, `BILL-2026-02-0001`) using atomic MongoDB counters — safe under concurrency with zero duplicates.
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
import {
|
|
505
|
+
Repository,
|
|
506
|
+
customIdPlugin,
|
|
507
|
+
sequentialId,
|
|
508
|
+
dateSequentialId,
|
|
509
|
+
prefixedId,
|
|
510
|
+
} from "@classytic/mongokit";
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### Sequential Counter
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
const invoiceRepo = new Repository(InvoiceModel, [
|
|
517
|
+
customIdPlugin({
|
|
518
|
+
field: "invoiceNumber",
|
|
519
|
+
generator: sequentialId({
|
|
520
|
+
prefix: "INV",
|
|
521
|
+
model: InvoiceModel,
|
|
522
|
+
}),
|
|
523
|
+
}),
|
|
524
|
+
]);
|
|
525
|
+
|
|
526
|
+
const inv1 = await invoiceRepo.create({ amount: 100 });
|
|
527
|
+
// inv1.invoiceNumber → "INV-0001"
|
|
528
|
+
|
|
529
|
+
const inv2 = await invoiceRepo.create({ amount: 200 });
|
|
530
|
+
// inv2.invoiceNumber → "INV-0002"
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
**Options:**
|
|
534
|
+
|
|
535
|
+
| Option | Default | Description |
|
|
536
|
+
| ------------ | ------------ | ---------------------------------------------------- |
|
|
537
|
+
| `prefix` | _(required)_ | Prefix string (e.g., `'INV'`, `'ORD'`) |
|
|
538
|
+
| `model` | _(required)_ | Mongoose model (counter key derived from model name) |
|
|
539
|
+
| `padding` | `4` | Number of digits (`4` → `0001`) |
|
|
540
|
+
| `separator` | `'-'` | Separator between prefix and number |
|
|
541
|
+
| `counterKey` | model name | Custom counter key to avoid collisions |
|
|
542
|
+
|
|
543
|
+
#### Date-Partitioned Counter
|
|
544
|
+
|
|
545
|
+
Counter resets per period — ideal for invoice/bill numbering:
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
const billRepo = new Repository(BillModel, [
|
|
549
|
+
customIdPlugin({
|
|
550
|
+
field: "billNumber",
|
|
551
|
+
generator: dateSequentialId({
|
|
552
|
+
prefix: "BILL",
|
|
553
|
+
model: BillModel,
|
|
554
|
+
partition: "monthly", // resets each month
|
|
555
|
+
}),
|
|
556
|
+
}),
|
|
557
|
+
]);
|
|
558
|
+
|
|
559
|
+
const bill = await billRepo.create({ total: 250 });
|
|
560
|
+
// bill.billNumber → "BILL-2026-02-0001"
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Partition modes:**
|
|
564
|
+
|
|
565
|
+
- `'yearly'` → `BILL-2026-0001` (resets every January)
|
|
566
|
+
- `'monthly'` → `BILL-2026-02-0001` (resets every month)
|
|
567
|
+
- `'daily'` → `BILL-2026-02-20-0001` (resets every day)
|
|
568
|
+
|
|
569
|
+
#### Prefixed Random ID
|
|
570
|
+
|
|
571
|
+
No database round-trip — purely in-memory random suffix:
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
const orderRepo = new Repository(OrderModel, [
|
|
575
|
+
customIdPlugin({
|
|
576
|
+
field: "orderRef",
|
|
577
|
+
generator: prefixedId({ prefix: "ORD", length: 10 }),
|
|
578
|
+
}),
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
const order = await orderRepo.create({ total: 99 });
|
|
582
|
+
// order.orderRef → "ORD_a7b3xk9m2p"
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
#### Custom Generator
|
|
586
|
+
|
|
587
|
+
Write your own generator function for full control:
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
const repo = new Repository(OrderModel, [
|
|
591
|
+
customIdPlugin({
|
|
592
|
+
field: "orderRef",
|
|
593
|
+
generator: async (context) => {
|
|
594
|
+
const region = context.data?.region || "US";
|
|
595
|
+
const seq = await getNextSequence("orders");
|
|
596
|
+
return `ORD-${region}-${String(seq).padStart(4, "0")}`;
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
335
599
|
]);
|
|
600
|
+
// → "ORD-US-0001", "ORD-EU-0002", ...
|
|
336
601
|
```
|
|
337
602
|
|
|
603
|
+
#### Plugin Options
|
|
604
|
+
|
|
605
|
+
| Option | Default | Description |
|
|
606
|
+
| --------------------- | ------------ | -------------------------------------------- |
|
|
607
|
+
| `field` | `'customId'` | Document field to store the generated ID |
|
|
608
|
+
| `generator` | _(required)_ | Function returning the ID (sync or async) |
|
|
609
|
+
| `generateOnlyIfEmpty` | `true` | Skip generation if field already has a value |
|
|
610
|
+
|
|
611
|
+
#### Batch Creation
|
|
612
|
+
|
|
613
|
+
Works with `createMany` — each document gets its own sequential ID:
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
const docs = await invoiceRepo.createMany([
|
|
617
|
+
{ amount: 10 },
|
|
618
|
+
{ amount: 20, invoiceNumber: "MANUAL-001" }, // skipped (already has ID)
|
|
619
|
+
{ amount: 30 },
|
|
620
|
+
]);
|
|
621
|
+
// docs[0].invoiceNumber → "INV-0001"
|
|
622
|
+
// docs[1].invoiceNumber → "MANUAL-001" (preserved)
|
|
623
|
+
// docs[2].invoiceNumber → "INV-0002"
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
#### Atomic Counter API
|
|
627
|
+
|
|
628
|
+
The `getNextSequence` helper is exported for use in custom generators:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
import { getNextSequence } from "@classytic/mongokit";
|
|
632
|
+
|
|
633
|
+
const seq = await getNextSequence("my-counter"); // → 1, 2, 3, ...
|
|
634
|
+
const batch = await getNextSequence("my-counter", 5); // → jumps by 5
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Counters are stored in the `_mongokit_counters` collection using MongoDB's atomic `findOneAndUpdate` + `$inc` — guaranteed unique under any level of concurrency.
|
|
638
|
+
|
|
639
|
+
> **Note:** Counters are monotonically increasing and never decrement on document deletion.
|
|
640
|
+
> This is standard behavior for business documents (invoices, bills, receipts) — you never reuse a number.
|
|
641
|
+
|
|
338
642
|
### Vector Search (Atlas)
|
|
339
643
|
|
|
340
644
|
```javascript
|
|
@@ -367,10 +671,65 @@ const results = await repo.searchSimilar({ query: [0.1, 0.2, ...], limit: 5 });
|
|
|
367
671
|
const vector = await repo.embed('some text');
|
|
368
672
|
```
|
|
369
673
|
|
|
674
|
+
### Elasticsearch / OpenSearch Plugin
|
|
675
|
+
|
|
676
|
+
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.
|
|
677
|
+
|
|
678
|
+
**Architecture:** Query ES/OpenSearch → get IDs + relevance scores → fetch full docs from MongoDB → return in ES ranking order.
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
681
|
+
import {
|
|
682
|
+
Repository,
|
|
683
|
+
methodRegistryPlugin,
|
|
684
|
+
elasticSearchPlugin,
|
|
685
|
+
} from "@classytic/mongokit";
|
|
686
|
+
import { Client } from "@elastic/elasticsearch"; // or '@opensearch-project/opensearch'
|
|
687
|
+
|
|
688
|
+
const esClient = new Client({ node: "http://localhost:9200" });
|
|
689
|
+
|
|
690
|
+
const productRepo = new Repository(ProductModel, [
|
|
691
|
+
methodRegistryPlugin(), // Required first
|
|
692
|
+
elasticSearchPlugin({
|
|
693
|
+
client: esClient,
|
|
694
|
+
index: "products",
|
|
695
|
+
idField: "_id", // field in ES doc that maps to MongoDB _id
|
|
696
|
+
}),
|
|
697
|
+
]);
|
|
698
|
+
|
|
699
|
+
// Perform semantic/full-text search
|
|
700
|
+
const results = await productRepo.search(
|
|
701
|
+
{ match: { description: "wireless headphones" } },
|
|
702
|
+
{
|
|
703
|
+
limit: 20, // capped to 1000 max (safety bound)
|
|
704
|
+
from: 0,
|
|
705
|
+
mongoOptions: {
|
|
706
|
+
select: "name price description",
|
|
707
|
+
lean: true,
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// results.docs - MongoDB documents in ES ranking order
|
|
713
|
+
// results.docs[*]._score - ES relevance score (preserved, including 0)
|
|
714
|
+
// results.total - total hits count from ES
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
**Why this exists:**
|
|
718
|
+
|
|
719
|
+
- `$text` in MongoDB requires a text index and is not scalable for fuzzy/semantic search
|
|
720
|
+
- ES/OpenSearch provides BM25, vector search, semantic search, analyzers, facets
|
|
721
|
+
- This plugin bridges both: ES rank + MongoDB's transactional documents
|
|
722
|
+
|
|
723
|
+
**Bounds enforcement:**
|
|
724
|
+
|
|
725
|
+
- `limit` is clamped to `[1, 1000]` — prevents runaway ES queries
|
|
726
|
+
- `from` is clamped to `>= 0` — prevents negative offsets
|
|
727
|
+
- Returns `{ docs: [], total: 0 }` immediately if ES returns no hits
|
|
728
|
+
|
|
370
729
|
### Logging
|
|
371
730
|
|
|
372
731
|
```javascript
|
|
373
|
-
import { configureLogger } from
|
|
732
|
+
import { configureLogger } from "@classytic/mongokit";
|
|
374
733
|
|
|
375
734
|
// Silence all internal warnings
|
|
376
735
|
configureLogger(false);
|
|
@@ -389,16 +748,20 @@ The `mongoOperationsPlugin` adds MongoDB-specific atomic operations like `increm
|
|
|
389
748
|
#### Basic Usage (No TypeScript Autocomplete)
|
|
390
749
|
|
|
391
750
|
```javascript
|
|
392
|
-
import {
|
|
751
|
+
import {
|
|
752
|
+
Repository,
|
|
753
|
+
methodRegistryPlugin,
|
|
754
|
+
mongoOperationsPlugin,
|
|
755
|
+
} from "@classytic/mongokit";
|
|
393
756
|
|
|
394
757
|
const repo = new Repository(ProductModel, [
|
|
395
|
-
methodRegistryPlugin(),
|
|
396
|
-
mongoOperationsPlugin()
|
|
758
|
+
methodRegistryPlugin(), // Required first
|
|
759
|
+
mongoOperationsPlugin(),
|
|
397
760
|
]);
|
|
398
761
|
|
|
399
762
|
// Works at runtime but TypeScript doesn't provide autocomplete
|
|
400
|
-
await repo.increment(productId,
|
|
401
|
-
await repo.upsert({ sku:
|
|
763
|
+
await repo.increment(productId, "views", 1);
|
|
764
|
+
await repo.upsert({ sku: "ABC" }, { name: "Product", price: 99 });
|
|
402
765
|
```
|
|
403
766
|
|
|
404
767
|
#### With TypeScript Type Safety (Recommended)
|
|
@@ -406,8 +769,12 @@ await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
|
406
769
|
For full TypeScript autocomplete and type checking, use the `MongoOperationsMethods` type:
|
|
407
770
|
|
|
408
771
|
```typescript
|
|
409
|
-
import {
|
|
410
|
-
|
|
772
|
+
import {
|
|
773
|
+
Repository,
|
|
774
|
+
methodRegistryPlugin,
|
|
775
|
+
mongoOperationsPlugin,
|
|
776
|
+
} from "@classytic/mongokit";
|
|
777
|
+
import type { MongoOperationsMethods } from "@classytic/mongokit";
|
|
411
778
|
|
|
412
779
|
// 1. Create your repository class
|
|
413
780
|
class ProductRepo extends Repository<IProduct> {
|
|
@@ -423,17 +790,18 @@ type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
|
423
790
|
// 3. Instantiate with type assertion
|
|
424
791
|
const repo = new ProductRepo(ProductModel, [
|
|
425
792
|
methodRegistryPlugin(),
|
|
426
|
-
mongoOperationsPlugin()
|
|
793
|
+
mongoOperationsPlugin(),
|
|
427
794
|
]) as ProductRepoWithPlugins;
|
|
428
795
|
|
|
429
796
|
// 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(
|
|
797
|
+
await repo.increment(productId, "views", 1); // ✅ Autocomplete works
|
|
798
|
+
await repo.upsert({ sku: "ABC" }, { name: "Product" }); // ✅ Type-safe
|
|
799
|
+
await repo.pushToArray(productId, "tags", "featured"); // ✅ Validated
|
|
800
|
+
await repo.findBySku("ABC"); // ✅ Custom methods too
|
|
434
801
|
```
|
|
435
802
|
|
|
436
803
|
**Available operations:**
|
|
804
|
+
|
|
437
805
|
- `upsert(query, data, opts)` - Create or find document
|
|
438
806
|
- `increment(id, field, value, opts)` - Atomically increment field
|
|
439
807
|
- `decrement(id, field, value, opts)` - Atomically decrement field
|
|
@@ -452,7 +820,7 @@ await repo.findBySku('ABC'); // ✅ Custom methods too
|
|
|
452
820
|
Plugin methods are added at runtime. Use `WithPlugins<TDoc, TRepo>` for TypeScript autocomplete:
|
|
453
821
|
|
|
454
822
|
```typescript
|
|
455
|
-
import type { WithPlugins } from
|
|
823
|
+
import type { WithPlugins } from "@classytic/mongokit";
|
|
456
824
|
|
|
457
825
|
class UserRepo extends Repository<IUser> {}
|
|
458
826
|
|
|
@@ -463,26 +831,26 @@ const repo = new UserRepo(Model, [
|
|
|
463
831
|
]) as WithPlugins<IUser, UserRepo>;
|
|
464
832
|
|
|
465
833
|
// Full TypeScript autocomplete!
|
|
466
|
-
await repo.increment(id,
|
|
834
|
+
await repo.increment(id, "views", 1);
|
|
467
835
|
await repo.restore(id);
|
|
468
836
|
await repo.invalidateCache(id);
|
|
469
837
|
```
|
|
470
838
|
|
|
471
|
-
**Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`
|
|
839
|
+
**Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`, `AuditTrailMethods`
|
|
472
840
|
|
|
473
841
|
## Event System
|
|
474
842
|
|
|
475
843
|
```javascript
|
|
476
|
-
repo.on(
|
|
844
|
+
repo.on("before:create", async (context) => {
|
|
477
845
|
context.data.processedAt = new Date();
|
|
478
846
|
});
|
|
479
847
|
|
|
480
|
-
repo.on(
|
|
481
|
-
console.log(
|
|
848
|
+
repo.on("after:create", ({ context, result }) => {
|
|
849
|
+
console.log("Created:", result);
|
|
482
850
|
});
|
|
483
851
|
|
|
484
|
-
repo.on(
|
|
485
|
-
console.error(
|
|
852
|
+
repo.on("error:create", ({ context, error }) => {
|
|
853
|
+
console.error("Failed:", error);
|
|
486
854
|
});
|
|
487
855
|
```
|
|
488
856
|
|
|
@@ -497,15 +865,19 @@ MongoKit provides a complete toolkit for building REST APIs: QueryParser for req
|
|
|
497
865
|
Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
|
|
498
866
|
|
|
499
867
|
```typescript
|
|
500
|
-
import type {
|
|
868
|
+
import type {
|
|
869
|
+
IController,
|
|
870
|
+
IRequestContext,
|
|
871
|
+
IControllerResponse,
|
|
872
|
+
} from "@classytic/mongokit";
|
|
501
873
|
|
|
502
874
|
// IRequestContext - what your controller receives
|
|
503
875
|
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>;
|
|
876
|
+
query: Record<string, unknown>; // URL query params
|
|
877
|
+
body: Record<string, unknown>; // Request body
|
|
878
|
+
params: Record<string, string>; // Route params (:id)
|
|
879
|
+
user?: { id: string; role?: string }; // Auth user
|
|
880
|
+
context?: Record<string, unknown>; // Tenant ID, etc.
|
|
509
881
|
}
|
|
510
882
|
|
|
511
883
|
// IControllerResponse - what your controller returns
|
|
@@ -518,11 +890,15 @@ interface IControllerResponse<T> {
|
|
|
518
890
|
|
|
519
891
|
// IController - implement this interface
|
|
520
892
|
interface IController<TDoc> {
|
|
521
|
-
list(
|
|
893
|
+
list(
|
|
894
|
+
ctx: IRequestContext,
|
|
895
|
+
): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
522
896
|
get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
523
897
|
create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
524
898
|
update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
525
|
-
delete(
|
|
899
|
+
delete(
|
|
900
|
+
ctx: IRequestContext,
|
|
901
|
+
): Promise<IControllerResponse<{ message: string }>>;
|
|
526
902
|
}
|
|
527
903
|
```
|
|
528
904
|
|
|
@@ -531,12 +907,12 @@ interface IController<TDoc> {
|
|
|
531
907
|
Converts HTTP query strings to MongoDB queries with built-in security:
|
|
532
908
|
|
|
533
909
|
```typescript
|
|
534
|
-
import { QueryParser } from
|
|
910
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
535
911
|
|
|
536
912
|
const parser = new QueryParser({
|
|
537
|
-
maxLimit: 100,
|
|
538
|
-
maxFilterDepth: 5,
|
|
539
|
-
maxRegexLength: 100,
|
|
913
|
+
maxLimit: 100, // Prevent excessive queries
|
|
914
|
+
maxFilterDepth: 5, // Prevent nested injection
|
|
915
|
+
maxRegexLength: 100, // ReDoS protection
|
|
540
916
|
});
|
|
541
917
|
|
|
542
918
|
// Parse request query
|
|
@@ -544,6 +920,7 @@ const { filters, limit, page, sort, search } = parser.parse(req.query);
|
|
|
544
920
|
```
|
|
545
921
|
|
|
546
922
|
**Supported query patterns:**
|
|
923
|
+
|
|
547
924
|
```bash
|
|
548
925
|
# Filtering
|
|
549
926
|
GET /users?status=active&role=admin
|
|
@@ -574,6 +951,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
|
|
|
574
951
|
```
|
|
575
952
|
|
|
576
953
|
**Security features:**
|
|
954
|
+
|
|
577
955
|
- Blocks `$where`, `$function`, `$accumulator`, `$expr` operators
|
|
578
956
|
- ReDoS protection for regex patterns
|
|
579
957
|
- Max filter depth enforcement
|
|
@@ -586,7 +964,7 @@ GET /posts?populate[author][populate][department][select]=name # Nested
|
|
|
586
964
|
QueryParser supports Mongoose populate options via URL query parameters:
|
|
587
965
|
|
|
588
966
|
```typescript
|
|
589
|
-
import { QueryParser } from
|
|
967
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
590
968
|
|
|
591
969
|
const parser = new QueryParser();
|
|
592
970
|
|
|
@@ -596,19 +974,19 @@ const parsed = parser.parse(req.query);
|
|
|
596
974
|
// Use with Repository
|
|
597
975
|
const posts = await postRepo.getAll(
|
|
598
976
|
{ filters: parsed.filters, page: parsed.page, limit: parsed.limit },
|
|
599
|
-
{ populateOptions: parsed.populateOptions }
|
|
977
|
+
{ populateOptions: parsed.populateOptions },
|
|
600
978
|
);
|
|
601
979
|
```
|
|
602
980
|
|
|
603
981
|
**Supported populate options:**
|
|
604
982
|
|
|
605
|
-
| Option
|
|
606
|
-
|
|
607
|
-
| `select`
|
|
608
|
-
| `match`
|
|
609
|
-
| `limit`
|
|
610
|
-
| `sort`
|
|
611
|
-
| `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5)
|
|
983
|
+
| Option | URL Syntax | Description |
|
|
984
|
+
| ---------- | ------------------------------------------------ | ----------------------------------------------- |
|
|
985
|
+
| `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
|
|
986
|
+
| `match` | `populate[path][match][field]=value` | Filter populated documents |
|
|
987
|
+
| `limit` | `populate[path][limit]=10` | Limit number of populated docs |
|
|
988
|
+
| `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
|
|
989
|
+
| `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
|
|
612
990
|
|
|
613
991
|
**Example - Complex populate:**
|
|
614
992
|
|
|
@@ -632,15 +1010,15 @@ const parsed = parser.parse(req.query);
|
|
|
632
1010
|
Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
|
|
633
1011
|
|
|
634
1012
|
```typescript
|
|
635
|
-
import { buildCrudSchemasFromModel } from
|
|
1013
|
+
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
636
1014
|
|
|
637
1015
|
const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
|
|
638
1016
|
fieldRules: {
|
|
639
|
-
organizationId: { immutable: true },
|
|
640
|
-
role: { systemManaged: true },
|
|
1017
|
+
organizationId: { immutable: true }, // Can't update after create
|
|
1018
|
+
role: { systemManaged: true }, // Users can't set this
|
|
641
1019
|
createdAt: { systemManaged: true },
|
|
642
1020
|
},
|
|
643
|
-
strictAdditionalProperties: true,
|
|
1021
|
+
strictAdditionalProperties: true, // Reject unknown fields
|
|
644
1022
|
});
|
|
645
1023
|
|
|
646
1024
|
// Generated schemas:
|
|
@@ -660,7 +1038,7 @@ import {
|
|
|
660
1038
|
type IController,
|
|
661
1039
|
type IRequestContext,
|
|
662
1040
|
type IControllerResponse,
|
|
663
|
-
} from
|
|
1041
|
+
} from "@classytic/mongokit";
|
|
664
1042
|
|
|
665
1043
|
class UserController implements IController<IUser> {
|
|
666
1044
|
private repo = new Repository(UserModel);
|
|
@@ -695,7 +1073,7 @@ class UserController implements IController<IUser> {
|
|
|
695
1073
|
|
|
696
1074
|
async delete(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
697
1075
|
await this.repo.delete(ctx.params.id);
|
|
698
|
-
return { success: true, data: { message:
|
|
1076
|
+
return { success: true, data: { message: "Deleted" }, status: 200 };
|
|
699
1077
|
}
|
|
700
1078
|
}
|
|
701
1079
|
```
|
|
@@ -703,29 +1081,41 @@ class UserController implements IController<IUser> {
|
|
|
703
1081
|
### Fastify Integration
|
|
704
1082
|
|
|
705
1083
|
```typescript
|
|
706
|
-
import { buildCrudSchemasFromModel } from
|
|
1084
|
+
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
707
1085
|
|
|
708
1086
|
const controller = new UserController();
|
|
709
1087
|
const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
|
|
710
1088
|
|
|
711
1089
|
// Routes with auto-validation and OpenAPI docs
|
|
712
|
-
fastify.get(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
1090
|
+
fastify.get(
|
|
1091
|
+
"/users",
|
|
1092
|
+
{ schema: { querystring: crudSchemas.listQuery } },
|
|
1093
|
+
async (req, reply) => {
|
|
1094
|
+
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
1095
|
+
const response = await controller.list(ctx);
|
|
1096
|
+
return reply.status(response.status).send(response);
|
|
1097
|
+
},
|
|
1098
|
+
);
|
|
717
1099
|
|
|
718
|
-
fastify.post(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
1100
|
+
fastify.post(
|
|
1101
|
+
"/users",
|
|
1102
|
+
{ schema: { body: crudSchemas.createBody } },
|
|
1103
|
+
async (req, reply) => {
|
|
1104
|
+
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
1105
|
+
const response = await controller.create(ctx);
|
|
1106
|
+
return reply.status(response.status).send(response);
|
|
1107
|
+
},
|
|
1108
|
+
);
|
|
723
1109
|
|
|
724
|
-
fastify.get(
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
1110
|
+
fastify.get(
|
|
1111
|
+
"/users/:id",
|
|
1112
|
+
{ schema: { params: crudSchemas.params } },
|
|
1113
|
+
async (req, reply) => {
|
|
1114
|
+
const ctx = { query: {}, body: {}, params: req.params, user: req.user };
|
|
1115
|
+
const response = await controller.get(ctx);
|
|
1116
|
+
return reply.status(response.status).send(response);
|
|
1117
|
+
},
|
|
1118
|
+
);
|
|
729
1119
|
```
|
|
730
1120
|
|
|
731
1121
|
### Express Integration
|
|
@@ -733,13 +1123,13 @@ fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req
|
|
|
733
1123
|
```typescript
|
|
734
1124
|
const controller = new UserController();
|
|
735
1125
|
|
|
736
|
-
app.get(
|
|
1126
|
+
app.get("/users", async (req, res) => {
|
|
737
1127
|
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
738
1128
|
const response = await controller.list(ctx);
|
|
739
1129
|
res.status(response.status).json(response);
|
|
740
1130
|
});
|
|
741
1131
|
|
|
742
|
-
app.post(
|
|
1132
|
+
app.post("/users", async (req, res) => {
|
|
743
1133
|
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
744
1134
|
const response = await controller.create(ctx);
|
|
745
1135
|
res.status(response.status).json(response);
|
|
@@ -749,7 +1139,11 @@ app.post('/users', async (req, res) => {
|
|
|
749
1139
|
## TypeScript
|
|
750
1140
|
|
|
751
1141
|
```typescript
|
|
752
|
-
import {
|
|
1142
|
+
import {
|
|
1143
|
+
Repository,
|
|
1144
|
+
OffsetPaginationResult,
|
|
1145
|
+
KeysetPaginationResult,
|
|
1146
|
+
} from "@classytic/mongokit";
|
|
753
1147
|
|
|
754
1148
|
interface IUser extends Document {
|
|
755
1149
|
name: string;
|
|
@@ -761,10 +1155,10 @@ const repo = new Repository<IUser>(UserModel);
|
|
|
761
1155
|
const result = await repo.getAll({ page: 1, limit: 20 });
|
|
762
1156
|
|
|
763
1157
|
// Discriminated union - TypeScript knows the type
|
|
764
|
-
if (result.method ===
|
|
765
|
-
console.log(result.total, result.pages);
|
|
1158
|
+
if (result.method === "offset") {
|
|
1159
|
+
console.log(result.total, result.pages); // Available
|
|
766
1160
|
}
|
|
767
|
-
if (result.method ===
|
|
1161
|
+
if (result.method === "keyset") {
|
|
768
1162
|
console.log(result.next, result.hasMore); // Available
|
|
769
1163
|
}
|
|
770
1164
|
```
|
|
@@ -774,16 +1168,17 @@ if (result.method === 'keyset') {
|
|
|
774
1168
|
Create custom repository classes with domain-specific methods:
|
|
775
1169
|
|
|
776
1170
|
```typescript
|
|
777
|
-
import {
|
|
778
|
-
|
|
1171
|
+
import {
|
|
1172
|
+
Repository,
|
|
1173
|
+
softDeletePlugin,
|
|
1174
|
+
timestampPlugin,
|
|
1175
|
+
} from "@classytic/mongokit";
|
|
1176
|
+
import UserModel, { IUser } from "./models/User.js";
|
|
779
1177
|
|
|
780
1178
|
class UserRepository extends Repository<IUser> {
|
|
781
1179
|
constructor() {
|
|
782
|
-
super(UserModel, [
|
|
783
|
-
|
|
784
|
-
softDeletePlugin()
|
|
785
|
-
], {
|
|
786
|
-
defaultLimit: 20
|
|
1180
|
+
super(UserModel, [timestampPlugin(), softDeletePlugin()], {
|
|
1181
|
+
defaultLimit: 20,
|
|
787
1182
|
});
|
|
788
1183
|
}
|
|
789
1184
|
|
|
@@ -794,19 +1189,19 @@ class UserRepository extends Repository<IUser> {
|
|
|
794
1189
|
|
|
795
1190
|
async findActiveUsers() {
|
|
796
1191
|
return this.getAll({
|
|
797
|
-
filters: { status:
|
|
798
|
-
sort: { createdAt: -1 }
|
|
1192
|
+
filters: { status: "active" },
|
|
1193
|
+
sort: { createdAt: -1 },
|
|
799
1194
|
});
|
|
800
1195
|
}
|
|
801
1196
|
|
|
802
1197
|
async deactivate(id: string) {
|
|
803
|
-
return this.update(id, { status:
|
|
1198
|
+
return this.update(id, { status: "inactive", deactivatedAt: new Date() });
|
|
804
1199
|
}
|
|
805
1200
|
}
|
|
806
1201
|
|
|
807
1202
|
// Usage
|
|
808
1203
|
const userRepo = new UserRepository();
|
|
809
|
-
const user = await userRepo.findByEmail(
|
|
1204
|
+
const user = await userRepo.findByEmail("john@example.com");
|
|
810
1205
|
```
|
|
811
1206
|
|
|
812
1207
|
### Overriding Methods
|
|
@@ -819,12 +1214,15 @@ class AuditedUserRepository extends Repository<IUser> {
|
|
|
819
1214
|
|
|
820
1215
|
// Override create to add audit trail
|
|
821
1216
|
async create(data: Partial<IUser>, options = {}) {
|
|
822
|
-
const result = await super.create(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1217
|
+
const result = await super.create(
|
|
1218
|
+
{
|
|
1219
|
+
...data,
|
|
1220
|
+
createdBy: getCurrentUserId(),
|
|
1221
|
+
},
|
|
1222
|
+
options,
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
await auditLog("user.created", result._id);
|
|
828
1226
|
return result;
|
|
829
1227
|
}
|
|
830
1228
|
}
|
|
@@ -835,10 +1233,10 @@ class AuditedUserRepository extends Repository<IUser> {
|
|
|
835
1233
|
For simple cases without custom methods:
|
|
836
1234
|
|
|
837
1235
|
```javascript
|
|
838
|
-
import { createRepository, timestampPlugin } from
|
|
1236
|
+
import { createRepository, timestampPlugin } from "@classytic/mongokit";
|
|
839
1237
|
|
|
840
1238
|
const userRepo = createRepository(UserModel, [timestampPlugin()], {
|
|
841
|
-
defaultLimit: 20
|
|
1239
|
+
defaultLimit: 20,
|
|
842
1240
|
});
|
|
843
1241
|
```
|
|
844
1242
|
|
|
@@ -849,7 +1247,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
|
|
|
849
1247
|
- Uses its own event system (not Mongoose middleware)
|
|
850
1248
|
- Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
|
|
851
1249
|
- Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
|
|
852
|
-
- All
|
|
1250
|
+
- All 597 tests pass on Mongoose 9
|
|
853
1251
|
|
|
854
1252
|
## License
|
|
855
1253
|
|