@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 +135 -5
- package/dist/{limits-s1-d8rWb.mjs → PaginationEngine-PLyDhrO7.mjs} +260 -60
- package/dist/actions/index.d.mts +2 -9
- package/dist/actions/index.mjs +3 -5
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +1 -2
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
- package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-CalOB7yM.mjs} +110 -112
- package/dist/{aggregate-BkOG9qwr.d.mts → index-Df3ernpC.d.mts} +132 -129
- package/dist/index.d.mts +543 -543
- package/dist/index.mjs +25 -100
- package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-BqgVOlrR.d.mts} +24 -17
- package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-OmdmnHtx.mjs} +13 -12
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +2 -209
- package/dist/plugins/index.d.mts +1 -2
- package/dist/plugins/index.mjs +2 -3
- package/dist/{types-pVY0w1Pp.d.mts → types-BlCwDszq.d.mts} +25 -23
- package/dist/{aggregate-BClp040M.mjs → update-DXwVh6M1.mjs} +674 -671
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +4 -5
- package/dist/{custom-id.plugin-BJ3FSnzt.d.mts → validation-chain.plugin-DxqiHv-E.d.mts} +832 -832
- package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-Ow6EUIoo.mjs} +2272 -2210
- package/package.json +10 -5
- package/dist/chunk-DQk6qfdC.mjs +0 -18
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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 };
|
package/dist/actions/index.d.mts
CHANGED
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import "../
|
|
2
|
-
|
|
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 };
|
package/dist/actions/index.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { t as __exportAll } from "../chunk-
|
|
2
|
-
import {
|
|
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 };
|
package/dist/ai/index.d.mts
CHANGED
package/dist/ai/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|