@classytic/mongokit 3.3.2 → 3.4.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
@@ -17,7 +17,7 @@
17
17
  - **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
18
18
  - **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
19
19
  - **TypeScript first** - Full type safety with discriminated unions
20
- - **700+ passing tests** - Battle-tested and production-ready
20
+ - **940+ passing tests** - Battle-tested and production-ready
21
21
 
22
22
  ## Installation
23
23
 
@@ -210,7 +210,7 @@ const repo = new Repository(UserModel, [
210
210
  | `cascadePlugin(opts)` | Auto-delete related documents |
211
211
  | `methodRegistryPlugin()` | Dynamic method registration (required by plugins below) |
212
212
  | `mongoOperationsPlugin()` | Adds `increment`, `pushToArray`, `upsert`, etc. |
213
- | `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany` |
213
+ | `batchOperationsPlugin()` | Adds `updateMany`, `deleteMany`, `bulkWrite` |
214
214
  | `aggregateHelpersPlugin()` | Adds `groupBy`, `sum`, `average`, etc. |
215
215
  | `subdocumentPlugin()` | Manage subdocument arrays |
216
216
  | `multiTenantPlugin(opts)` | Auto-inject tenant isolation on all operations |
@@ -223,14 +223,82 @@ const repo = new Repository(UserModel, [
223
223
 
224
224
  ```javascript
225
225
  const repo = new Repository(UserModel, [
226
+ methodRegistryPlugin(),
227
+ batchOperationsPlugin(),
226
228
  softDeletePlugin({ deletedField: "deletedAt" }),
227
229
  ]);
228
230
 
229
- await repo.delete(id); // Marks as deleted
231
+ await repo.delete(id); // Marks as deleted (sets deletedAt)
230
232
  await repo.getAll(); // Excludes deleted
231
233
  await repo.getAll({ includeDeleted: true }); // Includes deleted
234
+
235
+ // Batch operations respect soft-delete automatically
236
+ await repo.deleteMany({ status: "draft" }); // Soft-deletes matching docs
237
+ await repo.updateMany({ status: "active" }, { $set: { featured: true } }); // Skips soft-deleted
238
+ ```
239
+
240
+ ### Populate via URL (Array Refs + Field Selection)
241
+
242
+ Populate arrays of ObjectIds with field selection, filtering, and sorting — all from URL query params:
243
+
244
+ ```bash
245
+ # Populate all products in an order
246
+ GET /orders?populate=products
247
+
248
+ # Only name and price from each product
249
+ GET /orders?populate[products][select]=name,price
250
+
251
+ # Exclude fields
252
+ GET /orders?populate[products][select]=-internalNotes,-cost
253
+
254
+ # Filter: only active products
255
+ GET /orders?populate[products][match][status]=active
256
+
257
+ # Limit + sort populated items
258
+ GET /orders?populate[products][limit]=5&populate[products][sort]=-price
259
+
260
+ # Combined
261
+ GET /orders?populate[products][select]=name,price&populate[products][match][status]=active&populate[products][limit]=10
232
262
  ```
233
263
 
264
+ ```typescript
265
+ // Express route — 3 lines
266
+ const parsed = parser.parse(req.query);
267
+ const result = await orderRepo.getAll(
268
+ { filters: parsed.filters, sort: parsed.sort, limit: parsed.limit },
269
+ { populateOptions: parsed.populateOptions, populate: parsed.populate },
270
+ );
271
+ ```
272
+
273
+ ### Lookup Joins via URL (No Refs Needed)
274
+
275
+ Join collections by any field (slug, code, SKU) using `$lookup` — no `ref` in schema required. Faster than `populate` for non-ref joins.
276
+
277
+ ```bash
278
+ # Join products with categories by slug
279
+ GET /products?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug&lookup[category][single]=true
280
+
281
+ # With field selection on joined collection (only bring name + slug)
282
+ GET /products?lookup[category][...same]&lookup[category][select]=name,slug
283
+
284
+ # Combined with filter + sort + root select
285
+ GET /products?status=active&sort=-price&select=name,price,category&lookup[category][...same]&lookup[category][select]=name
286
+ ```
287
+
288
+ ```typescript
289
+ // Express route — getAll auto-routes to $lookup when lookups are present
290
+ const parsed = parser.parse(req.query);
291
+ const result = await repo.getAll({
292
+ filters: parsed.filters,
293
+ sort: parsed.sort,
294
+ lookups: parsed.lookups, // auto-routes to lookupPopulate
295
+ select: parsed.select,
296
+ limit: parsed.limit,
297
+ });
298
+ ```
299
+
300
+ > **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.
301
+
234
302
  ### Caching
235
303
 
236
304
  ```javascript
@@ -854,7 +922,46 @@ repo.on("error:create", ({ context, error }) => {
854
922
  });
855
923
  ```
856
924
 
857
- **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
925
+ **Events:** `before:*`, `after:*`, `error:*` for `create`, `createMany`, `update`, `delete`, `deleteMany`, `updateMany`, `getById`, `getByQuery`, `getAll`, `aggregatePaginate`
926
+
927
+ ### Microservice Integration (Kafka / RabbitMQ / Redis Pub-Sub)
928
+
929
+ Use `after:*` hooks to publish events to message brokers — zero additional libraries needed:
930
+
931
+ ```typescript
932
+ import { HOOK_PRIORITY } from "@classytic/mongokit";
933
+
934
+ // Publish to Kafka after every create
935
+ repo.on("after:create", async ({ context, result }) => {
936
+ await kafka.publish("orders.created", {
937
+ operation: context.operation,
938
+ model: context.model,
939
+ document: result,
940
+ userId: context.user?._id,
941
+ tenantId: context.organizationId,
942
+ timestamp: Date.now(),
943
+ });
944
+ }, { priority: HOOK_PRIORITY.OBSERVABILITY });
945
+
946
+ // Redis Pub-Sub on updates
947
+ repo.on("after:update", async ({ context, result }) => {
948
+ await redis.publish("order:updated", JSON.stringify({
949
+ id: result._id,
950
+ changes: context.data,
951
+ }));
952
+ }, { priority: HOOK_PRIORITY.OBSERVABILITY });
953
+
954
+ // RabbitMQ on deletes (including soft-deletes)
955
+ repo.on("after:delete", async ({ context, result }) => {
956
+ await rabbitMQ.sendToQueue("order.deleted", {
957
+ id: result.id,
958
+ soft: result.soft,
959
+ tenantId: context.organizationId,
960
+ });
961
+ }, { priority: HOOK_PRIORITY.OBSERVABILITY });
962
+ ```
963
+
964
+ **Hook priority order:** `POLICY (100)` → `CACHE (200)` → `OBSERVABILITY (300)` → `DEFAULT (500)`. Event publishing at `OBSERVABILITY` ensures it runs after policy enforcement and cache invalidation.
858
965
 
859
966
  ## Building REST APIs
860
967
 
@@ -1240,6 +1347,29 @@ const userRepo = createRepository(UserModel, [timestampPlugin()], {
1240
1347
  });
1241
1348
  ```
1242
1349
 
1350
+ ## Error Handling
1351
+
1352
+ MongoKit translates MongoDB and Mongoose errors into HTTP-compatible errors with proper status codes:
1353
+
1354
+ | Error Type | Status | Example |
1355
+ |---|---|---|
1356
+ | Duplicate key (E11000) | **409** | `Duplicate value for email (email: "dup@test.com")` |
1357
+ | Validation error | **400** | `Validation Error: name is required` |
1358
+ | Cast error | **400** | `Invalid _id: not-a-valid-id` |
1359
+ | Document not found | **404** | `Document not found` |
1360
+ | Other errors | **500** | `Internal Server Error` |
1361
+
1362
+ ```typescript
1363
+ import { parseDuplicateKeyError } from "@classytic/mongokit";
1364
+
1365
+ // Use in custom error handlers
1366
+ const dupErr = parseDuplicateKeyError(error);
1367
+ if (dupErr) {
1368
+ // dupErr.status === 409
1369
+ // dupErr.message includes field name and value
1370
+ }
1371
+ ```
1372
+
1243
1373
  ## No Breaking Changes
1244
1374
 
1245
1375
  Extending Repository works exactly the same with Mongoose 8 and 9. The package:
@@ -1247,7 +1377,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
1247
1377
  - Uses its own event system (not Mongoose middleware)
1248
1378
  - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
1249
1379
  - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
1250
- - All 700+ tests pass on Mongoose 9
1380
+ - All 940+ tests pass on Mongoose 9
1251
1381
 
1252
1382
  ## License
1253
1383
 
@@ -1,9 +1,9 @@
1
+ import { a as warn, t as createError } from "./error-Bpbi_NKo.mjs";
1
2
  import mongoose from "mongoose";
2
-
3
3
  //#region src/pagination/utils/cursor.ts
4
4
  /**
5
5
  * Cursor Utilities
6
- *
6
+ *
7
7
  * Encoding and decoding of cursor tokens for keyset pagination.
8
8
  * Cursors are base64-encoded JSON containing position data and metadata.
9
9
  */
@@ -125,61 +125,6 @@ function rehydrateValue(serialized, type) {
125
125
  default: return serialized;
126
126
  }
127
127
  }
128
-
129
- //#endregion
130
- //#region src/pagination/utils/sort.ts
131
- /**
132
- * Normalizes sort object to ensure stable key order
133
- * Primary fields first, _id last (not alphabetical)
134
- *
135
- * @param sort - Sort specification
136
- * @returns Normalized sort with stable key order
137
- */
138
- function normalizeSort(sort) {
139
- const normalized = {};
140
- Object.keys(sort).forEach((key) => {
141
- if (key !== "_id") normalized[key] = sort[key];
142
- });
143
- if (sort._id !== void 0) normalized._id = sort._id;
144
- return normalized;
145
- }
146
- /**
147
- * Validates and normalizes sort for keyset pagination
148
- * Auto-adds _id tie-breaker if needed
149
- * Ensures _id direction matches primary field
150
- *
151
- * @param sort - Sort specification
152
- * @returns Validated and normalized sort
153
- * @throws Error if sort is invalid for keyset pagination
154
- */
155
- function validateKeysetSort(sort) {
156
- const keys = Object.keys(sort);
157
- if (keys.length === 1 && keys[0] !== "_id") {
158
- const field = keys[0];
159
- const direction = sort[field];
160
- return normalizeSort({
161
- [field]: direction,
162
- _id: direction
163
- });
164
- }
165
- if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
166
- if (keys.length === 2) {
167
- if (!keys.includes("_id")) throw new Error("Keyset pagination requires _id as tie-breaker");
168
- if (sort[keys.find((k) => k !== "_id")] !== sort._id) throw new Error("_id direction must match primary field direction");
169
- return normalizeSort(sort);
170
- }
171
- throw new Error("Keyset pagination only supports single field + _id");
172
- }
173
- /**
174
- * Extracts primary sort field (first non-_id field)
175
- *
176
- * @param sort - Sort specification
177
- * @returns Primary field name
178
- */
179
- function getPrimaryField(sort) {
180
- return Object.keys(sort).find((k) => k !== "_id") || "_id";
181
- }
182
-
183
128
  //#endregion
184
129
  //#region src/pagination/utils/filter.ts
185
130
  /**
@@ -232,7 +177,6 @@ function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
232
177
  }]
233
178
  };
234
179
  }
235
-
236
180
  //#endregion
237
181
  //#region src/pagination/utils/limits.ts
238
182
  /**
@@ -294,6 +238,262 @@ function calculateSkip(page, limit) {
294
238
  function calculateTotalPages(total, limit) {
295
239
  return Math.ceil(total / limit);
296
240
  }
297
-
298
241
  //#endregion
299
- export { validatePage as a, validateKeysetSort as c, validateCursorSort as d, validateCursorVersion as f, validateLimit as i, decodeCursor as l, calculateTotalPages as n, buildKeysetFilter as o, shouldWarnDeepPagination as r, getPrimaryField as s, calculateSkip as t, encodeCursor as u };
242
+ //#region src/pagination/utils/sort.ts
243
+ /**
244
+ * Normalizes sort object to ensure stable key order
245
+ * Primary fields first, _id last (not alphabetical)
246
+ *
247
+ * @param sort - Sort specification
248
+ * @returns Normalized sort with stable key order
249
+ */
250
+ function normalizeSort(sort) {
251
+ const normalized = {};
252
+ Object.keys(sort).forEach((key) => {
253
+ if (key !== "_id") normalized[key] = sort[key];
254
+ });
255
+ if (sort._id !== void 0) normalized._id = sort._id;
256
+ return normalized;
257
+ }
258
+ /**
259
+ * Validates and normalizes sort for keyset pagination
260
+ * Auto-adds _id tie-breaker if needed
261
+ * Ensures _id direction matches primary field
262
+ *
263
+ * @param sort - Sort specification
264
+ * @returns Validated and normalized sort
265
+ * @throws Error if sort is invalid for keyset pagination
266
+ */
267
+ function validateKeysetSort(sort) {
268
+ const keys = Object.keys(sort);
269
+ if (keys.length === 1 && keys[0] !== "_id") {
270
+ const field = keys[0];
271
+ const direction = sort[field];
272
+ return normalizeSort({
273
+ [field]: direction,
274
+ _id: direction
275
+ });
276
+ }
277
+ if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
278
+ if (keys.length === 2) {
279
+ if (!keys.includes("_id")) throw new Error("Keyset pagination requires _id as tie-breaker");
280
+ if (sort[keys.find((k) => k !== "_id")] !== sort._id) throw new Error("_id direction must match primary field direction");
281
+ return normalizeSort(sort);
282
+ }
283
+ throw new Error("Keyset pagination only supports single field + _id");
284
+ }
285
+ /**
286
+ * Extracts primary sort field (first non-_id field)
287
+ *
288
+ * @param sort - Sort specification
289
+ * @returns Primary field name
290
+ */
291
+ function getPrimaryField(sort) {
292
+ return Object.keys(sort).find((k) => k !== "_id") || "_id";
293
+ }
294
+ //#endregion
295
+ //#region src/pagination/PaginationEngine.ts
296
+ /**
297
+ * Production-grade pagination engine for MongoDB
298
+ * Supports offset, keyset (cursor), and aggregate pagination
299
+ */
300
+ var PaginationEngine = class {
301
+ Model;
302
+ config;
303
+ /**
304
+ * Create a new pagination engine
305
+ *
306
+ * @param Model - Mongoose model to paginate
307
+ * @param config - Pagination configuration
308
+ */
309
+ constructor(Model, config = {}) {
310
+ this.Model = Model;
311
+ this.config = {
312
+ defaultLimit: config.defaultLimit || 10,
313
+ maxLimit: config.maxLimit || 100,
314
+ maxPage: config.maxPage || 1e4,
315
+ deepPageThreshold: config.deepPageThreshold || 100,
316
+ cursorVersion: config.cursorVersion || 1,
317
+ useEstimatedCount: config.useEstimatedCount || false
318
+ };
319
+ }
320
+ /**
321
+ * Offset-based pagination using skip/limit
322
+ * Best for small datasets and when users need random page access
323
+ * O(n) performance - slower for deep pages
324
+ *
325
+ * @param options - Pagination options
326
+ * @returns Pagination result with total count
327
+ *
328
+ * @example
329
+ * const result = await engine.paginate({
330
+ * filters: { status: 'active' },
331
+ * sort: { createdAt: -1 },
332
+ * page: 1,
333
+ * limit: 20
334
+ * });
335
+ * console.log(result.docs, result.total, result.hasNext);
336
+ */
337
+ async paginate(options = {}) {
338
+ const { filters = {}, sort = { _id: -1 }, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = "exact", readPreference } = options;
339
+ const sanitizedPage = validatePage(page, this.config);
340
+ const sanitizedLimit = validateLimit(limit, this.config);
341
+ const skip = calculateSkip(sanitizedPage, sanitizedLimit);
342
+ const fetchLimit = countStrategy === "none" ? sanitizedLimit + 1 : sanitizedLimit;
343
+ let query = this.Model.find(filters);
344
+ if (select) query = query.select(select);
345
+ if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
346
+ query = query.sort(sort).skip(skip).limit(fetchLimit).lean(lean);
347
+ if (session) query = query.session(session);
348
+ if (hint) query = query.hint(hint);
349
+ if (maxTimeMS) query = query.maxTimeMS(maxTimeMS);
350
+ if (readPreference) query = query.read(readPreference);
351
+ const hasFilters = Object.keys(filters).length > 0;
352
+ const useEstimated = this.config.useEstimatedCount && !hasFilters;
353
+ let countPromise;
354
+ if (countStrategy === "estimated" || useEstimated && countStrategy !== "exact") countPromise = this.Model.estimatedDocumentCount();
355
+ else if (countStrategy === "exact") {
356
+ const countQuery = this.Model.countDocuments(filters).session(session ?? null);
357
+ if (hint) countQuery.hint(hint);
358
+ if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
359
+ if (readPreference) countQuery.read(readPreference);
360
+ countPromise = countQuery.exec();
361
+ } else countPromise = Promise.resolve(0);
362
+ const [docs, total] = await Promise.all([query.exec(), countPromise]);
363
+ const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
364
+ let hasNext;
365
+ if (countStrategy === "none") {
366
+ hasNext = docs.length > sanitizedLimit;
367
+ if (hasNext) docs.pop();
368
+ } else hasNext = sanitizedPage < totalPages;
369
+ const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : void 0;
370
+ return {
371
+ method: "offset",
372
+ docs,
373
+ page: sanitizedPage,
374
+ limit: sanitizedLimit,
375
+ total,
376
+ pages: totalPages,
377
+ hasNext,
378
+ hasPrev: sanitizedPage > 1,
379
+ ...warning && { warning }
380
+ };
381
+ }
382
+ /**
383
+ * Keyset (cursor-based) pagination for high-performance streaming
384
+ * Best for large datasets, infinite scroll, real-time feeds
385
+ * O(1) performance - consistent speed regardless of position
386
+ *
387
+ * @param options - Pagination options (sort is required)
388
+ * @returns Pagination result with next cursor
389
+ *
390
+ * @example
391
+ * // First page
392
+ * const page1 = await engine.stream({
393
+ * sort: { createdAt: -1 },
394
+ * limit: 20
395
+ * });
396
+ *
397
+ * // Next page using cursor
398
+ * const page2 = await engine.stream({
399
+ * sort: { createdAt: -1 },
400
+ * after: page1.next,
401
+ * limit: 20
402
+ * });
403
+ */
404
+ async stream(options) {
405
+ const { filters = {}, sort, after, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, readPreference } = options;
406
+ if (!sort) throw createError(400, "sort is required for keyset pagination");
407
+ const sanitizedLimit = validateLimit(limit, this.config);
408
+ const normalizedSort = validateKeysetSort(sort);
409
+ const filterKeys = Object.keys(filters).filter((k) => !k.startsWith("$"));
410
+ const sortFields = Object.keys(normalizedSort);
411
+ if (filterKeys.length > 0 && sortFields.length > 0) {
412
+ const indexFields = [...filterKeys.map((f) => `${f}: 1`), ...sortFields.map((f) => `${f}: ${normalizedSort[f]}`)];
413
+ warn(`[mongokit] Keyset pagination with filters [${filterKeys.join(", ")}] and sort [${sortFields.join(", ")}] requires a compound index for O(1) performance. Ensure index exists: { ${indexFields.join(", ")} }`);
414
+ }
415
+ let query = { ...filters };
416
+ if (after) {
417
+ const cursor = decodeCursor(after);
418
+ validateCursorVersion(cursor.version, this.config.cursorVersion);
419
+ validateCursorSort(cursor.sort, normalizedSort);
420
+ query = buildKeysetFilter(query, normalizedSort, cursor.value, cursor.id);
421
+ }
422
+ let mongoQuery = this.Model.find(query);
423
+ if (select) mongoQuery = mongoQuery.select(select);
424
+ if (populate && (Array.isArray(populate) ? populate.length : populate)) mongoQuery = mongoQuery.populate(populate);
425
+ mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
426
+ if (session) mongoQuery = mongoQuery.session(session);
427
+ if (hint) mongoQuery = mongoQuery.hint(hint);
428
+ if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
429
+ if (readPreference) mongoQuery = mongoQuery.read(readPreference);
430
+ const docs = await mongoQuery.exec();
431
+ const hasMore = docs.length > sanitizedLimit;
432
+ if (hasMore) docs.pop();
433
+ const primaryField = getPrimaryField(normalizedSort);
434
+ return {
435
+ method: "keyset",
436
+ docs,
437
+ limit: sanitizedLimit,
438
+ hasMore,
439
+ next: hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null
440
+ };
441
+ }
442
+ /**
443
+ * Aggregate pipeline with pagination
444
+ * Best for complex queries requiring aggregation stages
445
+ * Uses $facet to combine results and count in single query
446
+ *
447
+ * @param options - Aggregation options
448
+ * @returns Pagination result with total count
449
+ *
450
+ * @example
451
+ * const result = await engine.aggregatePaginate({
452
+ * pipeline: [
453
+ * { $match: { status: 'active' } },
454
+ * { $group: { _id: '$category', count: { $sum: 1 } } },
455
+ * { $sort: { count: -1 } }
456
+ * ],
457
+ * page: 1,
458
+ * limit: 20
459
+ * });
460
+ */
461
+ async aggregatePaginate(options = {}) {
462
+ const { pipeline = [], page = 1, limit = this.config.defaultLimit, session, hint, maxTimeMS, countStrategy = "exact", readPreference } = options;
463
+ const sanitizedPage = validatePage(page, this.config);
464
+ const sanitizedLimit = validateLimit(limit, this.config);
465
+ const skip = calculateSkip(sanitizedPage, sanitizedLimit);
466
+ const fetchLimit = countStrategy === "none" ? sanitizedLimit + 1 : sanitizedLimit;
467
+ const facetStages = { docs: [{ $skip: skip }, { $limit: fetchLimit }] };
468
+ if (countStrategy !== "none") facetStages.total = [{ $count: "count" }];
469
+ const facetPipeline = [...pipeline, { $facet: facetStages }];
470
+ const aggregation = this.Model.aggregate(facetPipeline);
471
+ if (session) aggregation.session(session);
472
+ if (hint) aggregation.hint(hint);
473
+ if (maxTimeMS) aggregation.option({ maxTimeMS });
474
+ if (readPreference) aggregation.read(readPreference);
475
+ const [result] = await aggregation.exec();
476
+ const docs = result.docs;
477
+ const total = result.total?.[0]?.count || 0;
478
+ const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
479
+ let hasNext;
480
+ if (countStrategy === "none") {
481
+ hasNext = docs.length > sanitizedLimit;
482
+ if (hasNext) docs.pop();
483
+ } else hasNext = sanitizedPage < totalPages;
484
+ const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
485
+ return {
486
+ method: "aggregate",
487
+ docs,
488
+ page: sanitizedPage,
489
+ limit: sanitizedLimit,
490
+ total,
491
+ pages: totalPages,
492
+ hasNext,
493
+ hasPrev: sanitizedPage > 1,
494
+ ...warning && { warning }
495
+ };
496
+ }
497
+ };
498
+ //#endregion
499
+ export { PaginationEngine as t };
@@ -1,9 +1,2 @@
1
- import "../types-pVY0w1Pp.mjs";
2
- import { a as create_d_exports, i as read_d_exports, n as delete_d_exports, r as update_d_exports, t as aggregate_d_exports } from "../aggregate-BkOG9qwr.mjs";
3
-
4
- //#region src/actions/index.d.ts
5
- declare namespace index_d_exports {
6
- export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
7
- }
8
- //#endregion
9
- export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, index_d_exports as t, update_d_exports as update };
1
+ import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-Df3ernpC.mjs";
2
+ export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
@@ -1,6 +1,5 @@
1
- import { t as __exportAll } from "../chunk-DQk6qfdC.mjs";
2
- import { a as delete_exports, g as create_exports, p as read_exports, s as update_exports, t as aggregate_exports } from "../aggregate-BClp040M.mjs";
3
-
1
+ import { t as __exportAll } from "../chunk-CfYAbeIz.mjs";
2
+ import { c as read_exports, h as aggregate_exports, n as update_exports, p as create_exports, u as delete_exports } from "../update-DXwVh6M1.mjs";
4
3
  //#region src/actions/index.ts
5
4
  var actions_exports = /* @__PURE__ */ __exportAll({
6
5
  aggregate: () => aggregate_exports,
@@ -9,6 +8,5 @@ var actions_exports = /* @__PURE__ */ __exportAll({
9
8
  read: () => read_exports,
10
9
  update: () => update_exports
11
10
  });
12
-
13
11
  //#endregion
14
- export { aggregate_exports as aggregate, create_exports as create, delete_exports as deleteActions, read_exports as read, actions_exports as t, update_exports as update };
12
+ export { aggregate_exports as aggregate, create_exports as create, delete_exports as deleteActions, read_exports as read, actions_exports as t, update_exports as update };
@@ -1,4 +1,4 @@
1
- import { H as Plugin } from "../types-pVY0w1Pp.mjs";
1
+ import { H as Plugin } from "../types-BlCwDszq.mjs";
2
2
  import { ClientSession, PipelineStage } from "mongoose";
3
3
 
4
4
  //#region src/ai/types.d.ts
package/dist/ai/index.mjs CHANGED
@@ -198,6 +198,5 @@ function vectorPlugin(options) {
198
198
  }
199
199
  };
200
200
  }
201
-
202
201
  //#endregion
203
- export { buildVectorSearchPipeline, vectorPlugin };
202
+ export { buildVectorSearchPipeline, vectorPlugin };
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -1,23 +1,3 @@
1
- //#region src/utils/error.ts
2
- /**
3
- * Creates an error with HTTP status code
4
- *
5
- * @param status - HTTP status code
6
- * @param message - Error message
7
- * @returns Error with status property
8
- *
9
- * @example
10
- * throw createError(404, 'Document not found');
11
- * throw createError(400, 'Invalid input');
12
- * throw createError(403, 'Access denied');
13
- */
14
- function createError(status, message) {
15
- const error = new Error(message);
16
- error.status = status;
17
- return error;
18
- }
19
-
20
- //#endregion
21
1
  //#region src/utils/logger.ts
22
2
  const noop = () => {};
23
3
  let current = {
@@ -46,6 +26,38 @@ function warn(message, ...args) {
46
26
  function debug(message, ...args) {
47
27
  current.debug(message, ...args);
48
28
  }
49
-
50
29
  //#endregion
51
- export { createError as i, debug as n, warn as r, configureLogger as t };
30
+ //#region src/utils/error.ts
31
+ /**
32
+ * Creates an error with HTTP status code
33
+ *
34
+ * @param status - HTTP status code
35
+ * @param message - Error message
36
+ * @returns Error with status property
37
+ *
38
+ * @example
39
+ * throw createError(404, 'Document not found');
40
+ * throw createError(400, 'Invalid input');
41
+ * throw createError(403, 'Access denied');
42
+ */
43
+ function createError(status, message) {
44
+ const error = new Error(message);
45
+ error.status = status;
46
+ return error;
47
+ }
48
+ /**
49
+ * Detect and convert a MongoDB E11000 duplicate-key error into
50
+ * a 409 HttpError with an actionable message.
51
+ *
52
+ * Returns `null` when the error is not a duplicate-key error.
53
+ */
54
+ function parseDuplicateKeyError(error) {
55
+ if (!error || typeof error !== "object") return null;
56
+ const mongoErr = error;
57
+ if (mongoErr.code !== 11e3) return null;
58
+ const fields = mongoErr.keyPattern ? Object.keys(mongoErr.keyPattern) : [];
59
+ const values = mongoErr.keyValue ? Object.entries(mongoErr.keyValue).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
60
+ return createError(409, fields.length ? `Duplicate value for ${fields.join(", ")}${values ? ` (${values})` : ""}` : "Duplicate key error");
61
+ }
62
+ //#endregion
63
+ export { warn as a, debug as i, parseDuplicateKeyError as n, configureLogger as r, createError as t };