@classytic/mongokit 3.12.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +126 -4
  2. package/dist/{PaginationEngine-BDaAsyPn.mjs → PaginationEngine-CfQRM1yz.mjs} +16 -16
  3. package/dist/Repository-d5gWSROf.mjs +3603 -0
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/actions/index.mjs +1 -1
  6. package/dist/adapter/index.d.mts +190 -0
  7. package/dist/adapter/index.mjs +2 -0
  8. package/dist/adapter-DTDMnUOH.mjs +158 -0
  9. package/dist/ai/index.d.mts +1 -1
  10. package/dist/ai/index.mjs +1 -1
  11. package/dist/better-auth/index.d.mts +149 -0
  12. package/dist/better-auth/index.mjs +81 -0
  13. package/dist/field-selection-Br3WPOEb.mjs +99 -0
  14. package/dist/{index-D9cORR-o.d.mts → index-BbHaKwi5.d.mts} +33 -13
  15. package/dist/index.d.mts +690 -30
  16. package/dist/index.mjs +240 -17
  17. package/dist/{logger-8-VS1za6.mjs → logger-DprijP5h.mjs} +1 -5
  18. package/dist/{mongooseToJsonSchema-B187ufOP.mjs → mongooseToJsonSchema-BYbG894y.mjs} +1 -1
  19. package/dist/{mongooseToJsonSchema-rtkdbBhf.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +2 -1
  20. package/dist/pagination/PaginationEngine.d.mts +2 -2
  21. package/dist/pagination/PaginationEngine.mjs +1 -1
  22. package/dist/plugins/index.d.mts +2 -2
  23. package/dist/plugins/index.mjs +2 -2
  24. package/dist/query/primitives/coercion.d.mts +1 -1
  25. package/dist/query/primitives/coercion.mjs +1 -1
  26. package/dist/query/primitives/indexes.d.mts +1 -1
  27. package/dist/{types-DXscZnLu.d.mts → types-BbaPZleN.d.mts} +371 -112
  28. package/dist/{update-CDxPtpsk.mjs → update-C-3viiw1.mjs} +112 -28
  29. package/dist/utils/index.d.mts +2 -91
  30. package/dist/utils/index.mjs +5 -5
  31. package/dist/validation-chain.plugin-BLVp_Pw2.mjs +2399 -0
  32. package/dist/{validation-chain.plugin-CH-xPoKM.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +212 -80
  33. package/package.json +20 -3
  34. package/dist/field-selection-dI0m4RBs.mjs +0 -221
  35. package/dist/validation-chain.plugin-BicIgzzt.mjs +0 -4970
  36. /package/dist/{coercion-oM8G4b6m.d.mts → coercion-TRMmR49h.d.mts} +0 -0
  37. /package/dist/{id-resolution-e4BT9zAV.mjs → id-resolution-B0Odvvou.mjs} +0 -0
  38. /package/dist/{indexes-C26GNZRU.d.mts → indexes-CgOF7Wjd.d.mts} +0 -0
package/README.md CHANGED
@@ -9,7 +9,7 @@ Production-grade MongoDB repository pattern for Node.js. Zero runtime deps — M
9
9
  npm install @classytic/mongokit @classytic/repo-core mongoose
10
10
  ```
11
11
 
12
- Requires Mongoose `>=9.4.1`, `@classytic/repo-core` `>=0.3.0`, Node.js `>=22`.
12
+ Requires Mongoose `>=9.4.1`, `@classytic/repo-core` `>=0.4.0`, Node.js `>=22`.
13
13
 
14
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
15
 
@@ -17,7 +17,7 @@ Requires Mongoose `>=9.4.1`, `@classytic/repo-core` `>=0.3.0`, Node.js `>=22`.
17
17
 
18
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
19
  - `update(id, data)` → returns `null` on miss.
20
- - `delete(id)` → returns `{ success: false, message: 'Document not found' }` 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
21
  - Pass `{ throwOnNotFound: true }` to opt back into the legacy 404-throw behavior for any of the three.
22
22
 
23
23
  ---
@@ -96,6 +96,42 @@ repo.on('after:create', ({ context, result }) => { /* audit, notify */ });
96
96
 
97
97
  The `before:*` hook receives the context directly; `after:*` and `error:*` receive `{ context, result | error }`.
98
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).
102
+
103
+ ```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
+ });
115
+ ```
116
+
117
+ **Middleware is for ergonomics, NOT security.** This is the load-bearing distinction. The execution order on every call is:
118
+
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
+ ```
128
+
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**.
130
+
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).
132
+
133
+ If you want middleware to observe a policy rejection, listen on `error:<op>` — that fires from inside the middleware chain and is reachable.
134
+
99
135
  ---
100
136
 
101
137
  ## Pagination
@@ -105,12 +141,12 @@ The `before:*` hook receives the context directly; `after:*` and `error:*` recei
105
141
  ```ts
106
142
  // Offset — dashboards, admin panels
107
143
  await repo.getAll({ mode: 'offset', page: 1, limit: 20, sort: { createdAt: -1 } });
108
- // → { method: 'offset', docs, total, pages, hasNext, hasPrev }
144
+ // → { method: 'offset', data, total, pages, hasNext, hasPrev, page, limit }
109
145
 
110
146
  // Keyset — feeds, infinite scroll
111
147
  const p1 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, limit: 20 });
112
148
  const p2 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, after: p1.next });
113
- // → { method: 'keyset', docs, hasMore, next }
149
+ // → { method: 'keyset', data, hasMore, next, limit }
114
150
  ```
115
151
 
116
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 })`.
@@ -248,6 +284,58 @@ Each plugin is tree-shakeable. Import only what you use.
248
284
 
249
285
  ---
250
286
 
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:
292
+
293
+ ```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,
303
+ });
304
+
305
+ // 1. Wire mongoose-level plugins on the schema
306
+ orderSchema.plugin(timelineAuditPlugin, {
307
+ ownerField: 'customerId',
308
+ eventLimits: { 'order.updated': 20 },
309
+ });
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
+ ```
325
+
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
+
251
339
  ## QueryParser (URL → filter)
252
340
 
253
341
  Turn Express / Fastify query strings into sanitized Mongo filters + sort + pagination.
@@ -313,6 +401,40 @@ import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion'
313
401
 
314
402
  ---
315
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**:
409
+
410
+ ```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 });
430
+ ```
431
+
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.
433
+
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.
435
+
436
+ ---
437
+
316
438
  ## Testing
317
439
 
318
440
  Uses `mongodb-memory-server` by default; override via `MONGODB_URI` for a real replica set when running transaction tests locally.
@@ -1,4 +1,4 @@
1
- import { r as warn } from "./logger-8-VS1za6.mjs";
1
+ import { n as warn } from "./logger-DprijP5h.mjs";
2
2
  import { t as createError } from "./error-B00pI7dV.mjs";
3
3
  import { n as encodeCursor, r as resolveCursorFilter } from "./cursor-J84rR6wG.mjs";
4
4
  import { r as validateKeysetSort, t as getPrimaryField } from "./sort-CrZjTK25.mjs";
@@ -224,7 +224,7 @@ var PaginationEngine = class {
224
224
  * page: 1,
225
225
  * limit: 20
226
226
  * });
227
- * console.log(result.docs, result.total, result.hasNext);
227
+ * console.log(result.data, result.total, result.hasNext);
228
228
  */
229
229
  async paginate(options = {}) {
230
230
  const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = "exact", readPreference, collation } = options;
@@ -255,17 +255,17 @@ var PaginationEngine = class {
255
255
  if (readPreference) countQuery.read(readPreference);
256
256
  countPromise = countQuery.exec();
257
257
  }
258
- const [docs, total] = await Promise.all([query.exec(), countPromise]);
258
+ const [data, total] = await Promise.all([query.exec(), countPromise]);
259
259
  const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
260
260
  let hasNext;
261
261
  if (countStrategy === "none") {
262
- hasNext = docs.length > sanitizedLimit;
263
- if (hasNext) docs.pop();
262
+ hasNext = data.length > sanitizedLimit;
263
+ if (hasNext) data.pop();
264
264
  } else hasNext = sanitizedPage < totalPages;
265
265
  const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : void 0;
266
266
  return {
267
267
  method: "offset",
268
- docs,
268
+ data,
269
269
  page: sanitizedPage,
270
270
  limit: sanitizedLimit,
271
271
  total,
@@ -321,16 +321,16 @@ var PaginationEngine = class {
321
321
  if (hint) mongoQuery = mongoQuery.hint(hint);
322
322
  if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
323
323
  if (readPreference) mongoQuery = mongoQuery.read(readPreference);
324
- const docs = await mongoQuery.exec();
325
- const hasMore = docs.length > sanitizedLimit;
326
- if (hasMore) docs.pop();
324
+ const data = await mongoQuery.exec();
325
+ const hasMore = data.length > sanitizedLimit;
326
+ if (hasMore) data.pop();
327
327
  const primaryField = getPrimaryField(normalizedSort);
328
328
  return {
329
329
  method: "keyset",
330
- docs,
330
+ data,
331
331
  limit: sanitizedLimit,
332
332
  hasMore,
333
- next: hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null
333
+ next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null
334
334
  };
335
335
  }
336
336
  /**
@@ -358,7 +358,7 @@ var PaginationEngine = class {
358
358
  const sanitizedLimit = validateLimit(limit, this.config);
359
359
  const skip = calculateSkip(sanitizedPage, sanitizedLimit);
360
360
  const fetchLimit = countStrategy === "none" ? sanitizedLimit + 1 : sanitizedLimit;
361
- const facetStages = { docs: [{ $skip: skip }, { $limit: fetchLimit }] };
361
+ const facetStages = { data: [{ $skip: skip }, { $limit: fetchLimit }] };
362
362
  if (countStrategy !== "none") facetStages.total = [{ $count: "count" }];
363
363
  const facetPipeline = [...pipeline, { $facet: facetStages }];
364
364
  const aggregation = this.Model.aggregate(facetPipeline);
@@ -367,18 +367,18 @@ var PaginationEngine = class {
367
367
  if (maxTimeMS) aggregation.option({ maxTimeMS });
368
368
  if (readPreference) aggregation.read(readPreference);
369
369
  const [result] = await aggregation.exec();
370
- const docs = result.docs;
370
+ const data = result.data;
371
371
  const total = result.total?.[0]?.count || 0;
372
372
  const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
373
373
  let hasNext;
374
374
  if (countStrategy === "none") {
375
- hasNext = docs.length > sanitizedLimit;
376
- if (hasNext) docs.pop();
375
+ hasNext = data.length > sanitizedLimit;
376
+ if (hasNext) data.pop();
377
377
  } else hasNext = sanitizedPage < totalPages;
378
378
  const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
379
379
  return {
380
380
  method: "aggregate",
381
- docs,
381
+ data,
382
382
  page: sanitizedPage,
383
383
  limit: sanitizedLimit,
384
384
  total,