@classytic/mongokit 3.14.0 → 3.16.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 (28) hide show
  1. package/README.md +193 -319
  2. package/dist/{PaginationEngine-CfQRM1yz.mjs → PaginationEngine-0XXTW-9b.mjs} +2 -2
  3. package/dist/{Repository-BGL1Pq8w.mjs → Repository-DE7D2QeS.mjs} +240 -19
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/ai/index.mjs +14 -5
  7. package/dist/better-auth/index.d.mts +55 -1
  8. package/dist/better-auth/index.mjs +32 -2
  9. package/dist/{index-Dccha9dw.d.mts → index-CV5vv-8E.d.mts} +1 -1
  10. package/dist/index.d.mts +1182 -1079
  11. package/dist/index.mjs +32 -19
  12. package/dist/{mongooseToJsonSchema-BYbG894y.mjs → mongooseToJsonSchema-Bi0q0pYW.mjs} +1 -1
  13. package/dist/{mongooseToJsonSchema-BgRAaXX-.d.mts → mongooseToJsonSchema-GhvZUjRO.d.mts} +1 -1
  14. package/dist/pagination/PaginationEngine.d.mts +1 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +1 -1
  17. package/dist/plugins/index.mjs +1 -1
  18. package/dist/query/primitives/coercion.mjs +1 -1
  19. package/dist/{types-BbaPZleN.d.mts → types-BWZILovv.d.mts} +26 -1
  20. package/dist/utils/index.d.mts +1 -1
  21. package/dist/utils/index.mjs +3 -3
  22. package/dist/{validation-chain.plugin-CWhvBRej.mjs → validation-chain.plugin-Crl5nQAY.mjs} +1172 -1137
  23. package/dist/{validation-chain.plugin-CAPYCj_T.d.mts → validation-chain.plugin-Iee2_puD.d.mts} +368 -344
  24. package/package.json +6 -6
  25. /package/dist/{cursor-J84rR6wG.mjs → cursor-4LMYa4ga.mjs} +0 -0
  26. /package/dist/{field-selection-Br3WPOEb.mjs → field-selection-CQW3WBiK.mjs} +0 -0
  27. /package/dist/{id-resolution-B0Odvvou.mjs → id-resolution-DduoO4LI.mjs} +0 -0
  28. /package/dist/{sort-CrZjTK25.mjs → sort-RPVz5u0w.mjs} +0 -0
package/README.md CHANGED
@@ -3,456 +3,330 @@
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 for Node.js. Zero runtime deps — Mongoose and `@classytic/repo-core` are peers.
6
+ Production-ready repository layer for Mongoose apps: tenant-safe CRUD, soft delete, pagination, hooks, retries, transactions, change streams, and plugins.
7
+
8
+ Mongokit is built on `@classytic/repo-core` and implements the shared `StandardRepo<TDoc>` contract, so controller code can target one repository interface across MongoDB and SQLite.
9
+
10
+ ## Install
7
11
 
8
12
  ```bash
9
13
  npm install @classytic/mongokit @classytic/repo-core mongoose
10
14
  ```
11
15
 
12
- Requires Mongoose `>=9.4.1`, `@classytic/repo-core` `>=0.4.0`, Node.js `>=22`.
13
-
14
- > **Swap-able with sqlitekit.** Mongokit implements the `StandardRepo<TDoc>` contract from `@classytic/repo-core/repository`. Controller code written against the contract runs unchanged on [@classytic/sqlitekit](https://www.npmjs.com/package/@classytic/sqlitekit) — both kits share an identical conformance suite.
15
-
16
- ### Miss semantics (MinimalRepo contract)
16
+ Requirements:
17
17
 
18
- - `getById(id)` → returns `null` on miss (not thrown). Invalid-shape ids (e.g. `'not-a-valid-id'` on an ObjectId `_id`) short-circuit to `null` rather than raising mongoose `CastError`.
19
- - `update(id, data)` → returns `null` on miss.
20
- - `delete(id)` → returns `null` on miss. On hit, returns a `DeleteResult` (e.g. `{ message: 'Soft deleted successfully', id, soft: true }` when `softDeletePlugin` is wired; the driver's hard-delete result otherwise).
21
- - Pass `{ throwOnNotFound: true }` to opt back into the legacy 404-throw behavior for any of the three.
18
+ - Node.js `>=22`
19
+ - Mongoose `>=9.4.1`
20
+ - `@classytic/repo-core >=0.6.0`
22
21
 
23
- ---
22
+ ## Quick Start
24
23
 
25
- ## Quick start
24
+ Use `createRepository(model, config)` for the common path. It composes plugins in the safe order for you.
26
25
 
27
26
  ```ts
28
- import { Repository } from '@classytic/mongokit';
27
+ import { createRepository } from '@classytic/mongokit';
29
28
  import UserModel from './models/User.js';
30
29
 
31
- const userRepo = new Repository(UserModel);
32
-
33
- const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
34
- const page = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
35
- await userRepo.update(user._id, { name: 'Jane' });
36
- await userRepo.delete(user._id);
37
- ```
38
-
39
- Every Repository method is event-driven, pluggable, and returns raw Mongoose documents — no custom envelopes.
40
-
41
- ---
42
-
43
- ## Core concepts
44
-
45
- ### Repository
46
-
47
- A `Repository<TDoc>` wraps a Mongoose model and exposes a small, uniform CRUD surface:
30
+ const userRepo = createRepository<UserDoc>(UserModel, {
31
+ tenant: { tenantField: 'organizationId' },
32
+ softDelete: true,
33
+ timestamps: true,
34
+ batch: true,
35
+ });
48
36
 
49
- - **Reads:** `getById`, `getByQuery`, `getOne`, `getAll`, `findAll`, `count`, `exists`, `distinct`, `aggregate`, `aggregatePaginate`, `lookupPopulate`
50
- - **Writes:** `create`, `createMany`, `update`, `updateMany` (via plugin), `delete`, `deleteMany` (via plugin), `bulkWrite` (via plugin)
51
- - **Restore / trash bin:** via `softDeletePlugin`
37
+ const user = await userRepo.create(
38
+ { name: 'Ada', email: 'ada@example.com' },
39
+ { organizationId },
40
+ );
52
41
 
53
- Extend it by subclassing; add domain verbs on the subclass. Do not wrap it in a service layer.
42
+ const page = await userRepo.getAll({
43
+ page: 1,
44
+ limit: 20,
45
+ filters: { status: 'active' },
46
+ organizationId,
47
+ });
54
48
 
55
- ```ts
56
- class InvoiceRepository extends Repository<InvoiceDoc> {
57
- async markPaid(id: string, ctx: RepositoryContext) {
58
- return this.update(id, { status: 'paid', paidAt: new Date() }, ctx);
59
- }
60
- }
49
+ await userRepo.update(user._id, { name: 'Ada Lovelace' }, { organizationId });
50
+ await userRepo.delete(user._id, { organizationId });
61
51
  ```
62
52
 
63
- ### Plugins
64
-
65
- Plugins register hooks and/or methods on a repository. Order matters — they run at declared priorities:
53
+ For manual plugin control, use the constructor directly:
66
54
 
67
55
  ```ts
68
56
  import {
69
57
  Repository,
70
58
  methodRegistryPlugin,
71
- batchOperationsPlugin,
72
59
  multiTenantPlugin,
73
60
  softDeletePlugin,
74
- cachePlugin,
61
+ timestampPlugin,
75
62
  } from '@classytic/mongokit';
76
63
 
77
- const repo = new Repository<Invoice>(InvoiceModel, [
64
+ const userRepo = new Repository(UserModel, [
78
65
  methodRegistryPlugin(),
79
- batchOperationsPlugin(),
80
- multiTenantPlugin({ tenantField: 'organizationId' }), // POLICY (100)
81
- softDeletePlugin({ deletedField: 'deletedAt' }), // POLICY (100)
82
- cachePlugin({ adapter: redisAdapter }), // CACHE (200)
66
+ multiTenantPlugin({ tenantField: 'organizationId' }),
67
+ softDeletePlugin(),
68
+ timestampPlugin(),
83
69
  ]);
84
70
  ```
85
71
 
86
- Hook priorities ensure deterministic ordering: `POLICY (100) → CACHE (200) → OBSERVABILITY (300) → DEFAULT (500)`.
72
+ ## Why Mongokit?
73
+
74
+ - **Tenant safety:** tenant filters are injected by policy hooks before driver calls.
75
+ - **Soft delete:** default soft delete with `restore`, `getDeleted`, TTL support, and hard-delete escape hatches.
76
+ - **Pagination:** offset and keyset pagination with index warnings for unsafe keyset queries.
77
+ - **Hooks and middleware:** deterministic `before:*`, `after:*`, `error:*` hooks plus wrap-style middleware.
78
+ - **Retries and cancellation:** `retryPolicy` and `AbortSignal` support across the repository surface.
79
+ - **Transactions:** helper APIs for single-repo and cross-repo MongoDB transactions.
80
+ - **Change streams:** portable `watch()` API over Mongo change streams.
81
+ - **Cross-kit contract:** assignable to `StandardRepo<TDoc>` from `@classytic/repo-core`.
87
82
 
88
- ### Events
83
+ ## Core API
89
84
 
90
- Every operation emits `before:*`, `after:*`, `error:*`:
85
+ Reads:
91
86
 
92
87
  ```ts
93
- repo.on('before:delete', (ctx) => { /* mutate ctx, throw to veto */ });
94
- repo.on('after:create', ({ context, result }) => { /* audit, notify */ });
88
+ await repo.getById(id, options);
89
+ await repo.getByQuery({ email }, options);
90
+ await repo.getOne({ slug }, options);
91
+ await repo.findAll({ status: 'active' }, options);
92
+ await repo.getAll({ page: 1, limit: 20, filters });
93
+ await repo.count(filters, options);
94
+ await repo.exists(filters, options);
95
+ await repo.distinct('status', filters, options);
95
96
  ```
96
97
 
97
- The `before:*` hook receives the context directly; `after:*` and `error:*` receive `{ context, result | error }`.
98
-
99
- ### Middleware
100
-
101
- `useMiddleware()` composes around every op (including cache-hit branches), wrap-style. Inputs and outputs both pass through; registration order = composition order (first registered runs outermost).
98
+ Writes:
102
99
 
103
100
  ```ts
104
- repo.useMiddleware(async ({ operation, next }) => {
105
- const start = performance.now();
106
- try { return await next(); }
107
- finally { metrics.record(operation, performance.now() - start); }
108
- });
109
-
110
- // Short-circuit by returning without calling next()
111
- repo.useMiddleware(async ({ operation, context, next }) => {
112
- if (operation === 'getById' && readOnlyMaintenance) return cachedReadOnlyResponse(context.id);
113
- return next();
114
- });
101
+ await repo.create(data, options);
102
+ await repo.createMany(rows, options);
103
+ await repo.update(id, patch, options);
104
+ await repo.updateMany(filter, patch, options);
105
+ await repo.delete(id, options);
106
+ await repo.deleteMany(filter, options);
107
+ await repo.findOneAndUpdate(filter, update, options);
115
108
  ```
116
109
 
117
- **Middleware is for ergonomics, NOT security.** This is the load-bearing distinction. The execution order on every call is:
110
+ Miss semantics follow the `MinimalRepo` contract:
118
111
 
119
- ```text
120
- _buildContext + before:<op> ← repo.on('before:*') hooks
121
- [outer middleware pre] ← repo.useMiddleware() registrations
122
- [...inner middleware pre]
123
- fn (driver call)
124
- after:<op> | error:<op> ← repo.on('after:*' / 'error:*')
125
- [...inner middleware post]
126
- [outer middleware post]
127
- ```
112
+ - `getById`, `update`, and `delete` return `null` on miss.
113
+ - Invalid ObjectId-shaped input returns `null` instead of surfacing a Mongoose `CastError`.
114
+ - Pass `{ throwOnNotFound: true }` to opt into 404-style errors.
128
115
 
129
- Build/`before:*` hooks fire BEFORE the middleware chain dispatches. A throw from a `before:*` policy hook (tenant scope, soft-delete, access-control) unwinds before middleware ever fires — middleware **cannot wrap, observe, or short-circuit a policy failure**.
116
+ ## Common Recipes
130
117
 
131
- That's by design. Middleware as a security boundary would be impossible to audit because registration order would determine whether tenant scope wins. Use `before:*` hooks for security policy (tenant scope, soft-delete filtering, audit, cache invalidation) and `useMiddleware()` for ergonomics (timing, metrics, input/output mutation in a single closure).
118
+ ### Multi-Tenant Reads And Writes
132
119
 
133
- If you want middleware to observe a policy rejection, listen on `error:<op>` — that fires from inside the middleware chain and is reachable.
120
+ ```ts
121
+ const repo = createRepository(OrderModel, {
122
+ tenant: { tenantField: 'organizationId', required: true },
123
+ });
134
124
 
135
- ---
125
+ await repo.create({ total: 120 }, { organizationId });
126
+ await repo.findAll({ status: 'paid' }, { organizationId });
127
+ ```
136
128
 
137
- ## Pagination
129
+ If `required: true` is set and no tenant can be resolved, the call fails closed before any driver call.
138
130
 
139
- `getAll` supports offset, keyset (cursor), and aggregate pagination. Pass `mode: 'offset' | 'keyset'` explicitly or let it auto-detect.
131
+ ### Soft Delete And Hard Delete
140
132
 
141
133
  ```ts
142
- // Offset — dashboards, admin panels
143
- await repo.getAll({ mode: 'offset', page: 1, limit: 20, sort: { createdAt: -1 } });
144
- // → { method: 'offset', data, total, pages, hasNext, hasPrev, page, limit }
145
-
146
- // Keyset — feeds, infinite scroll
147
- const p1 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, limit: 20 });
148
- const p2 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, after: p1.next });
149
- // → { method: 'keyset', data, hasMore, next, limit }
150
- ```
151
-
152
- 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 })`.
134
+ const repo = createRepository(UserModel, {
135
+ softDelete: { deletedField: 'deletedAt' },
136
+ });
153
137
 
154
- ---
138
+ await repo.delete(userId); // soft delete
139
+ await repo.restore(userId); // plugin method
140
+ await repo.delete(userId, { mode: 'hard' }); // physical delete
141
+ ```
155
142
 
156
- ## Delete semantics
143
+ Hard delete still runs tenant, audit, cache, and validation hooks.
157
144
 
158
- `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:
145
+ ### Pagination
159
146
 
160
147
  ```ts
161
- // Default — soft when plugin wired
162
- await repo.delete(userId, { organizationId });
148
+ const offsetPage = await repo.getAll({
149
+ mode: 'offset',
150
+ page: 1,
151
+ limit: 25,
152
+ sort: { createdAt: -1 },
153
+ });
163
154
 
164
- // Hard — physical delete, audit + tenant scoping still enforced
165
- await repo.delete(userId, { organizationId, mode: 'hard' });
155
+ const first = await repo.getAll({
156
+ mode: 'keyset',
157
+ limit: 25,
158
+ sort: { createdAt: -1 },
159
+ });
166
160
 
167
- // Bulk hard delete (requires batchOperationsPlugin)
168
- await repo.deleteMany(
169
- { createdAt: { $lt: cutoff } },
170
- { organizationId, mode: 'hard' },
171
- );
161
+ const next = await repo.getAll({
162
+ mode: 'keyset',
163
+ limit: 25,
164
+ sort: { createdAt: -1 },
165
+ after: first.next,
166
+ });
172
167
  ```
173
168
 
174
- Never drop to `repo.Model.deleteOne` — that bypasses every hook you wired.
175
-
176
- ---
177
-
178
- ## Transactions
179
-
180
- Two entry points, identical semantics, shared retry/fallback logic:
169
+ ### Transactions
181
170
 
182
171
  ```ts
183
172
  import { withTransaction } from '@classytic/mongokit';
184
173
 
185
- // Cross-repo — pass a Mongoose connection
186
174
  await withTransaction(mongoose.connection, async (session) => {
187
- const order = await orderRepo.create(data, { session });
188
- await ledgerRepo.create({ orderId: order._id, amount }, { session });
189
- return order;
190
- });
191
-
192
- // Single-repo convenience
193
- await orderRepo.withTransaction(async (session) => {
194
- return orderRepo.create(data, { session });
175
+ const order = await orderRepo.create(orderData, { session, organizationId });
176
+ await ledgerRepo.create({ orderId: order._id, amount: order.total }, { session, organizationId });
195
177
  });
196
178
  ```
197
179
 
198
- Both auto-retry on `TransientTransactionError` / `UnknownTransactionCommitResult`. Pass `{ allowFallback: true, onFallback }` to run the callback non-transactionally on standalone MongoDB (dev).
180
+ Single-repo convenience:
199
181
 
200
- ---
201
-
202
- ## Outbox pattern — compose, don't plugin
182
+ ```ts
183
+ await orderRepo.withTransaction(async (txRepo) => {
184
+ return txRepo.create(orderData, { organizationId });
185
+ });
186
+ ```
203
187
 
204
- mongokit does **not** ship an `outboxPlugin`. It doesn't need to.
188
+ ### Change Streams
205
189
 
206
- 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:
190
+ ```ts
191
+ const ac = new AbortController();
207
192
 
208
- - **mongokit's hooks** (`before:create` / `before:update` / `before:delete`) for session-bound writes
209
- - **arc's `EventTransport`** (Memory / Redis / Kafka / etc.) for delivery
210
- - **a Mongo collection** (with a TTL index on `deliveredAt`) as the outbox store
193
+ for await (const change of repo.watch({ status: 'pending' }, {
194
+ signal: ac.signal,
195
+ organizationId,
196
+ } as { signal: AbortSignal; organizationId: string })) {
197
+ if (change.operation === 'create') enqueue(change.doc);
198
+ }
199
+ ```
211
200
 
212
- 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).
201
+ `watch()` is plugin-routed like other reads, so tenant and soft-delete filters are applied before the Mongo change-stream pipeline opens.
213
202
 
214
- Sketch:
203
+ ### Query Parser
215
204
 
216
205
  ```ts
217
- // host: src/outbox/wire.ts
218
- import { wireOutbox, MongoOutboxStore } from './outbox-recipe.js';
219
- import { RedisEventTransport } from '@classytic/arc/events/redis';
220
-
221
- const store = new MongoOutboxStore({ connection: mongoose.connection, name: 'outbox' });
222
- const transport = new RedisEventTransport({ url: process.env.REDIS_URL });
223
-
224
- wireOutbox({
225
- repos: {
226
- 'catalog:product': catalog.repositories.product,
227
- 'revenue:transaction': revenue.repositories.transaction,
228
- 'order:order': order.repositories.order,
229
- },
230
- store,
231
- // optional: skip internal audit repos, add tenant/correlation meta, etc.
232
- shouldEnqueue: ({ resource }) => resource !== 'audit:log',
233
- enrichMeta: (ctx) => ({ correlationId: ctx.correlationId as string | undefined }),
206
+ import { QueryParser } from '@classytic/mongokit';
207
+
208
+ const parser = new QueryParser({
209
+ schema: InvoiceModel.schema,
210
+ allowedFilterFields: ['status', 'total', 'customerId'],
211
+ allowedSortFields: ['createdAt', 'total'],
212
+ searchMode: 'auto',
234
213
  });
235
214
 
236
- // relay worker — runs on app startup
237
- setInterval(async () => {
238
- const pending = await store.getPending(100);
239
- for (const event of pending) {
240
- try {
241
- await transport.publish(event);
242
- await store.acknowledge(event.meta.id);
243
- } catch {
244
- break; // retry next tick
245
- }
246
- }
247
- }, 1000);
215
+ const parsed = parser.parse(req.query);
216
+ const result = await invoiceRepo.getAll(parsed);
248
217
  ```
249
218
 
250
- That's the entire outbox. No plugin, no custom base class, no opinions baked into the data layer. The host decides:
251
-
252
- - Which repos emit events (`wireOutbox.repos`)
253
- - What the event type naming convention is (map keys → `${resource}.created`)
254
- - Which transport to publish to (any `EventTransport` implementation)
255
- - When the relay runs (`setInterval`, BullMQ, scheduled worker…)
256
- - How to enrich meta (AsyncLocalStorage, request ctx, tenant keys)
257
-
258
- mongokit's job stops at "the hook has `context.session`." Everything else is composition.
259
-
260
- ---
219
+ Supports common URL operators such as `_gt`, `_gte`, `_lt`, `_lte`, `_in`, `_nin`, `_regex`, geo filters, populate, and schema-aware coercion.
261
220
 
262
- ## Built-in plugins
221
+ ## Built-In Plugins
263
222
 
264
223
  | Plugin | Purpose |
265
- |---|---|
266
- | `methodRegistryPlugin` | Prerequisite for plugins that attach new methods |
267
- | `batchOperationsPlugin` | `updateMany`, `deleteMany`, `bulkWrite` with hook support |
268
- | `multiTenantPlugin` | Inject tenant scope at POLICY priority — supports `fieldType: 'objectId'` for `$lookup`/`.populate()` |
269
- | `softDeletePlugin` | `deletedAt` / custom field, `restore`, `getDeleted`, TTL, `before:restore` / `after:restore` hooks |
270
- | `cascadePlugin` | Cascade delete; prefer `{ repo: targetRepo, foreignKey, softDelete? }` to route through the target's hook pipeline |
271
- | `customIdPlugin` | Stripe-style prefixed public IDs (`txn_a7b3xk9m`) on top of `_id` |
272
- | `cachePlugin` | Pluggable adapter (memory, Redis); list-cache versioning for multi-pod correctness |
273
- | `auditLogPlugin` / `auditTrailPlugin` | Who/when/what capture to a sibling collection |
274
- | `observabilityPlugin` | Metric hooks for OpenTelemetry / Prometheus bridges |
224
+ | --- | --- |
225
+ | `methodRegistryPlugin` | Adds plugin-contributed methods safely |
226
+ | `multiTenantPlugin` | Tenant scoping and tenant stamping |
227
+ | `softDeletePlugin` | Soft delete, restore, deleted-list queries, TTL |
275
228
  | `timestampPlugin` | `createdAt` / `updatedAt` management |
276
- | `validationChainPlugin` | Layered sync + async validators |
277
- | `fieldFilterPlugin` | Role-based field visibility on reads |
278
- | `subdocumentPlugin` | Helpers for nested array document CRUD |
279
- | `mongoOperationsPlugin` | Low-level Mongo helpers (`$inc`, `$push`, etc.) with hooks |
280
- | `aggregateHelpersPlugin` | Common aggregation builders |
281
- | `elasticPlugin` | Mirror writes to Elasticsearch |
229
+ | `batchOperationsPlugin` | `bulkWrite` and batch helpers |
230
+ | `cachePlugin` | Pluggable read-through cache |
231
+ | `auditLogPlugin` / `auditTrailPlugin` | Operational and compliance audit trails |
232
+ | `observabilityPlugin` | Metrics hook points |
233
+ | `cascadePlugin` | Hook-routed cascade delete |
234
+ | `customIdPlugin` | Prefixed or sequential public IDs |
235
+ | `fieldFilterPlugin` | Role-aware field visibility |
236
+ | `validationChainPlugin` | Layered sync and async validation |
237
+ | `mongoOperationsPlugin` | Hook-routed Mongo update helpers |
238
+ | `aggregateHelpersPlugin` | Common aggregation helpers |
239
+ | `subdocumentPlugin` | Nested array document helpers |
282
240
 
283
- Each plugin is tree-shakeable. Import only what you use.
241
+ Import only the plugins you use.
284
242
 
285
- ---
243
+ ## Events And Middleware
286
244
 
287
- ## Composing with mongoose-level plugins
288
-
289
- mongokit deliberately doesn't reabsorb mongoose plugins that already work via `schema.plugin(...)`. They compose cleanly: the mongoose plugin attaches to the schema before the model lands at mongokit, both layers fire on every write.
290
-
291
- Canonical examples — install + wire as documented by the upstream package, then hand the model to mongokit:
245
+ Every operation emits hooks:
292
246
 
293
247
  ```ts
294
- import mongoose from 'mongoose';
295
- import { Repository, multiTenantPlugin } from '@classytic/mongokit';
296
- import timelineAuditPlugin from 'mongoose-timeline-audit';
297
- import slugPlugin from 'mongoose-slug-plugin';
298
-
299
- const orderSchema = new mongoose.Schema({
300
- customerId: { type: mongoose.Schema.Types.ObjectId, ref: 'Customer' },
301
- status: String,
302
- title: String,
248
+ repo.on('before:delete', (context) => {
249
+ if (!context.user) throw new Error('unauthorized');
303
250
  });
304
251
 
305
- // 1. Wire mongoose-level plugins on the schema
306
- orderSchema.plugin(timelineAuditPlugin, {
307
- ownerField: 'customerId',
308
- eventLimits: { 'order.updated': 20 },
252
+ repo.on('after:create', ({ context, result }) => {
253
+ audit.info({ model: context.model, id: result._id });
309
254
  });
310
- orderSchema.plugin(slugPlugin, { tmpl: '<%=title%>' });
311
-
312
- // 2. Build the model
313
- const Order = mongoose.model('Order', orderSchema);
314
-
315
- // 3. Hand the model to mongokit — kit plugins layer on top
316
- const orderRepo = new Repository(Order, [
317
- multiTenantPlugin({ tenantField: 'organizationId' }),
318
- ]);
319
-
320
- // Now every write fires BOTH layers:
321
- // - mongoose pre('save') / post('save') (timeline-audit, slug)
322
- // - mongokit before:create / after:create (multi-tenant, audit, cache)
323
- await orderRepo.create({ customerId, status: 'pending', title: 'New order' });
324
255
  ```
325
256
 
326
- **Why this composition over absorbing into mongokit:** mongoose plugins hook `schema.add()` + `pre('save')`; mongokit plugins hook `before:create` / `after:update`. Different event models, different concerns. Wrapping a mongoose plugin as a mongokit plugin would be a fork, not a wrapper — and the mongoose plugin already works without mongokit being involved at all. Keeping the layers separate also lets each package version independently.
327
-
328
- **When to use which audit surface:**
329
-
330
- | Need | Reach for |
331
- |---|---|
332
- | Per-doc UX timeline visible to end users (`order.timeline = [{event, actor, at}, ...]`) | `mongoose-timeline-audit` (mongoose plugin) — embedded array on the doc |
333
- | Compliance audit ledger across models, immutable, queryable | `auditTrailPlugin` (mongokit, ships in this package) — separate collection |
334
-
335
- The two are complementary, not alternatives. A typical app wires both — timeline for the customer-facing order page, auditTrail for the compliance officer's quarterly export.
336
-
337
- ---
338
-
339
- ## QueryParser (URL → filter)
340
-
341
- Turn Express / Fastify query strings into sanitized Mongo filters + sort + pagination.
257
+ Middleware wraps successful operation execution:
342
258
 
343
259
  ```ts
344
- import { QueryParser } from '@classytic/mongokit';
345
-
346
- const parser = new QueryParser({
347
- schema: InvoiceModel.schema, // enables type coercion + geo/text detection
348
- allowedFilterFields: ['status', 'total', 'customerId'],
349
- allowedSortFields: ['createdAt', 'total'],
350
- searchMode: 'auto', // 'text' | 'regex' | 'auto'
260
+ repo.useMiddleware(async ({ operation, next }) => {
261
+ const start = performance.now();
262
+ try {
263
+ return await next();
264
+ } finally {
265
+ metrics.record(operation, performance.now() - start);
266
+ }
351
267
  });
352
-
353
- // GET /invoices?status=paid&total_gte=1000&sort=-createdAt&page=1&limit=20
354
- const { filters, sort, page, limit, search } = parser.parse(req.query);
355
- const result = await invoiceRepo.getAll({ filters, sort, page, limit, search });
356
- ```
357
-
358
- 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.
359
-
360
- **Nested subdocument filters.** Dotted paths pass through to MongoDB directly — Mongoose's strict-mode casting handles the nested-schema lookup, no special config required:
361
-
362
- ```
363
- ?contact.email[contains]=foo → { 'contact.email': { $regex: /foo/i } }
364
- ?name.given=sadman → { 'name.given': 'sadman' }
365
- ?name.given[in]=a,b → { 'name.given': { $in: ['a', 'b'] } }
366
268
  ```
367
269
 
368
- Dot-in-key semantics follow `qs` parsing: `?name.given=x` produces `{ 'name.given': 'x' }` (NOT nested into `{ name: { given: 'x' } }`). For the nested-object form use bracket notation: `?name[given]=x`.
369
-
370
- ---
270
+ Use `before:*` hooks for security policy. Use middleware for ergonomics such as timing, tracing, and input/output shaping.
371
271
 
372
272
  ## TypeScript
373
273
 
374
- Full type safety for repository methods, plugin method combinations, events, and query contexts.
375
-
376
274
  ```ts
377
275
  import type {
378
276
  Repository,
379
277
  RepositoryContext,
380
- RepositoryEvent,
381
278
  SoftDeleteMethods,
382
279
  BatchOperationsMethods,
383
280
  } from '@classytic/mongokit';
384
281
 
385
- type InvoiceRepo = Repository<Invoice> &
386
- SoftDeleteMethods<Invoice> &
282
+ type UserRepo = Repository<UserDoc> &
283
+ SoftDeleteMethods<UserDoc> &
387
284
  BatchOperationsMethods;
388
285
  ```
389
286
 
390
- ---
287
+ Feature-detect portable behavior with:
391
288
 
392
- ## Subpath imports
289
+ ```ts
290
+ if (repo.capabilities.changeStreams) {
291
+ // repo.watch is available
292
+ }
293
+ ```
393
294
 
394
- Import pure primitives without pulling the full package surface:
295
+ ## Subpath Imports
395
296
 
396
297
  ```ts
397
- import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';
398
298
  import { parseGeoFilter } from '@classytic/mongokit/query/primitives/geo';
399
299
  import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion';
300
+ import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';
400
301
  ```
401
302
 
402
- ---
403
-
404
- ## Better Auth bridge — `@classytic/mongokit/better-auth`
405
-
406
- When [Better Auth](https://better-auth.com) writes to your Mongo collections via `@better-auth/mongo-adapter`, mongokit ships a kit-owned bridge so you can expose those collections as full repositories — pagination, query parser, OpenAPI, audit, all of it.
407
-
408
- **Two helpers**:
303
+ Optional bridges:
409
304
 
410
305
  ```ts
411
- import {
412
- registerBetterAuthStubs, // bulk stubs for `populate('user')` etc.
413
- createBetterAuthOverlay, // per-collection DataAdapter for arc/your host
414
- } from '@classytic/mongokit/better-auth';
415
-
416
- // 1. Bulk-register stubs so populate() works app-wide.
417
- registerBetterAuthStubs(mongoose, { plugins: ['organization'] });
418
-
419
- // 2. Per-resource overlay — async because we read BA's resolved schema.
420
- // Resolves once at boot, picks up additionalFields + modelName overrides
421
- // + plugin schema additions automatically.
422
- const orgAdapter = await createBetterAuthOverlay({
423
- auth, // your betterAuth() instance
424
- mongoose,
425
- collection: 'organization',
426
- });
427
-
428
- // Plug the adapter into any host that consumes `DataAdapter<TDoc>`:
429
- defineResource({ name: 'organization', adapter: orgAdapter });
306
+ import { createBetterAuthOverlay } from '@classytic/mongokit/better-auth';
430
307
  ```
431
308
 
432
- Need custom validators / Repository methods / `toJSON` transforms (e.g., strip `password`)? Drop the factory and hand-roll the schema + Repository — that's the recommended path when you outgrow the defaults. The factory and the hand-roll produce structurally identical adapters; you can switch back and forth without touching consumer code.
309
+ ## Advanced Documentation
433
310
 
434
- `better-auth` is an **optional peer dependency** — only required when you import this subpath. Mongokit users who don't touch BA don't get an install warning.
311
+ The GitHub repository includes deeper guides for:
435
312
 
436
- ---
313
+ - security hardening
314
+ - lookup and aggregation patterns
315
+ - type architecture
316
+ - contribution workflow
317
+ - release checks
437
318
 
438
- ## Testing
319
+ See the `docs/` directory in the repository for those long-form references.
439
320
 
440
- Uses `mongodb-memory-server` by default; override via `MONGODB_URI` for a real replica set when running transaction tests locally.
321
+ ## Testing
441
322
 
442
323
  ```bash
443
- npm test # full suite (~80 files, 1500+ tests)
444
- npx vitest run tests/X.ts # single file while iterating
324
+ npm test
325
+ npm run typecheck
326
+ npm run build
445
327
  ```
446
328
 
447
- See [`tests/`](./tests) for real-world plugin composition examples (multi-tenant + soft-delete + cascade + audit).
448
-
449
- ---
450
-
451
- ## Mongoose compatibility
452
-
453
- Aligned with **Mongoose 9.4.x**. Earlier versions (8.x) are untested — pin a mongokit v3.x release if you need 8.x support.
454
-
455
- ---
329
+ The test suite uses `mongodb-memory-server` by default. Set `MONGODB_URI` to run against an external MongoDB deployment.
456
330
 
457
331
  ## License
458
332