@classytic/mongokit 3.5.5 → 3.6.0

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