@classytic/mongokit 3.5.6 → 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.
package/README.md CHANGED
@@ -3,1706 +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
- - **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
- > Requires Mongoose `>=9.4.1` | Node.js `>=22`
12
+ Requires Mongoose `>=9.4.1`, Node.js `>=22`.
33
13
 
34
- ## Quick Start
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
- // Create
43
- const user = await userRepo.create({ name: "John", email: "john@example.com" });
16
+ ## Quick start
44
17
 
45
- // Read with auto-detected pagination
46
- const users = await userRepo.getAll({ page: 1, limit: 20 });
18
+ ```ts
19
+ import { Repository } from '@classytic/mongokit';
20
+ import UserModel from './models/User.js';
47
21
 
48
- // Update
49
- await userRepo.update(user._id, { name: "Jane" });
22
+ const userRepo = new Repository(UserModel);
50
23
 
51
- // 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' });
52
27
  await userRepo.delete(user._id);
53
28
  ```
54
29
 
55
- ## Pagination
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
- // EXPLICIT: Keyset pagination (recommended for feeds, infinite scroll)
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
- // Next page with cursor
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
- User input is regex-escaped (literal substring match), and any pre-existing
229
- `$or` filter is preserved by promoting both clauses to `$and`.
36
+ ### Repository
230
37
 
231
- > **QueryParser ↔ Repository:** `QueryParser` also exposes `searchMode`/`searchFields`
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
- ### Geo queries
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
- QueryParser supports 4 geo operators that compile to canonical GeoJSON-shaped
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
- ```javascript
245
- const parser = new QueryParser({ schema: PlaceModel.schema });
246
-
247
- // Distance-sorted (implicit distance ordering, no explicit sort allowed)
248
- const near = parser.parse({ "location[near]": "-73.98,40.76,5000" });
249
- // → { location: { $near: { $geometry: { type: 'Point', coordinates: [-73.98, 40.76] }, $maxDistance: 5000 } } }
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
- Each primitive is independently unit-tested, tree-shake friendly (no barrel),
318
- and decoupled from Mongoose — they accept structural types, not concrete
319
- Mongoose instances.
320
-
321
- ## Plugins
54
+ ### Plugins
322
55
 
323
- ### Using Plugins
56
+ Plugins register hooks and/or methods on a repository. Order matters — they run at declared priorities:
324
57
 
325
- ```javascript
58
+ ```ts
326
59
  import {
327
60
  Repository,
328
- timestampPlugin,
61
+ methodRegistryPlugin,
62
+ batchOperationsPlugin,
63
+ multiTenantPlugin,
329
64
  softDeletePlugin,
330
65
  cachePlugin,
331
- createMemoryCache,
332
- } from "@classytic/mongokit";
66
+ } from '@classytic/mongokit';
333
67
 
334
- const repo = new Repository(UserModel, [
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
- softDeletePlugin({ deletedField: "deletedAt" }),
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
- ### Lookup Joins via URL (No Refs Needed)
77
+ Hook priorities ensure deterministic ordering: `POLICY (100) → CACHE (200) → OBSERVABILITY (300) → DEFAULT (500)`.
415
78
 
416
- Join collections by any field (slug, code, SKU) using `$lookup` — no `ref` in schema required. Faster than `populate` for non-ref joins.
79
+ ### Events
417
80
 
418
- ```bash
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
- ```typescript
430
- // Express route — getAll auto-routes to $lookup when lookups are present
431
- const parsed = parser.parse(req.query);
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
- > **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.
442
-
443
- ### Caching
88
+ The `before:*` hook receives the context directly; `after:*` and `error:*` receive `{ context, result | error }`.
444
89
 
445
- ```javascript
446
- import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
90
+ ---
447
91
 
448
- const repo = new Repository(UserModel, [
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
- // Skip cache for fresh data
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
- // Mutations auto-invalidate cache
464
- await repo.update(id, { name: "New" });
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
- // Manual invalidation
467
- await repo.invalidateCache(id);
468
- await repo.invalidateAllCache();
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
- **Redis adapter example:**
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
- ```javascript
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
- ### Validation Chain
111
+ ## Delete semantics
491
112
 
492
- ```javascript
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
- ### Cascade Delete
519
-
520
- ```javascript
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
- // Deleting product also deletes related StockEntry and Review docs
536
- await repo.delete(productId);
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
- const repo = new Repository(UserModel, [
545
- fieldFilterPlugin({
546
- public: ["id", "name", "avatar"],
547
- authenticated: ["email", "phone"],
548
- admin: ["createdAt", "internalNotes"],
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
- ### Multi-Tenant
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
- const repo = new Repository(UserModel, [
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
- ### Audit Trail (DB-Persisted)
133
+ ## Transactions
573
134
 
574
- 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.
135
+ Two entry points, identical semantics, shared retry/fallback logic:
575
136
 
576
- ```typescript
577
- import {
578
- Repository,
579
- methodRegistryPlugin,
580
- auditTrailPlugin,
581
- } from "@classytic/mongokit";
137
+ ```ts
138
+ import { withTransaction } from '@classytic/mongokit';
582
139
 
583
- const repo = new Repository(JobModel, [
584
- methodRegistryPlugin(),
585
- auditTrailPlugin({
586
- operations: ["create", "update", "delete"], // Which ops to track
587
- trackChanges: true, // Field-level before/after diff on updates
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
- // All actions by a user
637
- const userAudits = await auditQuery.getUserTrail(userId);
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
- **Key design decisions:**
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
- **TypeScript type safety:**
155
+ ---
677
156
 
678
- ```typescript
679
- import type { AuditTrailMethods } from "@classytic/mongokit";
157
+ ## Outbox pattern — compose, don't plugin
680
158
 
681
- type JobRepoWithAudit = JobRepo & AuditTrailMethods;
159
+ mongokit does **not** ship an `outboxPlugin`. It doesn't need to.
682
160
 
683
- const repo = new JobRepo(JobModel, [
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
- ### Observability
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
- ```javascript
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
- ### Custom ID Generation
169
+ Sketch:
709
170
 
710
- Generate human-readable sequential IDs (e.g., `INV-0001`, `BILL-2026-02-0001`) using atomic MongoDB counters — safe under concurrency with zero duplicates.
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
- ```typescript
713
- import {
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
- ### Custom Search Backends (search-resolver plugin contract)
884
-
885
- The built-in `searchMode: 'text' | 'regex'` is sugar over a more general
886
- **plugin contract** for any search backend — Elasticsearch, Meilisearch,
887
- Typesense, Algolia, pgvector, Pinecone, hybrid BM25+vector. The contract
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
- // results.docs - MongoDB documents in ES ranking order
978
- // results.docs[*]._score - ES relevance score (preserved, including 0)
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
- // Works at runtime but TypeScript doesn't provide autocomplete
1028
- await repo.increment(productId, "views", 1);
1029
- await repo.upsert({ sku: "ABC" }, { name: "Product", price: 99 });
1030
- ```
1031
-
1032
- #### With TypeScript Type Safety (Recommended)
1033
-
1034
- For full TypeScript autocomplete and type checking, use the `MongoOperationsMethods` type:
1035
-
1036
- ```typescript
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
- **Available operations:**
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
- - `upsert(query, data, opts)` - Create or find document
1071
- - `increment(id, field, value, opts)` - Atomically increment field
1072
- - `decrement(id, field, value, opts)` - Atomically decrement field
1073
- - `pushToArray(id, field, value, opts)` - Add to array
1074
- - `pullFromArray(id, field, value, opts)` - Remove from array
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
- ### Plugin Type Safety
213
+ mongokit's job stops at "the hook has `context.session`." Everything else is composition.
1084
214
 
1085
- Plugin methods are added at runtime. Use `WithPlugins<TDoc, TRepo>` for TypeScript autocomplete:
215
+ ---
1086
216
 
1087
- ```typescript
1088
- import type { WithPlugins } from "@classytic/mongokit";
217
+ ## Built-in plugins
1089
218
 
1090
- class UserRepo extends Repository<IUser> {}
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 |
1091
237
 
1092
- const repo = new UserRepo(Model, [
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
- // IControllerResponse - what your controller returns
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
- ```typescript
1226
- import { QueryParser } from "@classytic/mongokit";
242
+ ## QueryParser (URL → filter)
1227
243
 
1228
- const parser = new QueryParser({
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
- // Read back configured whitelists (used by Arc MCP integration)
1242
- parser.allowedFilterFields; // ['status', 'name', 'email'] or undefined
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: ProductModel.schema,
1264
- fieldTypes: {
1265
- "address.zip": "string", // override a Mongoose path
1266
- legacyCount: "number", // declare a non-schema field
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
- Without either option, the parser falls back to a string-shape heuristic
1272
- (numeric strings without leading zeros and ≤15 chars become numbers; ObjectId
1273
- hex stays a string; `'true'`/`'false'` become booleans). The heuristic is
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
- **Security features:**
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
- **Supported populate options:**
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
- `TDoc` is inferred from the Mongoose model — no manual annotation needed:
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
- // All return types flow correctly
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
- softDeletePlugin,
1586
- timestampPlugin,
1587
- } from "@classytic/mongokit";
1588
- import UserModel, { IUser } from "./models/User.js";
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
- ```typescript
1622
- class AuditedUserRepository extends Repository<IUser> {
1623
- constructor() {
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
- ## Factory Function
283
+ ---
1644
284
 
1645
- For simple cases without custom methods:
285
+ ## Subpath imports
1646
286
 
1647
- ```javascript
1648
- import { createRepository, timestampPlugin } from "@classytic/mongokit";
287
+ Import pure primitives without pulling the full package surface:
1649
288
 
1650
- const userRepo = createRepository(UserModel, [timestampPlugin()], {
1651
- defaultLimit: 20,
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
- ## Error Handling
1656
-
1657
- MongoKit translates MongoDB and Mongoose errors into HTTP-compatible errors with proper status codes:
295
+ ---
1658
296
 
1659
- | Error Type | Status | Example |
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
- ```typescript
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
- // Use in custom error handlers
1671
- const dupErr = parseDuplicateKeyError(error);
1672
- if (dupErr) {
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
- ## Custom ID Field
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
- All plugins respect `idField`: soft-delete, cascade, audit-log, audit-trail, validation-chain, elastic, cache, observability.
308
+ ---
1696
309
 
1697
- ## Mongoose Compatibility
310
+ ## Mongoose compatibility
1698
311
 
1699
- Requires Mongoose `>=9.4.1`. Tested and aligned with Mongoose 9.4.1. 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.
1700
313
 
1701
- - Uses its own event system (not Mongoose middleware)
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