@classytic/mongokit 3.15.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.
- package/README.md +193 -319
- package/dist/{PaginationEngine-CfQRM1yz.mjs → PaginationEngine-0XXTW-9b.mjs} +2 -2
- package/dist/{Repository-BGL1Pq8w.mjs → Repository-DE7D2QeS.mjs} +240 -19
- package/dist/actions/index.d.mts +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +14 -5
- package/dist/better-auth/index.d.mts +15 -0
- package/dist/better-auth/index.mjs +1 -1
- package/dist/{index-Dccha9dw.d.mts → index-CV5vv-8E.d.mts} +1 -1
- package/dist/index.d.mts +1182 -1079
- package/dist/index.mjs +32 -19
- package/dist/{mongooseToJsonSchema-BYbG894y.mjs → mongooseToJsonSchema-Bi0q0pYW.mjs} +1 -1
- package/dist/{mongooseToJsonSchema-BgRAaXX-.d.mts → mongooseToJsonSchema-GhvZUjRO.d.mts} +1 -1
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/{types-BbaPZleN.d.mts → types-BWZILovv.d.mts} +26 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +3 -3
- package/dist/{validation-chain.plugin-CWhvBRej.mjs → validation-chain.plugin-Crl5nQAY.mjs} +1172 -1137
- package/dist/{validation-chain.plugin-CAPYCj_T.d.mts → validation-chain.plugin-Iee2_puD.d.mts} +368 -344
- package/package.json +6 -6
- /package/dist/{cursor-J84rR6wG.mjs → cursor-4LMYa4ga.mjs} +0 -0
- /package/dist/{field-selection-Br3WPOEb.mjs → field-selection-CQW3WBiK.mjs} +0 -0
- /package/dist/{id-resolution-B0Odvvou.mjs → id-resolution-DduoO4LI.mjs} +0 -0
- /package/dist/{sort-CrZjTK25.mjs → sort-RPVz5u0w.mjs} +0 -0
package/README.md
CHANGED
|
@@ -3,456 +3,330 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@classytic/mongokit)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
Production-
|
|
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
|
-
|
|
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
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
-
|
|
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 {
|
|
27
|
+
import { createRepository } from '@classytic/mongokit';
|
|
29
28
|
import UserModel from './models/User.js';
|
|
30
29
|
|
|
31
|
-
const userRepo =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
const user = await userRepo.create(
|
|
38
|
+
{ name: 'Ada', email: 'ada@example.com' },
|
|
39
|
+
{ organizationId },
|
|
40
|
+
);
|
|
52
41
|
|
|
53
|
-
|
|
42
|
+
const page = await userRepo.getAll({
|
|
43
|
+
page: 1,
|
|
44
|
+
limit: 20,
|
|
45
|
+
filters: { status: 'active' },
|
|
46
|
+
organizationId,
|
|
47
|
+
});
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
timestampPlugin,
|
|
75
62
|
} from '@classytic/mongokit';
|
|
76
63
|
|
|
77
|
-
const
|
|
64
|
+
const userRepo = new Repository(UserModel, [
|
|
78
65
|
methodRegistryPlugin(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
cachePlugin({ adapter: redisAdapter }), // CACHE (200)
|
|
66
|
+
multiTenantPlugin({ tenantField: 'organizationId' }),
|
|
67
|
+
softDeletePlugin(),
|
|
68
|
+
timestampPlugin(),
|
|
83
69
|
]);
|
|
84
70
|
```
|
|
85
71
|
|
|
86
|
-
|
|
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
|
-
|
|
83
|
+
## Core API
|
|
89
84
|
|
|
90
|
-
|
|
85
|
+
Reads:
|
|
91
86
|
|
|
92
87
|
```ts
|
|
93
|
-
repo.
|
|
94
|
-
repo.
|
|
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
|
-
|
|
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.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
110
|
+
Miss semantics follow the `MinimalRepo` contract:
|
|
118
111
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
116
|
+
## Common Recipes
|
|
130
117
|
|
|
131
|
-
|
|
118
|
+
### Multi-Tenant Reads And Writes
|
|
132
119
|
|
|
133
|
-
|
|
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
|
-
|
|
129
|
+
If `required: true` is set and no tenant can be resolved, the call fails closed before any driver call.
|
|
138
130
|
|
|
139
|
-
|
|
131
|
+
### Soft Delete And Hard Delete
|
|
140
132
|
|
|
141
133
|
```ts
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
143
|
+
Hard delete still runs tenant, audit, cache, and validation hooks.
|
|
157
144
|
|
|
158
|
-
|
|
145
|
+
### Pagination
|
|
159
146
|
|
|
160
147
|
```ts
|
|
161
|
-
|
|
162
|
-
|
|
148
|
+
const offsetPage = await repo.getAll({
|
|
149
|
+
mode: 'offset',
|
|
150
|
+
page: 1,
|
|
151
|
+
limit: 25,
|
|
152
|
+
sort: { createdAt: -1 },
|
|
153
|
+
});
|
|
163
154
|
|
|
164
|
-
|
|
165
|
-
|
|
155
|
+
const first = await repo.getAll({
|
|
156
|
+
mode: 'keyset',
|
|
157
|
+
limit: 25,
|
|
158
|
+
sort: { createdAt: -1 },
|
|
159
|
+
});
|
|
166
160
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
{
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
180
|
+
Single-repo convenience:
|
|
199
181
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
182
|
+
```ts
|
|
183
|
+
await orderRepo.withTransaction(async (txRepo) => {
|
|
184
|
+
return txRepo.create(orderData, { organizationId });
|
|
185
|
+
});
|
|
186
|
+
```
|
|
203
187
|
|
|
204
|
-
|
|
188
|
+
### Change Streams
|
|
205
189
|
|
|
206
|
-
|
|
190
|
+
```ts
|
|
191
|
+
const ac = new AbortController();
|
|
207
192
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
### Query Parser
|
|
215
204
|
|
|
216
205
|
```ts
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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-
|
|
221
|
+
## Built-In Plugins
|
|
263
222
|
|
|
264
223
|
| Plugin | Purpose |
|
|
265
|
-
|
|
266
|
-
| `methodRegistryPlugin` |
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
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
|
-
| `
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
281
|
-
| `
|
|
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
|
-
|
|
241
|
+
Import only the plugins you use.
|
|
284
242
|
|
|
285
|
-
|
|
243
|
+
## Events And Middleware
|
|
286
244
|
|
|
287
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
386
|
-
SoftDeleteMethods<
|
|
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
|
-
|
|
289
|
+
```ts
|
|
290
|
+
if (repo.capabilities.changeStreams) {
|
|
291
|
+
// repo.watch is available
|
|
292
|
+
}
|
|
293
|
+
```
|
|
393
294
|
|
|
394
|
-
|
|
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
|
-
|
|
309
|
+
## Advanced Documentation
|
|
433
310
|
|
|
434
|
-
|
|
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
|
-
|
|
319
|
+
See the `docs/` directory in the repository for those long-form references.
|
|
439
320
|
|
|
440
|
-
|
|
321
|
+
## Testing
|
|
441
322
|
|
|
442
323
|
```bash
|
|
443
|
-
npm test
|
|
444
|
-
|
|
324
|
+
npm test
|
|
325
|
+
npm run typecheck
|
|
326
|
+
npm run build
|
|
445
327
|
```
|
|
446
328
|
|
|
447
|
-
|
|
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
|
|