@classytic/mongokit 3.5.6 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -1580
- package/dist/{PaginationEngine-CpsQl471.mjs → PaginationEngine-CunXkR9p.mjs} +100 -4
- package/dist/actions/index.d.mts +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/{index-DmVbUzDL.d.mts → index-BSRSsRRx.d.mts} +1 -1
- package/dist/index.d.mts +64 -7
- package/dist/index.mjs +3 -3
- package/dist/{mongooseToJsonSchema-DQMU0Dlv.d.mts → mongooseToJsonSchema-D2YjHo7W.d.mts} +1 -1
- package/dist/pagination/PaginationEngine.d.mts +8 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/{types-BwO4u3JN.d.mts → types-DiQfDesD.d.mts} +29 -3
- package/dist/utils/index.d.mts +2 -2
- package/dist/{validation-chain.plugin-DCYjnPau.mjs → validation-chain.plugin-DoOEyM_g.mjs} +261 -140
- package/dist/{validation-chain.plugin-BtmT-FFB.d.mts → validation-chain.plugin-mI2Z19lM.d.mts} +26 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,1706 +3,315 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@classytic/mongokit)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
**Works with:** Express, Fastify, NestJS, Next.js, Koa, Hapi, Serverless
|
|
9
|
-
|
|
10
|
-
## Features
|
|
11
|
-
|
|
12
|
-
- **Zero dependencies** - Only Mongoose as peer dependency
|
|
13
|
-
- **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
|
|
18
|
-
- **Geo queries** - `[near]`, `[nearSphere]`, `[withinRadius]`, `[geoWithin]` URL operators with auto sort/count handling
|
|
19
|
-
- **Schema-aware coercion** - Pass `schema: Model.schema` to QueryParser for deterministic type-safe filter values
|
|
20
|
-
- **String/UUID/Number `_id` support** - Works out of the box with any `_id` type, not just ObjectId
|
|
21
|
-
- **Query primitives** - Composable pure functions (`coercion`, `geo`, `indexes`) importable standalone for custom parsers
|
|
22
|
-
- **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
|
|
23
|
-
- **TypeScript first** - Full type safety with discriminated unions, typed events, and field autocomplete
|
|
24
|
-
- **Extensively tested** — 1500+ tests, Mongoose 9.4.1 aligned, battle-tested and production-ready
|
|
25
|
-
|
|
26
|
-
## Installation
|
|
6
|
+
Production-grade MongoDB repository pattern for Node.js. Zero runtime deps, Mongoose as a peer.
|
|
27
7
|
|
|
28
8
|
```bash
|
|
29
9
|
npm install @classytic/mongokit mongoose
|
|
30
10
|
```
|
|
31
11
|
|
|
32
|
-
|
|
12
|
+
Requires Mongoose `>=9.4.1`, Node.js `>=22`.
|
|
33
13
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```javascript
|
|
37
|
-
import { Repository } from "@classytic/mongokit";
|
|
38
|
-
import UserModel from "./models/User.js";
|
|
39
|
-
|
|
40
|
-
const userRepo = new Repository(UserModel);
|
|
14
|
+
---
|
|
41
15
|
|
|
42
|
-
|
|
43
|
-
const user = await userRepo.create({ name: "John", email: "john@example.com" });
|
|
16
|
+
## Quick start
|
|
44
17
|
|
|
45
|
-
|
|
46
|
-
|
|
18
|
+
```ts
|
|
19
|
+
import { Repository } from '@classytic/mongokit';
|
|
20
|
+
import UserModel from './models/User.js';
|
|
47
21
|
|
|
48
|
-
|
|
49
|
-
await userRepo.update(user._id, { name: "Jane" });
|
|
22
|
+
const userRepo = new Repository(UserModel);
|
|
50
23
|
|
|
51
|
-
|
|
24
|
+
const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
|
|
25
|
+
const page = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
|
|
26
|
+
await userRepo.update(user._id, { name: 'Jane' });
|
|
52
27
|
await userRepo.delete(user._id);
|
|
53
28
|
```
|
|
54
29
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`getAll()` takes an **explicit `mode`** or auto-detects based on parameters:
|
|
58
|
-
|
|
59
|
-
```javascript
|
|
60
|
-
// EXPLICIT: Offset pagination (recommended for dashboards, admin panels)
|
|
61
|
-
const result = await repo.getAll({
|
|
62
|
-
mode: "offset", // explicit — no ambiguity
|
|
63
|
-
page: 1,
|
|
64
|
-
limit: 20,
|
|
65
|
-
filters: { status: "active" },
|
|
66
|
-
sort: { createdAt: -1 },
|
|
67
|
-
countStrategy: "exact", // 'exact' | 'estimated' | 'none'
|
|
68
|
-
hint: { createdAt: -1 }, // index hint for query governance
|
|
69
|
-
maxTimeMS: 2000, // kill slow queries
|
|
70
|
-
});
|
|
71
|
-
// → { method: 'offset', docs, total, pages, hasNext, hasPrev }
|
|
30
|
+
Every Repository method is event-driven, pluggable, and returns raw Mongoose documents — no custom envelopes.
|
|
72
31
|
|
|
73
|
-
|
|
74
|
-
const stream = await repo.getAll({
|
|
75
|
-
mode: "keyset",
|
|
76
|
-
sort: { createdAt: -1 },
|
|
77
|
-
limit: 20,
|
|
78
|
-
});
|
|
79
|
-
// → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
|
|
32
|
+
---
|
|
80
33
|
|
|
81
|
-
|
|
82
|
-
const next = await repo.getAll({
|
|
83
|
-
after: stream.next,
|
|
84
|
-
sort: { createdAt: -1 },
|
|
85
|
-
limit: 20,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// AUTO-DETECTION (backwards compatible, no mode required)
|
|
89
|
-
// page parameter → offset mode
|
|
90
|
-
// after/cursor parameter → keyset mode
|
|
91
|
-
// sort without page → keyset mode (first page)
|
|
92
|
-
// nothing/filters only → offset mode (page 1)
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**Auto-detection rules (when `mode` is omitted):**
|
|
96
|
-
|
|
97
|
-
- `page` present → **offset** mode
|
|
98
|
-
- `after` or `cursor` present → **keyset** mode
|
|
99
|
-
- Non-default `sort` provided without `page` → **keyset** mode
|
|
100
|
-
- Nothing / filters only → **offset** mode (page 1)
|
|
101
|
-
|
|
102
|
-
> ⚠️ **Recommended:** Always pass `mode` explicitly in new code to make intent clear and avoid surprising behavior when query params change.
|
|
103
|
-
|
|
104
|
-
### Performance Options
|
|
105
|
-
|
|
106
|
-
| Option | Type | Description |
|
|
107
|
-
| --------------- | ------------------------------ | ------------------------------------------------------------------- |
|
|
108
|
-
| `hint` | `string \| object` | Force a specific index — prevents collection scans on large tables |
|
|
109
|
-
| `maxTimeMS` | `number` | Kill query if it takes longer than N ms (prevent runaway queries) |
|
|
110
|
-
| `countStrategy` | `'exact'\|'estimated'\|'none'` | Control cost of total-count query — use `'estimated'` for 10M+ rows |
|
|
111
|
-
|
|
112
|
-
### Required Indexes
|
|
113
|
-
|
|
114
|
-
```javascript
|
|
115
|
-
// For keyset pagination: sort field + _id (compound)
|
|
116
|
-
PostSchema.index({ createdAt: -1, _id: -1 });
|
|
117
|
-
|
|
118
|
-
// For multi-tenant: tenant + sort field + _id
|
|
119
|
-
UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
## API Reference
|
|
123
|
-
|
|
124
|
-
### CRUD Operations
|
|
125
|
-
|
|
126
|
-
| Method | Options Type | Description |
|
|
127
|
-
| -------------------------------- | -------------------- | ---------------------------------- |
|
|
128
|
-
| `create(data, opts)` | `CreateOptions` | Create single document |
|
|
129
|
-
| `createMany(data[], opts)` | `CreateOptions` | Create multiple documents |
|
|
130
|
-
| `getById(id, opts)` | `CacheableOptions` | Find by ID |
|
|
131
|
-
| `getByQuery(query, opts)` | `CacheableOptions` | Find one by query |
|
|
132
|
-
| `getOne(filter, opts)` | `CacheableOptions` | Find one by compound filter |
|
|
133
|
-
| `getAll(params, opts)` | `CacheableOptions` | Paginated list (auto-detects mode) |
|
|
134
|
-
| `findAll(filters, opts)` | `OperationOptions` | All docs without pagination |
|
|
135
|
-
| `getOrCreate(query, data, opts)` | `SessionOptions` | Find or create |
|
|
136
|
-
| `update(id, data, opts)` | `UpdateOptions` | Update document |
|
|
137
|
-
| `delete(id, opts)` | `SessionOptions` | Delete document |
|
|
138
|
-
| `count(query, opts)` | `ReadOptions` | Count documents |
|
|
139
|
-
| `exists(query, opts)` | `ReadOptions` | Check existence |
|
|
140
|
-
| `aggregate(pipeline, opts)` | `AggregateOptions` | Run aggregation pipeline |
|
|
141
|
-
| `distinct(field, query, opts)` | `ReadOptions` | Get distinct values |
|
|
142
|
-
|
|
143
|
-
All option types inherit from a clean hierarchy — import only what you need:
|
|
144
|
-
|
|
145
|
-
```
|
|
146
|
-
SessionOptions → { session }
|
|
147
|
-
└─ ReadOptions → + readPreference
|
|
148
|
-
├─ OperationOptions → + select, populate, populateOptions, lean, throwOnNotFound
|
|
149
|
-
│ ├─ CacheableOptions → + skipCache, cacheTtl
|
|
150
|
-
│ └─ UpdateOptions → + updatePipeline, arrayFilters
|
|
151
|
-
├─ AggregateOptions → + allowDiskUse, collation, maxTimeMS, maxPipelineStages
|
|
152
|
-
└─ LookupPopulateOptions → + filters, lookups, sort, page, limit, collation
|
|
153
|
-
└─ CreateOptions → + ordered
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### Aggregation
|
|
157
|
-
|
|
158
|
-
```javascript
|
|
159
|
-
// Basic aggregation
|
|
160
|
-
const result = await repo.aggregate([
|
|
161
|
-
{ $match: { status: 'active' } },
|
|
162
|
-
{ $group: { _id: '$category', total: { $sum: 1 } } }
|
|
163
|
-
]);
|
|
164
|
-
|
|
165
|
-
// Paginated aggregation
|
|
166
|
-
const result = await repo.aggregatePaginate({
|
|
167
|
-
pipeline: [...],
|
|
168
|
-
page: 1,
|
|
169
|
-
limit: 20
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Distinct values
|
|
173
|
-
const categories = await repo.distinct('category', { status: 'active' });
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Transactions
|
|
177
|
-
|
|
178
|
-
```javascript
|
|
179
|
-
await repo.withTransaction(async (session) => {
|
|
180
|
-
await repo.create({ name: "User 1" }, { session });
|
|
181
|
-
await repo.create({ name: "User 2" }, { session });
|
|
182
|
-
// Auto-commits on success, auto-rollbacks on error
|
|
183
|
-
});
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## Configuration
|
|
187
|
-
|
|
188
|
-
```javascript
|
|
189
|
-
const repo = new Repository(UserModel, plugins, {
|
|
190
|
-
defaultLimit: 20, // Default docs per page
|
|
191
|
-
maxLimit: 100, // Maximum allowed limit
|
|
192
|
-
maxPage: 10000, // Maximum page number
|
|
193
|
-
deepPageThreshold: 100, // Warn when page exceeds this
|
|
194
|
-
useEstimatedCount: false, // Use fast estimated counts
|
|
195
|
-
cursorVersion: 1, // Cursor format version
|
|
196
|
-
});
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
### Search configuration
|
|
200
|
-
|
|
201
|
-
`Repository.getAll({ search })` has three layers, ordered from simplest to most
|
|
202
|
-
flexible. Pick the lowest one that solves your problem.
|
|
203
|
-
|
|
204
|
-
| Layer | When to use | Where it lives |
|
|
205
|
-
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
|
|
206
|
-
| **`searchMode: 'text'`** | You have a MongoDB text index and want `$text`-ranked results. | Repository option (default) |
|
|
207
|
-
| **`searchMode: 'regex'`** | Small/medium collection, no text index, simple substring matching across known fields. Works behind any HTTP framework. | Repository option |
|
|
208
|
-
| **Search-resolver plugin** | External engine (Elasticsearch, Meilisearch, Typesense, pgvector, hybrid BM25+vector). Composes with cache, multi-tenant. | `before:getAll` plugin — see Plugins below |
|
|
209
|
-
|
|
210
|
-
```javascript
|
|
211
|
-
// Built-in regex mode — no MongoDB text index required
|
|
212
|
-
const repo = new Repository(
|
|
213
|
-
MemoModel,
|
|
214
|
-
[],
|
|
215
|
-
{},
|
|
216
|
-
{
|
|
217
|
-
// 'text' → default, requires text index
|
|
218
|
-
// 'regex' → case-insensitive $or of $regex across searchFields, no index needed
|
|
219
|
-
// 'auto' → text if a text index exists, otherwise regex
|
|
220
|
-
searchMode: "regex",
|
|
221
|
-
searchFields: ["title", "scope", "body"],
|
|
222
|
-
},
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
await repo.getAll({ search: "alpha", filters: { scope: "public" } });
|
|
226
|
-
```
|
|
34
|
+
## Core concepts
|
|
227
35
|
|
|
228
|
-
|
|
229
|
-
`$or` filter is preserved by promoting both clauses to `$and`.
|
|
36
|
+
### Repository
|
|
230
37
|
|
|
231
|
-
|
|
232
|
-
> for the HTTP boundary (`?search=foo` URL → parsed query). The two layers are
|
|
233
|
-
> independent: configure the parser if you want URL-time consumption (Arc does
|
|
234
|
-
> this), configure the Repository if you want data-layer consumption
|
|
235
|
-
> (Express/Nest/raw handlers), or both for defense in depth — they will not
|
|
236
|
-
> conflict because the parser clears `parsed.search` before it reaches the repo.
|
|
38
|
+
A `Repository<TDoc>` wraps a Mongoose model and exposes a small, uniform CRUD surface:
|
|
237
39
|
|
|
238
|
-
|
|
40
|
+
- **Reads:** `getById`, `getByQuery`, `getOne`, `getAll`, `findAll`, `count`, `exists`, `distinct`, `aggregate`, `aggregatePaginate`, `lookupPopulate`
|
|
41
|
+
- **Writes:** `create`, `createMany`, `update`, `updateMany` (via plugin), `delete`, `deleteMany` (via plugin), `bulkWrite` (via plugin)
|
|
42
|
+
- **Restore / trash bin:** via `softDeletePlugin`
|
|
239
43
|
|
|
240
|
-
|
|
241
|
-
MongoDB queries. All validate coordinate ranges and reject malformed input by
|
|
242
|
-
dropping the filter (not silently widening).
|
|
44
|
+
Extend it by subclassing; add domain verbs on the subclass. Do not wrap it in a service layer.
|
|
243
45
|
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// Count-compatible radius — use this for paginated getAll (supports sort + count)
|
|
252
|
-
const radius = parser.parse({ "location[withinRadius]": "-73.98,40.76,5000" });
|
|
253
|
-
// → { location: { $geoWithin: { $centerSphere: [[-73.98, 40.76], 0.000783...] } } }
|
|
254
|
-
|
|
255
|
-
// Bounding box (works without a 2dsphere index)
|
|
256
|
-
const box = parser.parse({ "location[geoWithin]": "-74,40.7,-73.9,40.8" });
|
|
257
|
-
// → { location: { $geoWithin: { $box: [[-74, 40.7], [-73.9, 40.8]] } } }
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
**Repository integration:** `Repository.getAll` auto-detects `$near` /
|
|
261
|
-
`$nearSphere` in filters and handles the MongoDB constraints transparently:
|
|
262
|
-
|
|
263
|
-
- Skips the default `-createdAt` sort (MongoDB forbids explicit sort with `$near`)
|
|
264
|
-
- Warns and drops any caller-supplied sort that conflicts
|
|
265
|
-
- Rewrites the count query via `$geoWithin: $centerSphere` for accurate `total`
|
|
266
|
-
- Falls back to `countStrategy: 'none'` for unbounded `$near` (no `$maxDistance`)
|
|
267
|
-
- All plugins (multi-tenant, soft-delete, cache, populate) compose unchanged
|
|
268
|
-
|
|
269
|
-
Use `[near]` when you need distance-sorted results. Use `[withinRadius]` when
|
|
270
|
-
you need paginated lists with `total` and custom sort.
|
|
271
|
-
|
|
272
|
-
### String / UUID / Number `_id`
|
|
273
|
-
|
|
274
|
-
Schemas that declare `_id` as `String`, `Number`, or `UUID` instead of the
|
|
275
|
-
default `ObjectId` work out of the box — no configuration needed:
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
|
-
const SessionSchema = new Schema({
|
|
279
|
-
_id: { type: String, default: () => randomUUID() },
|
|
280
|
-
userId: { type: String, required: true },
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
const repo = new Repository(SessionModel);
|
|
284
|
-
await repo.create({ userId: "user-1" }); // _id auto-generated as UUID
|
|
285
|
-
await repo.getById("550e8400-e29b-..."); // works — no ObjectId validation
|
|
286
|
-
await repo.update("550e8400-e29b-...", { ... }); // works
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
The `id-resolution` utility detects the `_id` type from the schema and
|
|
290
|
-
validates accordingly. Schema generation also includes String `_id` as an
|
|
291
|
-
optional field in `createBody` so Fastify/AJV validation accepts user-supplied
|
|
292
|
-
IDs. Available standalone:
|
|
293
|
-
|
|
294
|
-
```typescript
|
|
295
|
-
import { getSchemaIdType, isValidIdForType } from "@classytic/mongokit/utils";
|
|
296
|
-
|
|
297
|
-
const idType = getSchemaIdType(SessionModel.schema); // 'string'
|
|
298
|
-
isValidIdForType("550e8400-...", idType); // true
|
|
299
|
-
isValidIdForType("not-valid", "objectid"); // false
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Query primitives
|
|
303
|
-
|
|
304
|
-
Pure, composable functions that power QueryParser internals — importable
|
|
305
|
-
standalone for custom parsers, Arc controllers, migration scripts, and AI
|
|
306
|
-
agents that build queries programmatically:
|
|
307
|
-
|
|
308
|
-
```typescript
|
|
309
|
-
// Geo
|
|
310
|
-
import { parseGeoFilter, hasNearOperator } from "@classytic/mongokit/query/primitives/geo";
|
|
311
|
-
// Coercion
|
|
312
|
-
import { coerceFieldValue, buildFieldTypeMap } from "@classytic/mongokit/query/primitives/coercion";
|
|
313
|
-
// Schema indexes
|
|
314
|
-
import { extractSchemaIndexes } from "@classytic/mongokit/query/primitives/indexes";
|
|
46
|
+
```ts
|
|
47
|
+
class InvoiceRepository extends Repository<InvoiceDoc> {
|
|
48
|
+
async markPaid(id: string, ctx: RepositoryContext) {
|
|
49
|
+
return this.update(id, { status: 'paid', paidAt: new Date() }, ctx);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
315
52
|
```
|
|
316
53
|
|
|
317
|
-
|
|
318
|
-
and decoupled from Mongoose — they accept structural types, not concrete
|
|
319
|
-
Mongoose instances.
|
|
320
|
-
|
|
321
|
-
## Plugins
|
|
54
|
+
### Plugins
|
|
322
55
|
|
|
323
|
-
|
|
56
|
+
Plugins register hooks and/or methods on a repository. Order matters — they run at declared priorities:
|
|
324
57
|
|
|
325
|
-
```
|
|
58
|
+
```ts
|
|
326
59
|
import {
|
|
327
60
|
Repository,
|
|
328
|
-
|
|
61
|
+
methodRegistryPlugin,
|
|
62
|
+
batchOperationsPlugin,
|
|
63
|
+
multiTenantPlugin,
|
|
329
64
|
softDeletePlugin,
|
|
330
65
|
cachePlugin,
|
|
331
|
-
|
|
332
|
-
} from "@classytic/mongokit";
|
|
66
|
+
} from '@classytic/mongokit';
|
|
333
67
|
|
|
334
|
-
const repo = new Repository(
|
|
335
|
-
timestampPlugin(),
|
|
336
|
-
softDeletePlugin(),
|
|
337
|
-
cachePlugin({ adapter: createMemoryCache(), ttl: 60 }),
|
|
338
|
-
]);
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Available Plugins
|
|
342
|
-
|
|
343
|
-
| Plugin | Description |
|
|
344
|
-
| ----------------------------------- | --------------------------------------------------------- |
|
|
345
|
-
| `timestampPlugin()` | Auto-manage `createdAt`/`updatedAt` |
|
|
346
|
-
| `softDeletePlugin(opts)` | Mark as deleted instead of removing |
|
|
347
|
-
| `auditLogPlugin(logger)` | Log all CUD operations |
|
|
348
|
-
| `cachePlugin(opts)` | Redis/Memcached/memory caching with auto-invalidation |
|
|
349
|
-
| `validationChainPlugin(validators)` | Custom validation rules |
|
|
350
|
-
| `fieldFilterPlugin(preset)` | Role-based field visibility |
|
|
351
|
-
| `cascadePlugin(opts)` | Auto-delete related documents |
|
|
352
|
-
| `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
|
|
353
|
-
| `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
|
|
354
|
-
| `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany`, `bulkWrite` |
|
|
355
|
-
| `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
|
|
356
|
-
| `subdocumentPlugin()` | Manage subdocument arrays |
|
|
357
|
-
| `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
|
|
358
|
-
| `customIdPlugin(opts)` | Auto-generate sequential/random IDs with atomic counters |
|
|
359
|
-
| `elasticSearchPlugin(opts)` | Delegate text/semantic search to Elasticsearch/OpenSearch |
|
|
360
|
-
| `auditTrailPlugin(opts)` | DB-persisted audit trail with change tracking and TTL |
|
|
361
|
-
| `observabilityPlugin(opts)` | Operation timing, metrics, slow query detection |
|
|
362
|
-
|
|
363
|
-
### Soft Delete
|
|
364
|
-
|
|
365
|
-
```javascript
|
|
366
|
-
const repo = new Repository(UserModel, [
|
|
68
|
+
const repo = new Repository<Invoice>(InvoiceModel, [
|
|
367
69
|
methodRegistryPlugin(),
|
|
368
70
|
batchOperationsPlugin(),
|
|
369
|
-
|
|
71
|
+
multiTenantPlugin({ tenantField: 'organizationId' }), // POLICY (100)
|
|
72
|
+
softDeletePlugin({ deletedField: 'deletedAt' }), // POLICY (100)
|
|
73
|
+
cachePlugin({ adapter: redisAdapter }), // CACHE (200)
|
|
370
74
|
]);
|
|
371
|
-
|
|
372
|
-
await repo.delete(id); // Marks as deleted (sets deletedAt)
|
|
373
|
-
await repo.getAll(); // Excludes deleted
|
|
374
|
-
await repo.getAll({ includeDeleted: true }); // Includes deleted
|
|
375
|
-
|
|
376
|
-
// Batch operations respect soft-delete automatically
|
|
377
|
-
await repo.deleteMany({ status: "draft" }); // Soft-deletes matching docs
|
|
378
|
-
await repo.updateMany({ status: "active" }, { $set: { featured: true } }); // Skips soft-deleted
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
### Populate via URL (Array Refs + Field Selection)
|
|
382
|
-
|
|
383
|
-
Populate arrays of ObjectIds with field selection, filtering, and sorting — all from URL query params:
|
|
384
|
-
|
|
385
|
-
```bash
|
|
386
|
-
# Populate all products in an order
|
|
387
|
-
GET /orders?populate=products
|
|
388
|
-
|
|
389
|
-
# Only name and price from each product
|
|
390
|
-
GET /orders?populate[products][select]=name,price
|
|
391
|
-
|
|
392
|
-
# Exclude fields
|
|
393
|
-
GET /orders?populate[products][select]=-internalNotes,-cost
|
|
394
|
-
|
|
395
|
-
# Filter: only active products
|
|
396
|
-
GET /orders?populate[products][match][status]=active
|
|
397
|
-
|
|
398
|
-
# Limit + sort populated items
|
|
399
|
-
GET /orders?populate[products][limit]=5&populate[products][sort]=-price
|
|
400
|
-
|
|
401
|
-
# Combined
|
|
402
|
-
GET /orders?populate[products][select]=name,price&populate[products][match][status]=active&populate[products][limit]=10
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
```typescript
|
|
406
|
-
// Express route — 3 lines
|
|
407
|
-
const parsed = parser.parse(req.query);
|
|
408
|
-
const result = await orderRepo.getAll(
|
|
409
|
-
{ filters: parsed.filters, sort: parsed.sort, limit: parsed.limit },
|
|
410
|
-
{ populateOptions: parsed.populateOptions, populate: parsed.populate },
|
|
411
|
-
);
|
|
412
75
|
```
|
|
413
76
|
|
|
414
|
-
|
|
77
|
+
Hook priorities ensure deterministic ordering: `POLICY (100) → CACHE (200) → OBSERVABILITY (300) → DEFAULT (500)`.
|
|
415
78
|
|
|
416
|
-
|
|
79
|
+
### Events
|
|
417
80
|
|
|
418
|
-
|
|
419
|
-
# Join products with categories by slug
|
|
420
|
-
GET /products?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug&lookup[category][single]=true
|
|
421
|
-
|
|
422
|
-
# With field selection on joined collection (only bring name + slug)
|
|
423
|
-
GET /products?lookup[category][...same]&lookup[category][select]=name,slug
|
|
424
|
-
|
|
425
|
-
# Combined with filter + sort + root select
|
|
426
|
-
GET /products?status=active&sort=-price&select=name,price,category&lookup[category][...same]&lookup[category][select]=name
|
|
427
|
-
```
|
|
81
|
+
Every operation emits `before:*`, `after:*`, `error:*`:
|
|
428
82
|
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const result = await repo.getAll({
|
|
433
|
-
filters: parsed.filters,
|
|
434
|
-
sort: parsed.sort,
|
|
435
|
-
lookups: parsed.lookups, // auto-routes to lookupPopulate
|
|
436
|
-
select: parsed.select,
|
|
437
|
-
limit: parsed.limit,
|
|
438
|
-
});
|
|
83
|
+
```ts
|
|
84
|
+
repo.on('before:delete', (ctx) => { /* mutate ctx, throw to veto */ });
|
|
85
|
+
repo.on('after:create', ({ context, result }) => { /* audit, notify */ });
|
|
439
86
|
```
|
|
440
87
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
### Caching
|
|
88
|
+
The `before:*` hook receives the context directly; `after:*` and `error:*` receive `{ context, result | error }`.
|
|
444
89
|
|
|
445
|
-
|
|
446
|
-
import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
|
|
90
|
+
---
|
|
447
91
|
|
|
448
|
-
|
|
449
|
-
cachePlugin({
|
|
450
|
-
adapter: createMemoryCache(), // or Redis adapter
|
|
451
|
-
ttl: 60, // Default TTL (seconds)
|
|
452
|
-
byIdTtl: 300, // TTL for getById
|
|
453
|
-
queryTtl: 30, // TTL for lists
|
|
454
|
-
}),
|
|
455
|
-
]);
|
|
456
|
-
|
|
457
|
-
// Reads are cached automatically
|
|
458
|
-
const user = await repo.getById(id);
|
|
92
|
+
## Pagination
|
|
459
93
|
|
|
460
|
-
|
|
461
|
-
const fresh = await repo.getById(id, { skipCache: true });
|
|
94
|
+
`getAll` supports offset, keyset (cursor), and aggregate pagination. Pass `mode: 'offset' | 'keyset'` explicitly or let it auto-detect.
|
|
462
95
|
|
|
463
|
-
|
|
464
|
-
|
|
96
|
+
```ts
|
|
97
|
+
// Offset — dashboards, admin panels
|
|
98
|
+
await repo.getAll({ mode: 'offset', page: 1, limit: 20, sort: { createdAt: -1 } });
|
|
99
|
+
// → { method: 'offset', docs, total, pages, hasNext, hasPrev }
|
|
465
100
|
|
|
466
|
-
//
|
|
467
|
-
await repo.
|
|
468
|
-
await repo.
|
|
101
|
+
// Keyset — feeds, infinite scroll
|
|
102
|
+
const p1 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, limit: 20 });
|
|
103
|
+
const p2 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, after: p1.next });
|
|
104
|
+
// → { method: 'keyset', docs, hasMore, next }
|
|
469
105
|
```
|
|
470
106
|
|
|
471
|
-
|
|
107
|
+
Keyset pagination with `filters + sort` warns once if no matching schema-declared compound index exists. Silent in `NODE_ENV=test`. Route warnings via `configureLogger({ warn })`.
|
|
472
108
|
|
|
473
|
-
|
|
474
|
-
const redisAdapter = {
|
|
475
|
-
async get(key) {
|
|
476
|
-
return JSON.parse((await redis.get(key)) || "null");
|
|
477
|
-
},
|
|
478
|
-
async set(key, value, ttl) {
|
|
479
|
-
await redis.setex(key, ttl, JSON.stringify(value));
|
|
480
|
-
},
|
|
481
|
-
async del(key) {
|
|
482
|
-
await redis.del(key);
|
|
483
|
-
},
|
|
484
|
-
async clear(pattern) {
|
|
485
|
-
/* optional bulk delete */
|
|
486
|
-
},
|
|
487
|
-
};
|
|
488
|
-
```
|
|
109
|
+
---
|
|
489
110
|
|
|
490
|
-
|
|
111
|
+
## Delete semantics
|
|
491
112
|
|
|
492
|
-
|
|
493
|
-
import {
|
|
494
|
-
validationChainPlugin,
|
|
495
|
-
requireField,
|
|
496
|
-
uniqueField,
|
|
497
|
-
immutableField,
|
|
498
|
-
blockIf,
|
|
499
|
-
autoInject,
|
|
500
|
-
} from "@classytic/mongokit";
|
|
501
|
-
|
|
502
|
-
const repo = new Repository(UserModel, [
|
|
503
|
-
validationChainPlugin([
|
|
504
|
-
requireField("email", ["create"]),
|
|
505
|
-
uniqueField("email", "Email already exists"),
|
|
506
|
-
immutableField("userId"),
|
|
507
|
-
blockIf(
|
|
508
|
-
"noAdminDelete",
|
|
509
|
-
["delete"],
|
|
510
|
-
(ctx) => ctx.data?.role === "admin",
|
|
511
|
-
"Cannot delete admin users",
|
|
512
|
-
),
|
|
513
|
-
autoInject("slug", (ctx) => slugify(ctx.data?.name), ["create"]),
|
|
514
|
-
]),
|
|
515
|
-
]);
|
|
516
|
-
```
|
|
113
|
+
`delete(id)` respects the plugin stack by default — soft when `softDeletePlugin` is wired, physical otherwise. Pass `mode: 'hard'` for GDPR / admin cleanup while keeping every policy hook firing:
|
|
517
114
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
import { cascadePlugin, softDeletePlugin } from "@classytic/mongokit";
|
|
522
|
-
|
|
523
|
-
const repo = new Repository(ProductModel, [
|
|
524
|
-
softDeletePlugin(),
|
|
525
|
-
cascadePlugin({
|
|
526
|
-
relations: [
|
|
527
|
-
{ model: "StockEntry", foreignKey: "product" },
|
|
528
|
-
{ model: "Review", foreignKey: "product", softDelete: false },
|
|
529
|
-
],
|
|
530
|
-
parallel: true,
|
|
531
|
-
logger: console,
|
|
532
|
-
}),
|
|
533
|
-
]);
|
|
115
|
+
```ts
|
|
116
|
+
// Default — soft when plugin wired
|
|
117
|
+
await repo.delete(userId, { organizationId });
|
|
534
118
|
|
|
535
|
-
//
|
|
536
|
-
await repo.delete(
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
### Field Filtering (RBAC)
|
|
540
|
-
|
|
541
|
-
```javascript
|
|
542
|
-
import { fieldFilterPlugin } from "@classytic/mongokit";
|
|
119
|
+
// Hard — physical delete, audit + tenant scoping still enforced
|
|
120
|
+
await repo.delete(userId, { organizationId, mode: 'hard' });
|
|
543
121
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}),
|
|
550
|
-
]);
|
|
122
|
+
// Bulk hard delete (requires batchOperationsPlugin)
|
|
123
|
+
await repo.deleteMany(
|
|
124
|
+
{ createdAt: { $lt: cutoff } },
|
|
125
|
+
{ organizationId, mode: 'hard' },
|
|
126
|
+
);
|
|
551
127
|
```
|
|
552
128
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
```javascript
|
|
556
|
-
import { multiTenantPlugin } from "@classytic/mongokit";
|
|
129
|
+
Never drop to `repo.Model.deleteOne` — that bypasses every hook you wired.
|
|
557
130
|
|
|
558
|
-
|
|
559
|
-
multiTenantPlugin({
|
|
560
|
-
tenantField: "organizationId",
|
|
561
|
-
contextKey: "organizationId", // reads from context
|
|
562
|
-
required: true,
|
|
563
|
-
}),
|
|
564
|
-
]);
|
|
565
|
-
|
|
566
|
-
// All operations are automatically scoped to the tenant
|
|
567
|
-
const users = await repo.getAll({ organizationId: "org_123" });
|
|
568
|
-
await repo.update(userId, { name: "New" }, { organizationId: "org_123" });
|
|
569
|
-
// Cross-tenant update/delete is blocked — returns "not found"
|
|
570
|
-
```
|
|
131
|
+
---
|
|
571
132
|
|
|
572
|
-
|
|
133
|
+
## Transactions
|
|
573
134
|
|
|
574
|
-
|
|
135
|
+
Two entry points, identical semantics, shared retry/fallback logic:
|
|
575
136
|
|
|
576
|
-
```
|
|
577
|
-
import {
|
|
578
|
-
Repository,
|
|
579
|
-
methodRegistryPlugin,
|
|
580
|
-
auditTrailPlugin,
|
|
581
|
-
} from "@classytic/mongokit";
|
|
137
|
+
```ts
|
|
138
|
+
import { withTransaction } from '@classytic/mongokit';
|
|
582
139
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
trackDocument: false, // Full doc snapshot on create (heavy)
|
|
589
|
-
ttlDays: 90, // Auto-purge after 90 days (MongoDB TTL index)
|
|
590
|
-
excludeFields: ["password", "token"], // Redact sensitive fields
|
|
591
|
-
metadata: (context) => ({
|
|
592
|
-
// Custom metadata per entry
|
|
593
|
-
ip: context.req?.ip,
|
|
594
|
-
userAgent: context.req?.headers?.["user-agent"],
|
|
595
|
-
}),
|
|
596
|
-
}),
|
|
597
|
-
]);
|
|
598
|
-
|
|
599
|
-
// Query audit trail for a specific document (requires methodRegistryPlugin)
|
|
600
|
-
const trail = await repo.getAuditTrail(documentId, {
|
|
601
|
-
page: 1,
|
|
602
|
-
limit: 20,
|
|
603
|
-
operation: "update", // Optional filter
|
|
140
|
+
// Cross-repo — pass a Mongoose connection
|
|
141
|
+
await withTransaction(mongoose.connection, async (session) => {
|
|
142
|
+
const order = await orderRepo.create(data, { session });
|
|
143
|
+
await ledgerRepo.create({ orderId: order._id, amount }, { session });
|
|
144
|
+
return order;
|
|
604
145
|
});
|
|
605
|
-
// → { docs, page, limit, total, pages, hasNext, hasPrev }
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
**What gets stored:**
|
|
609
|
-
|
|
610
|
-
```javascript
|
|
611
|
-
{
|
|
612
|
-
model: 'Job',
|
|
613
|
-
operation: 'update',
|
|
614
|
-
documentId: ObjectId('...'),
|
|
615
|
-
userId: ObjectId('...'),
|
|
616
|
-
orgId: ObjectId('...'),
|
|
617
|
-
changes: {
|
|
618
|
-
title: { from: 'Old Title', to: 'New Title' },
|
|
619
|
-
salary: { from: 50000, to: 65000 },
|
|
620
|
-
},
|
|
621
|
-
metadata: { ip: '192.168.1.1' },
|
|
622
|
-
timestamp: ISODate('2026-02-26T...'),
|
|
623
|
-
}
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
**Standalone queries** (admin dashboards, audit APIs — no repo needed):
|
|
627
|
-
|
|
628
|
-
```typescript
|
|
629
|
-
import { AuditTrailQuery } from "@classytic/mongokit";
|
|
630
|
-
|
|
631
|
-
const auditQuery = new AuditTrailQuery(); // 'audit_trails' collection
|
|
632
|
-
|
|
633
|
-
// All audits for an org
|
|
634
|
-
const orgAudits = await auditQuery.getOrgTrail(orgId);
|
|
635
146
|
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
// History of a specific document
|
|
640
|
-
const docHistory = await auditQuery.getDocumentTrail("Job", jobId);
|
|
641
|
-
|
|
642
|
-
// Custom query with date range
|
|
643
|
-
const recent = await auditQuery.query({
|
|
644
|
-
orgId,
|
|
645
|
-
operation: "delete",
|
|
646
|
-
from: new Date("2025-01-01"),
|
|
647
|
-
to: new Date(),
|
|
648
|
-
page: 1,
|
|
649
|
-
limit: 50,
|
|
147
|
+
// Single-repo convenience
|
|
148
|
+
await orderRepo.withTransaction(async (session) => {
|
|
149
|
+
return orderRepo.create(data, { session });
|
|
650
150
|
});
|
|
651
|
-
|
|
652
|
-
// Direct model access for anything custom
|
|
653
|
-
const model = auditQuery.getModel();
|
|
654
|
-
const deleteCount = await model.countDocuments({ operation: "delete" });
|
|
655
151
|
```
|
|
656
152
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
- **Fire & forget** — audit writes are async and never block or fail the main operation
|
|
660
|
-
- **Shared collection** — one `audit_trails` collection for all models (filtered by `model` field)
|
|
661
|
-
- **TTL index** — MongoDB auto-deletes old entries, no cron needed
|
|
662
|
-
- **Change diff** — compares before/after on updates, stores only changed fields
|
|
663
|
-
|
|
664
|
-
**Plugin options:**
|
|
665
|
-
|
|
666
|
-
| Option | Default | Description |
|
|
667
|
-
| --------------- | -------------------------------- | -------------------------------------- |
|
|
668
|
-
| `operations` | `['create', 'update', 'delete']` | Which operations to audit |
|
|
669
|
-
| `trackChanges` | `true` | Store before/after diff on updates |
|
|
670
|
-
| `trackDocument` | `false` | Store full document snapshot on create |
|
|
671
|
-
| `ttlDays` | `undefined` (keep forever) | Auto-purge after N days |
|
|
672
|
-
| `collectionName`| `'audit_trails'` | MongoDB collection name |
|
|
673
|
-
| `excludeFields` | `[]` | Fields to redact from diffs/snapshots |
|
|
674
|
-
| `metadata` | `undefined` | Callback to inject custom metadata |
|
|
153
|
+
Both auto-retry on `TransientTransactionError` / `UnknownTransactionCommitResult`. Pass `{ allowFallback: true, onFallback }` to run the callback non-transactionally on standalone MongoDB (dev).
|
|
675
154
|
|
|
676
|
-
|
|
155
|
+
---
|
|
677
156
|
|
|
678
|
-
|
|
679
|
-
import type { AuditTrailMethods } from "@classytic/mongokit";
|
|
157
|
+
## Outbox pattern — compose, don't plugin
|
|
680
158
|
|
|
681
|
-
|
|
159
|
+
mongokit does **not** ship an `outboxPlugin`. It doesn't need to.
|
|
682
160
|
|
|
683
|
-
|
|
684
|
-
methodRegistryPlugin(),
|
|
685
|
-
auditTrailPlugin({ ttlDays: 90 }),
|
|
686
|
-
]) as JobRepoWithAudit;
|
|
687
|
-
|
|
688
|
-
// Full autocomplete for getAuditTrail
|
|
689
|
-
const trail = await repo.getAuditTrail(jobId, { operation: "update" });
|
|
690
|
-
```
|
|
161
|
+
The only thing outbox requires is the ability to write an event row **in the same MongoDB session** as the business write — otherwise a crash between the two writes loses the event. Mongokit's hook system already hands you `context.session` at the exact moment of the write, so hosts can wire outbox with a ~60-line recipe that composes:
|
|
691
162
|
|
|
692
|
-
|
|
163
|
+
- **mongokit's hooks** (`before:create` / `before:update` / `before:delete`) for session-bound writes
|
|
164
|
+
- **arc's `EventTransport`** (Memory / Redis / Kafka / etc.) for delivery
|
|
165
|
+
- **a Mongo collection** (with a TTL index on `deliveredAt`) as the outbox store
|
|
693
166
|
|
|
694
|
-
|
|
695
|
-
import { observabilityPlugin } from "@classytic/mongokit";
|
|
696
|
-
|
|
697
|
-
const repo = new Repository(UserModel, [
|
|
698
|
-
observabilityPlugin({
|
|
699
|
-
onMetric: (metric) => {
|
|
700
|
-
// Send to DataDog, New Relic, OpenTelemetry, etc.
|
|
701
|
-
statsd.histogram(`mongokit.${metric.operation}`, metric.duration);
|
|
702
|
-
},
|
|
703
|
-
slowThresholdMs: 200, // log operations slower than 200ms
|
|
704
|
-
}),
|
|
705
|
-
]);
|
|
706
|
-
```
|
|
167
|
+
See [`tests/_shared/outbox-recipe.ts`](./tests/_shared/outbox-recipe.ts) for the full reference implementation and [`tests/outbox-recipe.test.ts`](./tests/outbox-recipe.test.ts) for end-to-end coverage of the pattern (session threading, FIFO relay, failure retry, `shouldEnqueue` / `enrichMeta` extension points).
|
|
707
168
|
|
|
708
|
-
|
|
169
|
+
Sketch:
|
|
709
170
|
|
|
710
|
-
|
|
171
|
+
```ts
|
|
172
|
+
// host: src/outbox/wire.ts
|
|
173
|
+
import { wireOutbox, MongoOutboxStore } from './outbox-recipe.js';
|
|
174
|
+
import { RedisEventTransport } from '@classytic/arc/events';
|
|
711
175
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
Repository,
|
|
715
|
-
customIdPlugin,
|
|
716
|
-
sequentialId,
|
|
717
|
-
dateSequentialId,
|
|
718
|
-
prefixedId,
|
|
719
|
-
} from "@classytic/mongokit";
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
#### Sequential Counter
|
|
723
|
-
|
|
724
|
-
```typescript
|
|
725
|
-
const invoiceRepo = new Repository(InvoiceModel, [
|
|
726
|
-
customIdPlugin({
|
|
727
|
-
field: "invoiceNumber",
|
|
728
|
-
generator: sequentialId({
|
|
729
|
-
prefix: "INV",
|
|
730
|
-
model: InvoiceModel,
|
|
731
|
-
}),
|
|
732
|
-
}),
|
|
733
|
-
]);
|
|
734
|
-
|
|
735
|
-
const inv1 = await invoiceRepo.create({ amount: 100 });
|
|
736
|
-
// inv1.invoiceNumber → "INV-0001"
|
|
737
|
-
|
|
738
|
-
const inv2 = await invoiceRepo.create({ amount: 200 });
|
|
739
|
-
// inv2.invoiceNumber → "INV-0002"
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
**Options:**
|
|
743
|
-
|
|
744
|
-
| Option | Default | Description |
|
|
745
|
-
| ------------ | ------------ | ---------------------------------------------------- |
|
|
746
|
-
| `prefix` | _(required)_ | Prefix string (e.g., `'INV'`, `'ORD'`) |
|
|
747
|
-
| `model` | _(required)_ | Mongoose model (counter key derived from model name) |
|
|
748
|
-
| `padding` | `4` | Number of digits (`4` → `0001`) |
|
|
749
|
-
| `separator` | `'-'` | Separator between prefix and number |
|
|
750
|
-
| `counterKey` | model name | Custom counter key to avoid collisions |
|
|
751
|
-
|
|
752
|
-
#### Date-Partitioned Counter
|
|
753
|
-
|
|
754
|
-
Counter resets per period — ideal for invoice/bill numbering:
|
|
755
|
-
|
|
756
|
-
```typescript
|
|
757
|
-
const billRepo = new Repository(BillModel, [
|
|
758
|
-
customIdPlugin({
|
|
759
|
-
field: "billNumber",
|
|
760
|
-
generator: dateSequentialId({
|
|
761
|
-
prefix: "BILL",
|
|
762
|
-
model: BillModel,
|
|
763
|
-
partition: "monthly", // resets each month
|
|
764
|
-
}),
|
|
765
|
-
}),
|
|
766
|
-
]);
|
|
767
|
-
|
|
768
|
-
const bill = await billRepo.create({ total: 250 });
|
|
769
|
-
// bill.billNumber → "BILL-2026-02-0001"
|
|
770
|
-
```
|
|
771
|
-
|
|
772
|
-
**Partition modes:**
|
|
773
|
-
|
|
774
|
-
- `'yearly'` → `BILL-2026-0001` (resets every January)
|
|
775
|
-
- `'monthly'` → `BILL-2026-02-0001` (resets every month)
|
|
776
|
-
- `'daily'` → `BILL-2026-02-20-0001` (resets every day)
|
|
777
|
-
|
|
778
|
-
#### Prefixed Random ID
|
|
779
|
-
|
|
780
|
-
No database round-trip — purely in-memory random suffix:
|
|
781
|
-
|
|
782
|
-
```typescript
|
|
783
|
-
const orderRepo = new Repository(OrderModel, [
|
|
784
|
-
customIdPlugin({
|
|
785
|
-
field: "orderRef",
|
|
786
|
-
generator: prefixedId({ prefix: "ORD", length: 10 }),
|
|
787
|
-
}),
|
|
788
|
-
]);
|
|
789
|
-
|
|
790
|
-
const order = await orderRepo.create({ total: 99 });
|
|
791
|
-
// order.orderRef → "ORD_a7b3xk9m2p"
|
|
792
|
-
```
|
|
793
|
-
|
|
794
|
-
#### Custom Generator
|
|
795
|
-
|
|
796
|
-
Write your own generator function for full control:
|
|
797
|
-
|
|
798
|
-
```typescript
|
|
799
|
-
const repo = new Repository(OrderModel, [
|
|
800
|
-
customIdPlugin({
|
|
801
|
-
field: "orderRef",
|
|
802
|
-
generator: async (context) => {
|
|
803
|
-
const region = context.data?.region || "US";
|
|
804
|
-
const seq = await getNextSequence("orders");
|
|
805
|
-
return `ORD-${region}-${String(seq).padStart(4, "0")}`;
|
|
806
|
-
},
|
|
807
|
-
}),
|
|
808
|
-
]);
|
|
809
|
-
// → "ORD-US-0001", "ORD-EU-0002", ...
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
#### Plugin Options
|
|
813
|
-
|
|
814
|
-
| Option | Default | Description |
|
|
815
|
-
| --------------------- | ------------ | -------------------------------------------- |
|
|
816
|
-
| `field` | `'customId'` | Document field to store the generated ID |
|
|
817
|
-
| `generator` | _(required)_ | Function returning the ID (sync or async) |
|
|
818
|
-
| `generateOnlyIfEmpty` | `true` | Skip generation if field already has a value |
|
|
819
|
-
|
|
820
|
-
#### Batch Creation
|
|
821
|
-
|
|
822
|
-
Works with `createMany` — each document gets its own sequential ID:
|
|
823
|
-
|
|
824
|
-
```typescript
|
|
825
|
-
const docs = await invoiceRepo.createMany([
|
|
826
|
-
{ amount: 10 },
|
|
827
|
-
{ amount: 20, invoiceNumber: "MANUAL-001" }, // skipped (already has ID)
|
|
828
|
-
{ amount: 30 },
|
|
829
|
-
]);
|
|
830
|
-
// docs[0].invoiceNumber → "INV-0001"
|
|
831
|
-
// docs[1].invoiceNumber → "MANUAL-001" (preserved)
|
|
832
|
-
// docs[2].invoiceNumber → "INV-0002"
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
#### Atomic Counter API
|
|
836
|
-
|
|
837
|
-
The `getNextSequence` helper is exported for use in custom generators:
|
|
838
|
-
|
|
839
|
-
```typescript
|
|
840
|
-
import { getNextSequence } from "@classytic/mongokit";
|
|
841
|
-
|
|
842
|
-
const seq = await getNextSequence("my-counter"); // → 1, 2, 3, ...
|
|
843
|
-
const batch = await getNextSequence("my-counter", 5); // → jumps by 5
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
Counters are stored in the `_mongokit_counters` collection using MongoDB's atomic `findOneAndUpdate` + `$inc` — guaranteed unique under any level of concurrency.
|
|
847
|
-
|
|
848
|
-
> **Note:** Counters are monotonically increasing and never decrement on document deletion.
|
|
849
|
-
> This is standard behavior for business documents (invoices, bills, receipts) — you never reuse a number.
|
|
850
|
-
|
|
851
|
-
### Vector Search (Atlas)
|
|
852
|
-
|
|
853
|
-
```javascript
|
|
854
|
-
import { vectorPlugin } from '@classytic/mongokit/ai';
|
|
855
|
-
|
|
856
|
-
const repo = new Repository(ProductModel, [
|
|
857
|
-
methodRegistryPlugin(),
|
|
858
|
-
vectorPlugin({
|
|
859
|
-
fields: [{
|
|
860
|
-
path: 'embedding',
|
|
861
|
-
index: 'vector_index',
|
|
862
|
-
dimensions: 1536,
|
|
863
|
-
sourceFields: ['title', 'description'],
|
|
864
|
-
}],
|
|
865
|
-
embedFn: async ({ text }) =>
|
|
866
|
-
openai.embeddings.create({ input: text, model: 'text-embedding-3-small' })
|
|
867
|
-
.then(r => r.data[0].embedding),
|
|
868
|
-
autoEmbed: true,
|
|
869
|
-
onEmbedError: (err) => console.warn('Embed failed:', err.message),
|
|
870
|
-
}),
|
|
871
|
-
]);
|
|
872
|
-
|
|
873
|
-
// Search by text (auto-embeds the query)
|
|
874
|
-
const results = await repo.searchSimilar({ query: 'running shoes', limit: 10 });
|
|
875
|
-
|
|
876
|
-
// Search by vector directly
|
|
877
|
-
const results = await repo.searchSimilar({ query: [0.1, 0.2, ...], limit: 5 });
|
|
878
|
-
|
|
879
|
-
// Embed manually
|
|
880
|
-
const vector = await repo.embed('some text');
|
|
881
|
-
```
|
|
176
|
+
const store = new MongoOutboxStore({ connection: mongoose.connection, name: 'outbox' });
|
|
177
|
+
const transport = new RedisEventTransport({ url: process.env.REDIS_URL });
|
|
882
178
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
keeps mongokit core free of backend-specific dependencies, and the same
|
|
889
|
-
plugin works behind any HTTP framework (Arc, Express, Nest, raw handlers)
|
|
890
|
-
without further wiring.
|
|
891
|
-
|
|
892
|
-
**The contract** (4 lines):
|
|
893
|
-
|
|
894
|
-
1. Hook `before:getAll`.
|
|
895
|
-
2. If `ctx.search` is set, resolve it against your backend (returns IDs).
|
|
896
|
-
3. Mutate `ctx.filters` to constrain the Mongo query: `_id: { $in: ids }`.
|
|
897
|
-
4. Set `ctx.search = undefined` so Repository's text-index check is bypassed.
|
|
898
|
-
|
|
899
|
-
```javascript
|
|
900
|
-
function meilisearchPlugin({ client, index }) {
|
|
901
|
-
return (repo) => {
|
|
902
|
-
repo.on("before:getAll", async (ctx) => {
|
|
903
|
-
if (!ctx.search) return;
|
|
904
|
-
const hits = await client.index(index).search(ctx.search, {
|
|
905
|
-
limit: ctx.limit ?? 20,
|
|
906
|
-
});
|
|
907
|
-
const ids = hits.hits.map((h) => h.id);
|
|
908
|
-
ctx.filters = { ...(ctx.filters ?? {}), _id: { $in: ids } };
|
|
909
|
-
ctx.search = undefined; // bypass text-index check
|
|
910
|
-
ctx._meiliRanking = ids; // stash for after:getAll re-sort
|
|
911
|
-
});
|
|
912
|
-
repo.on("after:getAll", (ctx) => {
|
|
913
|
-
if (!ctx._meiliRanking) return;
|
|
914
|
-
const order = new Map(ctx._meiliRanking.map((id, i) => [String(id), i]));
|
|
915
|
-
ctx.result.docs.sort(
|
|
916
|
-
(a, b) => order.get(String(a._id)) - order.get(String(b._id)),
|
|
917
|
-
);
|
|
918
|
-
});
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// Wire at Repository construction — works in Arc, Express, Nest, anywhere
|
|
923
|
-
const repo = new Repository(ProductModel, [
|
|
924
|
-
meilisearchPlugin({ client: meili, index: "products" }),
|
|
925
|
-
cachePlugin({ adapter: createMemoryCache(), ttl: 60 }), // composes cleanly
|
|
926
|
-
]);
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
**Why this contract works:**
|
|
930
|
-
|
|
931
|
-
- `before:getAll` runs inside `_buildContext`, _before_ Repository reads `search` or hits the text-index check. A plugin that clears `ctx.search` short-circuits the built-in search path entirely.
|
|
932
|
-
- `Repository.getAll` reads `search` only from the post-hook context (no fallback to original params) — this is the framework-level guarantee that makes `ctx.search = undefined` a real clear, not a silently overridden no-op.
|
|
933
|
-
- The caller's existing filters are preserved by spreading: `ctx.filters = { ...(ctx.filters ?? {}), _id: ... }`. Multi-tenant scoping (which also runs in `before:getAll`), soft-delete filters, and the search plugin compose without ordering concerns — they all mutate `ctx.filters` independently and all survive.
|
|
934
|
-
- Cache plugin composes too: it sees the post-hook context (filters with the resolved ID list), so cache keys are accurate. ES/Meili results get cached just like Mongo results.
|
|
935
|
-
- The same plugin shape works for every backend; only the resolver call changes.
|
|
936
|
-
|
|
937
|
-
The bundled `elasticSearchPlugin` (next section) is the canonical reference implementation, and `tests/repository-search-mode.test.ts` exercises this contract end-to-end with multi-tenant + soft-delete + cache composition.
|
|
938
|
-
|
|
939
|
-
### Elasticsearch / OpenSearch Plugin
|
|
940
|
-
|
|
941
|
-
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.
|
|
942
|
-
|
|
943
|
-
**Architecture:** Query ES/OpenSearch → get IDs + relevance scores → fetch full docs from MongoDB → return in ES ranking order.
|
|
944
|
-
|
|
945
|
-
```typescript
|
|
946
|
-
import {
|
|
947
|
-
Repository,
|
|
948
|
-
methodRegistryPlugin,
|
|
949
|
-
elasticSearchPlugin,
|
|
950
|
-
} from "@classytic/mongokit";
|
|
951
|
-
import { Client } from "@elastic/elasticsearch"; // or '@opensearch-project/opensearch'
|
|
952
|
-
|
|
953
|
-
const esClient = new Client({ node: "http://localhost:9200" });
|
|
954
|
-
|
|
955
|
-
const productRepo = new Repository(ProductModel, [
|
|
956
|
-
methodRegistryPlugin(), // Required first
|
|
957
|
-
elasticSearchPlugin({
|
|
958
|
-
client: esClient,
|
|
959
|
-
index: "products",
|
|
960
|
-
idField: "_id", // field in ES doc that maps to MongoDB _id
|
|
961
|
-
}),
|
|
962
|
-
]);
|
|
963
|
-
|
|
964
|
-
// Perform semantic/full-text search
|
|
965
|
-
const results = await productRepo.search(
|
|
966
|
-
{ match: { description: "wireless headphones" } },
|
|
967
|
-
{
|
|
968
|
-
limit: 20, // capped to 1000 max (safety bound)
|
|
969
|
-
from: 0,
|
|
970
|
-
mongoOptions: {
|
|
971
|
-
select: "name price description",
|
|
972
|
-
lean: true,
|
|
973
|
-
},
|
|
179
|
+
wireOutbox({
|
|
180
|
+
repos: {
|
|
181
|
+
'catalog:product': catalog.repositories.product,
|
|
182
|
+
'revenue:transaction': revenue.repositories.transaction,
|
|
183
|
+
'order:order': order.repositories.order,
|
|
974
184
|
},
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
// results.total - total hits count from ES
|
|
980
|
-
```
|
|
981
|
-
|
|
982
|
-
**Why this exists:**
|
|
983
|
-
|
|
984
|
-
- `$text` in MongoDB requires a text index and is not scalable for fuzzy/semantic search
|
|
985
|
-
- ES/OpenSearch provides BM25, vector search, semantic search, analyzers, facets
|
|
986
|
-
- This plugin bridges both: ES rank + MongoDB's transactional documents
|
|
987
|
-
|
|
988
|
-
**Bounds enforcement:**
|
|
989
|
-
|
|
990
|
-
- `limit` is clamped to `[1, 1000]` — prevents runaway ES queries
|
|
991
|
-
- `from` is clamped to `>= 0` — prevents negative offsets
|
|
992
|
-
- Returns `{ docs: [], total: 0 }` immediately if ES returns no hits
|
|
993
|
-
|
|
994
|
-
### Logging
|
|
995
|
-
|
|
996
|
-
```javascript
|
|
997
|
-
import { configureLogger } from "@classytic/mongokit";
|
|
998
|
-
|
|
999
|
-
// Silence all internal warnings
|
|
1000
|
-
configureLogger(false);
|
|
1001
|
-
|
|
1002
|
-
// Custom logger
|
|
1003
|
-
configureLogger({
|
|
1004
|
-
warn: (msg, ...args) => myLogger.warn(msg, ...args),
|
|
1005
|
-
debug: (msg, ...args) => myLogger.debug(msg, ...args),
|
|
185
|
+
store,
|
|
186
|
+
// optional: skip internal audit repos, add tenant/correlation meta, etc.
|
|
187
|
+
shouldEnqueue: ({ resource }) => resource !== 'audit:log',
|
|
188
|
+
enrichMeta: (ctx) => ({ correlationId: ctx.correlationId as string | undefined }),
|
|
1006
189
|
});
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
### MongoDB Operations Plugin
|
|
1010
|
-
|
|
1011
|
-
The `mongoOperationsPlugin` adds MongoDB-specific atomic operations like `increment`, `upsert`, `pushToArray`, etc.
|
|
1012
|
-
|
|
1013
|
-
#### Basic Usage (No TypeScript Autocomplete)
|
|
1014
|
-
|
|
1015
|
-
```javascript
|
|
1016
|
-
import {
|
|
1017
|
-
Repository,
|
|
1018
|
-
methodRegistryPlugin,
|
|
1019
|
-
mongoOperationsPlugin,
|
|
1020
|
-
} from "@classytic/mongokit";
|
|
1021
|
-
|
|
1022
|
-
const repo = new Repository(ProductModel, [
|
|
1023
|
-
methodRegistryPlugin(), // Required first
|
|
1024
|
-
mongoOperationsPlugin(),
|
|
1025
|
-
]);
|
|
1026
190
|
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
await
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
import {
|
|
1038
|
-
Repository,
|
|
1039
|
-
methodRegistryPlugin,
|
|
1040
|
-
mongoOperationsPlugin,
|
|
1041
|
-
} from "@classytic/mongokit";
|
|
1042
|
-
import type { MongoOperationsMethods } from "@classytic/mongokit";
|
|
1043
|
-
|
|
1044
|
-
// 1. Create your repository class
|
|
1045
|
-
class ProductRepo extends Repository<IProduct> {
|
|
1046
|
-
// Add custom methods here
|
|
1047
|
-
async findBySku(sku: string) {
|
|
1048
|
-
return this.getByQuery({ sku });
|
|
191
|
+
// relay worker — runs on app startup
|
|
192
|
+
setInterval(async () => {
|
|
193
|
+
const pending = await store.getPending(100);
|
|
194
|
+
for (const event of pending) {
|
|
195
|
+
try {
|
|
196
|
+
await transport.publish(event);
|
|
197
|
+
await store.acknowledge(event.meta.id);
|
|
198
|
+
} catch {
|
|
199
|
+
break; // retry next tick
|
|
200
|
+
}
|
|
1049
201
|
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
// 2. Create type helper for autocomplete
|
|
1053
|
-
type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
1054
|
-
|
|
1055
|
-
// 3. Instantiate with type assertion
|
|
1056
|
-
const repo = new ProductRepo(ProductModel, [
|
|
1057
|
-
methodRegistryPlugin(),
|
|
1058
|
-
mongoOperationsPlugin(),
|
|
1059
|
-
]) as ProductRepoWithPlugins;
|
|
1060
|
-
|
|
1061
|
-
// 4. Now TypeScript provides full autocomplete and type checking!
|
|
1062
|
-
await repo.increment(productId, "views", 1); // ✅ Autocomplete works
|
|
1063
|
-
await repo.upsert({ sku: "ABC" }, { name: "Product" }); // ✅ Type-safe
|
|
1064
|
-
await repo.pushToArray(productId, "tags", "featured"); // ✅ Validated
|
|
1065
|
-
await repo.findBySku("ABC"); // ✅ Custom methods too
|
|
202
|
+
}, 1000);
|
|
1066
203
|
```
|
|
1067
204
|
|
|
1068
|
-
|
|
205
|
+
That's the entire outbox. No plugin, no custom base class, no opinions baked into the data layer. The host decides:
|
|
1069
206
|
|
|
1070
|
-
-
|
|
1071
|
-
-
|
|
1072
|
-
-
|
|
1073
|
-
-
|
|
1074
|
-
-
|
|
1075
|
-
- `addToSet(id, field, value, opts)` - Add unique value to array
|
|
1076
|
-
- `setField(id, field, value, opts)` - Set field value
|
|
1077
|
-
- `unsetField(id, fields, opts)` - Remove field(s)
|
|
1078
|
-
- `renameField(id, oldName, newName, opts)` - Rename field
|
|
1079
|
-
- `multiplyField(id, field, multiplier, opts)` - Multiply numeric field
|
|
1080
|
-
- `setMin(id, field, value, opts)` - Set to min (if current > value)
|
|
1081
|
-
- `setMax(id, field, value, opts)` - Set to max (if current < value)
|
|
207
|
+
- Which repos emit events (`wireOutbox.repos`)
|
|
208
|
+
- What the event type naming convention is (map keys → `${resource}.created`)
|
|
209
|
+
- Which transport to publish to (any `EventTransport` implementation)
|
|
210
|
+
- When the relay runs (`setInterval`, BullMQ, scheduled worker…)
|
|
211
|
+
- How to enrich meta (AsyncLocalStorage, request ctx, tenant keys)
|
|
1082
212
|
|
|
1083
|
-
|
|
213
|
+
mongokit's job stops at "the hook has `context.session`." Everything else is composition.
|
|
1084
214
|
|
|
1085
|
-
|
|
215
|
+
---
|
|
1086
216
|
|
|
1087
|
-
|
|
1088
|
-
import type { WithPlugins } from "@classytic/mongokit";
|
|
217
|
+
## Built-in plugins
|
|
1089
218
|
|
|
1090
|
-
|
|
219
|
+
| Plugin | Purpose |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `methodRegistryPlugin` | Prerequisite for plugins that attach new methods |
|
|
222
|
+
| `batchOperationsPlugin` | `updateMany`, `deleteMany`, `bulkWrite` with hook support |
|
|
223
|
+
| `multiTenantPlugin` | Inject tenant scope at POLICY priority — supports `fieldType: 'objectId'` for `$lookup`/`.populate()` |
|
|
224
|
+
| `softDeletePlugin` | `deletedAt` / custom field, `restore`, `getDeleted`, TTL, `before:restore` / `after:restore` hooks |
|
|
225
|
+
| `cascadePlugin` | Cascade delete; prefer `{ repo: targetRepo, foreignKey, softDelete? }` to route through the target's hook pipeline |
|
|
226
|
+
| `customIdPlugin` | Stripe-style prefixed public IDs (`txn_a7b3xk9m`) on top of `_id` |
|
|
227
|
+
| `cachePlugin` | Pluggable adapter (memory, Redis); list-cache versioning for multi-pod correctness |
|
|
228
|
+
| `auditLogPlugin` / `auditTrailPlugin` | Who/when/what capture to a sibling collection |
|
|
229
|
+
| `observabilityPlugin` | Metric hooks for OpenTelemetry / Prometheus bridges |
|
|
230
|
+
| `timestampPlugin` | `createdAt` / `updatedAt` management |
|
|
231
|
+
| `validationChainPlugin` | Layered sync + async validators |
|
|
232
|
+
| `fieldFilterPlugin` | Role-based field visibility on reads |
|
|
233
|
+
| `subdocumentPlugin` | Helpers for nested array document CRUD |
|
|
234
|
+
| `mongoOperationsPlugin` | Low-level Mongo helpers (`$inc`, `$push`, etc.) with hooks |
|
|
235
|
+
| `aggregateHelpersPlugin` | Common aggregation builders |
|
|
236
|
+
| `elasticPlugin` | Mirror writes to Elasticsearch |
|
|
1091
237
|
|
|
1092
|
-
|
|
1093
|
-
methodRegistryPlugin(),
|
|
1094
|
-
mongoOperationsPlugin(),
|
|
1095
|
-
softDeletePlugin(),
|
|
1096
|
-
]) as WithPlugins<IUser, UserRepo>;
|
|
1097
|
-
|
|
1098
|
-
// Full TypeScript autocomplete — field names inferred from IUser!
|
|
1099
|
-
await repo.increment(id, "age", 1); // ✅ "age" autocompleted from IUser
|
|
1100
|
-
await repo.groupBy("status"); // ✅ "status" autocompleted
|
|
1101
|
-
await repo.restore(id); // ✅ Returns Promise<IUser>
|
|
1102
|
-
await repo.getDeleted(); // ✅ Returns OffsetPaginationResult<IUser>
|
|
1103
|
-
await repo.invalidateCache(id);
|
|
1104
|
-
```
|
|
1105
|
-
|
|
1106
|
-
Field params use `DocField<TDoc>` — autocomplete for known fields, still accepts arbitrary strings for nested paths like `'address.city'`.
|
|
1107
|
-
|
|
1108
|
-
**Individual plugin types:** `MongoOperationsMethods<T>`, `BatchOperationsMethods`, `AggregateHelpersMethods`, `SubdocumentMethods<T>`, `SoftDeleteMethods<T>`, `CacheMethods`, `AuditTrailMethods`
|
|
1109
|
-
|
|
1110
|
-
## Event System
|
|
1111
|
-
|
|
1112
|
-
Event names are typed as `RepositoryEvent` — full autocomplete in TypeScript:
|
|
1113
|
-
|
|
1114
|
-
```typescript
|
|
1115
|
-
// before:* receives context directly — mutate in-place
|
|
1116
|
-
repo.on("before:create", async (context) => {
|
|
1117
|
-
context.data.processedAt = new Date();
|
|
1118
|
-
});
|
|
1119
|
-
|
|
1120
|
-
// after:* receives { context, result }
|
|
1121
|
-
repo.on("after:create", ({ context, result }) => {
|
|
1122
|
-
console.log("Created:", result);
|
|
1123
|
-
});
|
|
1124
|
-
|
|
1125
|
-
// error:* receives { context, error }
|
|
1126
|
-
repo.on("error:create", ({ context, error }) => {
|
|
1127
|
-
console.error("Failed:", error);
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
// Remove a listener
|
|
1131
|
-
repo.off("after:create", myListener);
|
|
1132
|
-
```
|
|
1133
|
-
|
|
1134
|
-
**Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `deleteMany`, `updateMany`, `getById`, `getByQuery`, `getAll`, `aggregate`, `aggregatePaginate`, `lookupPopulate`, `getOrCreate`, `count`, `exists`, `distinct`, `bulkWrite`
|
|
1135
|
-
|
|
1136
|
-
### Microservice Integration (Kafka / RabbitMQ / Redis Pub-Sub)
|
|
1137
|
-
|
|
1138
|
-
Use `after:*` hooks to publish events to message brokers — zero additional libraries needed:
|
|
1139
|
-
|
|
1140
|
-
```typescript
|
|
1141
|
-
import { HOOK_PRIORITY } from "@classytic/mongokit";
|
|
1142
|
-
|
|
1143
|
-
// Publish to Kafka after every create
|
|
1144
|
-
repo.on("after:create", async ({ context, result }) => {
|
|
1145
|
-
await kafka.publish("orders.created", {
|
|
1146
|
-
operation: context.operation,
|
|
1147
|
-
model: context.model,
|
|
1148
|
-
document: result,
|
|
1149
|
-
userId: context.user?._id,
|
|
1150
|
-
tenantId: context.organizationId,
|
|
1151
|
-
timestamp: Date.now(),
|
|
1152
|
-
});
|
|
1153
|
-
}, { priority: HOOK_PRIORITY.OBSERVABILITY });
|
|
1154
|
-
|
|
1155
|
-
// Redis Pub-Sub on updates
|
|
1156
|
-
repo.on("after:update", async ({ context, result }) => {
|
|
1157
|
-
await redis.publish("order:updated", JSON.stringify({
|
|
1158
|
-
id: result._id,
|
|
1159
|
-
changes: context.data,
|
|
1160
|
-
}));
|
|
1161
|
-
}, { priority: HOOK_PRIORITY.OBSERVABILITY });
|
|
1162
|
-
|
|
1163
|
-
// RabbitMQ on deletes (including soft-deletes)
|
|
1164
|
-
repo.on("after:delete", async ({ context, result }) => {
|
|
1165
|
-
await rabbitMQ.sendToQueue("order.deleted", {
|
|
1166
|
-
id: result.id,
|
|
1167
|
-
soft: result.soft,
|
|
1168
|
-
tenantId: context.organizationId,
|
|
1169
|
-
});
|
|
1170
|
-
}, { priority: HOOK_PRIORITY.OBSERVABILITY });
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
**Hook priority order:** `POLICY (100)` → `CACHE (200)` → `OBSERVABILITY (300)` → `DEFAULT (500)`. Event publishing at `OBSERVABILITY` ensures it runs after policy enforcement and cache invalidation.
|
|
1174
|
-
|
|
1175
|
-
## Building REST APIs
|
|
1176
|
-
|
|
1177
|
-
MongoKit provides a complete toolkit for building REST APIs: QueryParser for request handling, JSON Schema generation for validation/docs, and IController interface for framework-agnostic controllers.
|
|
1178
|
-
|
|
1179
|
-
### IController Interface
|
|
1180
|
-
|
|
1181
|
-
Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
|
|
1182
|
-
|
|
1183
|
-
```typescript
|
|
1184
|
-
import type {
|
|
1185
|
-
IController,
|
|
1186
|
-
IRequestContext,
|
|
1187
|
-
IControllerResponse,
|
|
1188
|
-
} from "@classytic/mongokit";
|
|
1189
|
-
|
|
1190
|
-
// IRequestContext - what your controller receives
|
|
1191
|
-
interface IRequestContext {
|
|
1192
|
-
query: Record<string, unknown>; // URL query params
|
|
1193
|
-
body: Record<string, unknown>; // Request body
|
|
1194
|
-
params: Record<string, string>; // Route params (:id)
|
|
1195
|
-
user?: { id: string; role?: string }; // Auth user
|
|
1196
|
-
context?: Record<string, unknown>; // Tenant ID, etc.
|
|
1197
|
-
}
|
|
238
|
+
Each plugin is tree-shakeable. Import only what you use.
|
|
1198
239
|
|
|
1199
|
-
|
|
1200
|
-
interface IControllerResponse<T> {
|
|
1201
|
-
success: boolean;
|
|
1202
|
-
data?: T;
|
|
1203
|
-
error?: string;
|
|
1204
|
-
status: number;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// IController - implement this interface
|
|
1208
|
-
interface IController<TDoc> {
|
|
1209
|
-
list(
|
|
1210
|
-
ctx: IRequestContext,
|
|
1211
|
-
): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
1212
|
-
get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1213
|
-
create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1214
|
-
update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1215
|
-
delete(
|
|
1216
|
-
ctx: IRequestContext,
|
|
1217
|
-
): Promise<IControllerResponse<{ message: string }>>;
|
|
1218
|
-
}
|
|
1219
|
-
```
|
|
1220
|
-
|
|
1221
|
-
### QueryParser
|
|
1222
|
-
|
|
1223
|
-
Converts HTTP query strings to MongoDB queries with built-in security:
|
|
240
|
+
---
|
|
1224
241
|
|
|
1225
|
-
|
|
1226
|
-
import { QueryParser } from "@classytic/mongokit";
|
|
242
|
+
## QueryParser (URL → filter)
|
|
1227
243
|
|
|
1228
|
-
|
|
1229
|
-
schema: ProductModel.schema, // Schema-aware coercion (recommended)
|
|
1230
|
-
maxLimit: 100, // Prevent excessive queries
|
|
1231
|
-
maxFilterDepth: 5, // Prevent nested injection
|
|
1232
|
-
maxRegexLength: 100, // ReDoS protection
|
|
1233
|
-
allowedFilterFields: ["status", "name", "email"], // Whitelist filter fields
|
|
1234
|
-
allowedSortFields: ["createdAt", "name"], // Whitelist sort fields
|
|
1235
|
-
allowedOperators: ["eq", "ne", "in", "gt", "lt"], // Whitelist operators
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
// Parse request query
|
|
1239
|
-
const { filters, limit, page, sort, search } = parser.parse(req.query);
|
|
244
|
+
Turn Express / Fastify query strings into sanitized Mongo filters + sort + pagination.
|
|
1240
245
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
parser.allowedSortFields; // ['createdAt', 'name'] or undefined
|
|
1244
|
-
parser.allowedOperators; // ['eq', 'ne', 'in', 'gt', 'lt'] or undefined
|
|
1245
|
-
```
|
|
246
|
+
```ts
|
|
247
|
+
import { QueryParser } from '@classytic/mongokit';
|
|
1246
248
|
|
|
1247
|
-
**Schema-aware value coercion (recommended):** Pass `schema: Model.schema` and
|
|
1248
|
-
the parser coerces filter values exactly to each field's declared type. With
|
|
1249
|
-
schema configured, `?stock=50` against a `Number` field becomes `50` (number),
|
|
1250
|
-
`?name=12345` against a `String` field stays `'12345'`, `?releasedAt=2026-04-07`
|
|
1251
|
-
against a `Date` field becomes a `Date` instance, `?address.zip=01234` against
|
|
1252
|
-
a nested `String` field preserves the leading zero. Direct equality and
|
|
1253
|
-
operator syntax (`stock[gte]=50`) coerce identically — no more "why does
|
|
1254
|
-
operator work but direct equality doesn't" surprises. Unknown fields fall
|
|
1255
|
-
through to a safe heuristic so ad-hoc filters still work.
|
|
1256
|
-
|
|
1257
|
-
For DB-agnostic setups (raw MongoDB, Prisma, computed fields, models you
|
|
1258
|
-
don't own), use `fieldTypes` instead of `schema`. The two can be combined —
|
|
1259
|
-
`fieldTypes` overrides `schema` per path:
|
|
1260
|
-
|
|
1261
|
-
```typescript
|
|
1262
249
|
const parser = new QueryParser({
|
|
1263
|
-
schema:
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
},
|
|
250
|
+
schema: InvoiceModel.schema, // enables type coercion + geo/text detection
|
|
251
|
+
allowedFilterFields: ['status', 'total', 'customerId'],
|
|
252
|
+
allowedSortFields: ['createdAt', 'total'],
|
|
253
|
+
searchMode: 'auto', // 'text' | 'regex' | 'auto'
|
|
1268
254
|
});
|
|
1269
|
-
```
|
|
1270
255
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
safe but inherently lossy — schema-aware mode is strictly better.
|
|
1275
|
-
|
|
1276
|
-
**Supported query patterns:**
|
|
1277
|
-
|
|
1278
|
-
```bash
|
|
1279
|
-
# Filtering
|
|
1280
|
-
GET /users?status=active&role=admin
|
|
1281
|
-
GET /users?age[gte]=18&age[lte]=65
|
|
1282
|
-
GET /users?role[in]=admin,user
|
|
1283
|
-
GET /users?email[contains]=@gmail.com
|
|
1284
|
-
GET /users?name[regex]=^John
|
|
1285
|
-
|
|
1286
|
-
# Pagination
|
|
1287
|
-
GET /users?page=2&limit=50
|
|
1288
|
-
GET /users?after=eyJfaWQiOi...&limit=20 # Cursor-based
|
|
1289
|
-
|
|
1290
|
-
# Sorting
|
|
1291
|
-
GET /users?sort=-createdAt,name
|
|
1292
|
-
|
|
1293
|
-
# Search (default: text index; or configure searchMode: 'regex' / 'auto')
|
|
1294
|
-
GET /users?search=john
|
|
1295
|
-
|
|
1296
|
-
# Geo queries (requires 2dsphere index for near/nearSphere)
|
|
1297
|
-
GET /places?location[near]=-73.98,40.76,5000 # Within 5km, distance-sorted
|
|
1298
|
-
GET /places?location[nearSphere]=-73.98,40.76,5000 # Spherical variant
|
|
1299
|
-
GET /places?location[withinRadius]=-73.98,40.76,5000 # Count-compatible radius (use for paginated lists)
|
|
1300
|
-
GET /places?location[geoWithin]=-74,40.7,-73.9,40.8 # Bounding box (no index required)
|
|
1301
|
-
|
|
1302
|
-
# Simple populate
|
|
1303
|
-
GET /posts?populate=author,category
|
|
1304
|
-
|
|
1305
|
-
# Advanced populate with options
|
|
1306
|
-
GET /posts?populate[author][select]=name,email
|
|
1307
|
-
GET /posts?populate[author][match][active]=true
|
|
1308
|
-
GET /posts?populate[comments][limit]=10
|
|
1309
|
-
GET /posts?populate[comments][sort]=-createdAt
|
|
1310
|
-
GET /posts?populate[author][populate][department][select]=name # Nested
|
|
256
|
+
// GET /invoices?status=paid&total_gte=1000&sort=-createdAt&page=1&limit=20
|
|
257
|
+
const { filters, sort, page, limit, search } = parser.parse(req.query);
|
|
258
|
+
const result = await invoiceRepo.getAll({ filters, sort, page, limit, search });
|
|
1311
259
|
```
|
|
1312
260
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
- Blocks `$where`, `$function`, `$accumulator` operators (`$expr` allowed for `$lookup` correlation)
|
|
1316
|
-
- ReDoS protection for regex patterns
|
|
1317
|
-
- Max filter depth enforcement
|
|
1318
|
-
- Field allowlists for filters and sorting (`allowedFilterFields`, `allowedSortFields`)
|
|
1319
|
-
- Operator allowlists (`allowedOperators`)
|
|
1320
|
-
- Collection allowlists for lookups
|
|
1321
|
-
- Populate path sanitization (blocks `$where`, `__proto__`, etc.)
|
|
1322
|
-
- Max populate depth limit (default: 5)
|
|
1323
|
-
|
|
1324
|
-
### Advanced Populate Options
|
|
1325
|
-
|
|
1326
|
-
QueryParser supports Mongoose populate options via URL query parameters:
|
|
1327
|
-
|
|
1328
|
-
```typescript
|
|
1329
|
-
import { QueryParser } from "@classytic/mongokit";
|
|
1330
|
-
|
|
1331
|
-
const parser = new QueryParser();
|
|
1332
|
-
|
|
1333
|
-
// Parse URL: /posts?populate[author][select]=name,email&populate[author][match][active]=true
|
|
1334
|
-
const parsed = parser.parse(req.query);
|
|
1335
|
-
|
|
1336
|
-
// Use with Repository
|
|
1337
|
-
const posts = await postRepo.getAll(
|
|
1338
|
-
{ filters: parsed.filters, page: parsed.page, limit: parsed.limit },
|
|
1339
|
-
{ populateOptions: parsed.populateOptions },
|
|
1340
|
-
);
|
|
1341
|
-
```
|
|
261
|
+
Supports URL operators: `_gt/_gte/_lt/_lte/_ne/_in/_nin/_regex`, geo (`[near]`, `[withinRadius]`, `[geoWithin]`), populate, and schema-aware coercion. ReDoS protection + allowlisted operators for hardening.
|
|
1342
262
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
| Option | URL Syntax | Description |
|
|
1346
|
-
| ---------- | ------------------------------------------------ | ----------------------------------------------- |
|
|
1347
|
-
| `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
|
|
1348
|
-
| `match` | `populate[path][match][field]=value` | Filter populated documents |
|
|
1349
|
-
| `limit` | `populate[path][limit]=10` | Limit number of populated docs |
|
|
1350
|
-
| `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
|
|
1351
|
-
| `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
|
|
1352
|
-
|
|
1353
|
-
**Example - Complex populate:**
|
|
1354
|
-
|
|
1355
|
-
```typescript
|
|
1356
|
-
// URL: /posts?populate[author][select]=name,avatar&populate[comments][limit]=5&populate[comments][sort]=-createdAt&populate[comments][match][approved]=true
|
|
1357
|
-
|
|
1358
|
-
const parsed = parser.parse(req.query);
|
|
1359
|
-
// parsed.populateOptions = [
|
|
1360
|
-
// { path: 'author', select: 'name avatar' },
|
|
1361
|
-
// { path: 'comments', match: { approved: true }, options: { limit: 5, sort: { createdAt: -1 } } }
|
|
1362
|
-
// ]
|
|
1363
|
-
|
|
1364
|
-
// Simple string populate still works
|
|
1365
|
-
// URL: /posts?populate=author,category
|
|
1366
|
-
// parsed.populate = 'author,category'
|
|
1367
|
-
// parsed.populateOptions = undefined
|
|
1368
|
-
```
|
|
1369
|
-
|
|
1370
|
-
### JSON Schema Generation
|
|
1371
|
-
|
|
1372
|
-
Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
|
|
1373
|
-
|
|
1374
|
-
```typescript
|
|
1375
|
-
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
1376
|
-
|
|
1377
|
-
const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
|
|
1378
|
-
fieldRules: {
|
|
1379
|
-
organizationId: { immutable: true }, // Can't update after create
|
|
1380
|
-
role: { systemManaged: true }, // Users can't set this
|
|
1381
|
-
createdAt: { systemManaged: true },
|
|
1382
|
-
},
|
|
1383
|
-
strictAdditionalProperties: true, // Reject unknown fields
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
// Generated schemas:
|
|
1387
|
-
// crudSchemas.createBody - POST body validation
|
|
1388
|
-
// crudSchemas.updateBody - PATCH body validation
|
|
1389
|
-
// crudSchemas.params - Route params (:id)
|
|
1390
|
-
// crudSchemas.listQuery - GET query validation
|
|
1391
|
-
```
|
|
1392
|
-
|
|
1393
|
-
#### Soft-required fields (draft-friendly bodies)
|
|
1394
|
-
|
|
1395
|
-
Some resources have fields that must be non-null in the DB but should be
|
|
1396
|
-
**optional in the HTTP body** — draft journal entries, multi-step wizards,
|
|
1397
|
-
or any state-machine resource that's filled out progressively. Marking a
|
|
1398
|
-
path `softRequired: true` keeps the Mongoose `required: true` invariant
|
|
1399
|
-
(null on save is still rejected) while excluding the field from the
|
|
1400
|
-
generated `createBody.required[]` array.
|
|
1401
|
-
|
|
1402
|
-
```typescript
|
|
1403
|
-
// 1. Per-path flag (when you own the Mongoose schema)
|
|
1404
|
-
const JournalEntrySchema = new Schema({
|
|
1405
|
-
label: { type: String, required: true }, // hard required
|
|
1406
|
-
journalType: {
|
|
1407
|
-
type: String,
|
|
1408
|
-
required: true, // DB invariant — null rejected on save
|
|
1409
|
-
softRequired: true, // HTTP body — optional in create/update
|
|
1410
|
-
enum: ['sale', 'purchase', 'adjustment'],
|
|
1411
|
-
},
|
|
1412
|
-
date: { type: Date, required: true, softRequired: true },
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
// 2. Per-build override (when the Mongoose model lives in an upstream package)
|
|
1416
|
-
const { createBody } = buildCrudSchemasFromModel(UpstreamModel, {
|
|
1417
|
-
softRequiredFields: ['journalType', 'date'],
|
|
1418
|
-
});
|
|
1419
|
-
// createBody.required → ['label'] (journalType + date excluded)
|
|
1420
|
-
// createBody.properties → still includes journalType + date (validated when present)
|
|
1421
|
-
```
|
|
1422
|
-
|
|
1423
|
-
The DB-level `required: true` is unaffected — `repo.create()` with `null`
|
|
1424
|
-
for a soft-required field still throws a Mongoose ValidationError. The
|
|
1425
|
-
flag only changes what HTTP clients must send.
|
|
1426
|
-
|
|
1427
|
-
### Complete Controller Example
|
|
1428
|
-
|
|
1429
|
-
```typescript
|
|
1430
|
-
import {
|
|
1431
|
-
Repository,
|
|
1432
|
-
QueryParser,
|
|
1433
|
-
buildCrudSchemasFromModel,
|
|
1434
|
-
type IController,
|
|
1435
|
-
type IRequestContext,
|
|
1436
|
-
type IControllerResponse,
|
|
1437
|
-
} from "@classytic/mongokit";
|
|
1438
|
-
|
|
1439
|
-
class UserController implements IController<IUser> {
|
|
1440
|
-
private repo = new Repository(UserModel);
|
|
1441
|
-
private parser = new QueryParser({ maxLimit: 100 });
|
|
1442
|
-
|
|
1443
|
-
async list(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
1444
|
-
const { filters, limit, page, sort } = this.parser.parse(ctx.query);
|
|
1445
|
-
|
|
1446
|
-
// Inject tenant filter
|
|
1447
|
-
if (ctx.context?.organizationId) {
|
|
1448
|
-
filters.organizationId = ctx.context.organizationId;
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
const result = await this.repo.getAll({ filters, limit, page, sort });
|
|
1452
|
-
return { success: true, data: result, status: 200 };
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
async get(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
1456
|
-
const doc = await this.repo.getById(ctx.params.id);
|
|
1457
|
-
return { success: true, data: doc, status: 200 };
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
async create(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
1461
|
-
const doc = await this.repo.create(ctx.body);
|
|
1462
|
-
return { success: true, data: doc, status: 201 };
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
async update(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
1466
|
-
const doc = await this.repo.update(ctx.params.id, ctx.body);
|
|
1467
|
-
return { success: true, data: doc, status: 200 };
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
async delete(ctx: IRequestContext): Promise<IControllerResponse> {
|
|
1471
|
-
await this.repo.delete(ctx.params.id);
|
|
1472
|
-
return { success: true, data: { message: "Deleted" }, status: 200 };
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
```
|
|
1476
|
-
|
|
1477
|
-
### Fastify Integration
|
|
1478
|
-
|
|
1479
|
-
```typescript
|
|
1480
|
-
import { buildCrudSchemasFromModel } from "@classytic/mongokit";
|
|
1481
|
-
|
|
1482
|
-
const controller = new UserController();
|
|
1483
|
-
const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
|
|
1484
|
-
|
|
1485
|
-
// Routes with auto-validation and OpenAPI docs
|
|
1486
|
-
fastify.get(
|
|
1487
|
-
"/users",
|
|
1488
|
-
{ schema: { querystring: crudSchemas.listQuery } },
|
|
1489
|
-
async (req, reply) => {
|
|
1490
|
-
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
1491
|
-
const response = await controller.list(ctx);
|
|
1492
|
-
return reply.status(response.status).send(response);
|
|
1493
|
-
},
|
|
1494
|
-
);
|
|
1495
|
-
|
|
1496
|
-
fastify.post(
|
|
1497
|
-
"/users",
|
|
1498
|
-
{ schema: { body: crudSchemas.createBody } },
|
|
1499
|
-
async (req, reply) => {
|
|
1500
|
-
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
1501
|
-
const response = await controller.create(ctx);
|
|
1502
|
-
return reply.status(response.status).send(response);
|
|
1503
|
-
},
|
|
1504
|
-
);
|
|
1505
|
-
|
|
1506
|
-
fastify.get(
|
|
1507
|
-
"/users/:id",
|
|
1508
|
-
{ schema: { params: crudSchemas.params } },
|
|
1509
|
-
async (req, reply) => {
|
|
1510
|
-
const ctx = { query: {}, body: {}, params: req.params, user: req.user };
|
|
1511
|
-
const response = await controller.get(ctx);
|
|
1512
|
-
return reply.status(response.status).send(response);
|
|
1513
|
-
},
|
|
1514
|
-
);
|
|
1515
|
-
```
|
|
1516
|
-
|
|
1517
|
-
### Express Integration
|
|
1518
|
-
|
|
1519
|
-
```typescript
|
|
1520
|
-
const controller = new UserController();
|
|
1521
|
-
|
|
1522
|
-
app.get("/users", async (req, res) => {
|
|
1523
|
-
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
|
|
1524
|
-
const response = await controller.list(ctx);
|
|
1525
|
-
res.status(response.status).json(response);
|
|
1526
|
-
});
|
|
1527
|
-
|
|
1528
|
-
app.post("/users", async (req, res) => {
|
|
1529
|
-
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
|
|
1530
|
-
const response = await controller.create(ctx);
|
|
1531
|
-
res.status(response.status).json(response);
|
|
1532
|
-
});
|
|
1533
|
-
```
|
|
263
|
+
---
|
|
1534
264
|
|
|
1535
265
|
## TypeScript
|
|
1536
266
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
```typescript
|
|
1540
|
-
import { Repository } from "@classytic/mongokit";
|
|
1541
|
-
import type { CacheableOptions, ReadOptions } from "@classytic/mongokit";
|
|
1542
|
-
|
|
1543
|
-
const repo = new Repository(UserModel); // TDoc inferred from UserModel
|
|
267
|
+
Full type safety for repository methods, plugin method combinations, events, and query contexts.
|
|
1544
268
|
|
|
1545
|
-
|
|
1546
|
-
const user = await repo.getById("123"); // IUser | null
|
|
1547
|
-
const users = await repo.getAll({ page: 1 }); // OffsetPaginationResult<IUser> | KeysetPaginationResult<IUser>
|
|
1548
|
-
|
|
1549
|
-
// Discriminated union — TypeScript narrows the type
|
|
1550
|
-
if (users.method === "offset") {
|
|
1551
|
-
console.log(users.total, users.pages); // ✅ Available
|
|
1552
|
-
}
|
|
1553
|
-
if (users.method === "keyset") {
|
|
1554
|
-
console.log(users.next, users.hasMore); // ✅ Available
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Typed options — import and reuse
|
|
1558
|
-
const opts: CacheableOptions = { skipCache: true, lean: true };
|
|
1559
|
-
const fresh = await repo.getById("123", opts);
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
### Utility Types
|
|
1563
|
-
|
|
1564
|
-
```typescript
|
|
269
|
+
```ts
|
|
1565
270
|
import type {
|
|
1566
|
-
InferDocument, // Extract TDoc from Model: InferDocument<typeof UserModel>
|
|
1567
|
-
InferRawDoc, // TDoc without Mongoose Document methods
|
|
1568
|
-
CreateInput, // Omit<TDoc, '_id' | 'createdAt' | 'updatedAt' | '__v'>
|
|
1569
|
-
UpdateInput, // Partial<Omit<TDoc, '_id' | 'createdAt' | '__v'>>
|
|
1570
|
-
DocField, // (keyof TDoc & string) | (string & {}) — autocomplete + nested paths
|
|
1571
|
-
PartialBy, // Make specific fields optional
|
|
1572
|
-
RequiredBy, // Make specific fields required
|
|
1573
|
-
DeepPartial, // Recursive partial
|
|
1574
|
-
KeysOfType, // Extract keys by value type: KeysOfType<IUser, string>
|
|
1575
|
-
} from "@classytic/mongokit";
|
|
1576
|
-
```
|
|
1577
|
-
|
|
1578
|
-
## Extending Repository
|
|
1579
|
-
|
|
1580
|
-
Create custom repository classes with domain-specific methods:
|
|
1581
|
-
|
|
1582
|
-
```typescript
|
|
1583
|
-
import {
|
|
1584
271
|
Repository,
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
class UserRepository extends Repository<IUser> {
|
|
1591
|
-
constructor() {
|
|
1592
|
-
super(UserModel, [timestampPlugin(), softDeletePlugin()], {
|
|
1593
|
-
defaultLimit: 20,
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// Custom domain methods
|
|
1598
|
-
async findByEmail(email: string) {
|
|
1599
|
-
return this.getByQuery({ email });
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
async findActiveUsers() {
|
|
1603
|
-
return this.getAll({
|
|
1604
|
-
filters: { status: "active" },
|
|
1605
|
-
sort: { createdAt: -1 },
|
|
1606
|
-
});
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
async deactivate(id: string) {
|
|
1610
|
-
return this.update(id, { status: "inactive", deactivatedAt: new Date() });
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
// Usage
|
|
1615
|
-
const userRepo = new UserRepository();
|
|
1616
|
-
const user = await userRepo.findByEmail("john@example.com");
|
|
1617
|
-
```
|
|
1618
|
-
|
|
1619
|
-
### Overriding Methods
|
|
272
|
+
RepositoryContext,
|
|
273
|
+
RepositoryEvent,
|
|
274
|
+
SoftDeleteMethods,
|
|
275
|
+
BatchOperationsMethods,
|
|
276
|
+
} from '@classytic/mongokit';
|
|
1620
277
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
super(UserModel);
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
// Override create to add audit trail
|
|
1628
|
-
async create(data: Partial<IUser>, options = {}) {
|
|
1629
|
-
const result = await super.create(
|
|
1630
|
-
{
|
|
1631
|
-
...data,
|
|
1632
|
-
createdBy: getCurrentUserId(),
|
|
1633
|
-
},
|
|
1634
|
-
options,
|
|
1635
|
-
);
|
|
1636
|
-
|
|
1637
|
-
await auditLog("user.created", result._id);
|
|
1638
|
-
return result;
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
278
|
+
type InvoiceRepo = Repository<Invoice> &
|
|
279
|
+
SoftDeleteMethods<Invoice> &
|
|
280
|
+
BatchOperationsMethods;
|
|
1641
281
|
```
|
|
1642
282
|
|
|
1643
|
-
|
|
283
|
+
---
|
|
1644
284
|
|
|
1645
|
-
|
|
285
|
+
## Subpath imports
|
|
1646
286
|
|
|
1647
|
-
|
|
1648
|
-
import { createRepository, timestampPlugin } from "@classytic/mongokit";
|
|
287
|
+
Import pure primitives without pulling the full package surface:
|
|
1649
288
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
}
|
|
289
|
+
```ts
|
|
290
|
+
import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';
|
|
291
|
+
import { parseGeoFilter } from '@classytic/mongokit/query/primitives/geo';
|
|
292
|
+
import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion';
|
|
1653
293
|
```
|
|
1654
294
|
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
MongoKit translates MongoDB and Mongoose errors into HTTP-compatible errors with proper status codes:
|
|
295
|
+
---
|
|
1658
296
|
|
|
1659
|
-
|
|
1660
|
-
|---|---|---|
|
|
1661
|
-
| Duplicate key (E11000) | **409** | `Duplicate value for email (email: "dup@test.com")` |
|
|
1662
|
-
| Validation error | **400** | `Validation Error: name is required` |
|
|
1663
|
-
| Cast error | **400** | `Invalid _id: not-a-valid-id` |
|
|
1664
|
-
| Document not found | **404** | `Document not found` |
|
|
1665
|
-
| Other errors | **500** | `Internal Server Error` |
|
|
297
|
+
## Testing
|
|
1666
298
|
|
|
1667
|
-
|
|
1668
|
-
import { parseDuplicateKeyError } from "@classytic/mongokit";
|
|
299
|
+
Uses `mongodb-memory-server` by default; override via `MONGODB_URI` for a real replica set when running transaction tests locally.
|
|
1669
300
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
// dupErr.status === 409
|
|
1674
|
-
// dupErr.message includes field name and value
|
|
1675
|
-
}
|
|
301
|
+
```bash
|
|
302
|
+
npm test # full suite (~80 files, 1500+ tests)
|
|
303
|
+
npx vitest run tests/X.ts # single file while iterating
|
|
1676
304
|
```
|
|
1677
305
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
Use `idField` when your documents use a custom identifier (slug, UUID, code) instead of `_id`:
|
|
1681
|
-
|
|
1682
|
-
```typescript
|
|
1683
|
-
// Repo-level: all calls use slug
|
|
1684
|
-
const productRepo = new Repository(ProductModel, [], {}, { idField: 'slug' });
|
|
1685
|
-
await productRepo.getById('laptop'); // { slug: 'laptop' }
|
|
1686
|
-
await productRepo.update('laptop', { price: 999 }); // { slug: 'laptop' }
|
|
1687
|
-
await productRepo.delete('laptop'); // { slug: 'laptop' }
|
|
1688
|
-
|
|
1689
|
-
// Per-call override: same repo, different lookups
|
|
1690
|
-
const repo = new Repository(ChatModel);
|
|
1691
|
-
await repo.getById('507f1f77bcf86cd799439011'); // by _id
|
|
1692
|
-
await repo.getById('my-chat-uuid', { idField: 'chatId' }); // by chatId
|
|
1693
|
-
```
|
|
306
|
+
See [`tests/`](./tests) for real-world plugin composition examples (multi-tenant + soft-delete + cascade + audit).
|
|
1694
307
|
|
|
1695
|
-
|
|
308
|
+
---
|
|
1696
309
|
|
|
1697
|
-
## Mongoose
|
|
310
|
+
## Mongoose compatibility
|
|
1698
311
|
|
|
1699
|
-
|
|
312
|
+
Aligned with **Mongoose 9.4.x**. Earlier versions (8.x) are untested — pin a mongokit v3.x release if you need 8.x support.
|
|
1700
313
|
|
|
1701
|
-
|
|
1702
|
-
- Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
|
|
1703
|
-
- Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
|
|
1704
|
-
- Reads `embeddedSchemaType.instance` for array element types (Mongoose 9.x)
|
|
1705
|
-
- 1500+ tests pass on Mongoose 9.4.1
|
|
314
|
+
---
|
|
1706
315
|
|
|
1707
316
|
## License
|
|
1708
317
|
|