@classytic/mongokit 3.39.0 → 3.40.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.
- package/README.md +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
- package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
- package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +105 -39
- package/dist/index.mjs +332 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
- package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +6 -4
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
- package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +98 -25
- package/dist/query-defaults-BxUcSOoF.mjs +208 -0
- package/dist/read-model/index.d.mts +66 -0
- package/dist/read-model/index.mjs +193 -0
- package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
- package/dist/sync/index.d.mts +53 -0
- package/dist/sync/index.mjs +371 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
- package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/transaction-BrRYtH_b.mjs +655 -0
- package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
- package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
- package/package.json +18 -4
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
2
|
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
3
3
|
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
4
|
+
import { i as applyToQuery, r as applyToAggregate, u as resolveQueryOptions } from "./query-defaults-BxUcSOoF.mjs";
|
|
4
5
|
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./pagination/utils/limits.mjs";
|
|
5
6
|
import mongoose from "mongoose";
|
|
6
|
-
import {
|
|
7
|
+
import { createTtlMemo } from "@classytic/repo-core/cache";
|
|
8
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { CURSOR_ERROR_CODES } from "@classytic/repo-core/pagination";
|
|
7
10
|
//#region src/pagination/utils/cursor-signing.ts
|
|
8
11
|
/**
|
|
9
12
|
* HMAC signing for pagination cursors.
|
|
@@ -119,14 +122,6 @@ function verifySignature(token, secrets) {
|
|
|
119
122
|
for (const key of secrets) if (matches(signature, sign(payload, key))) return payload;
|
|
120
123
|
throw new Error("Invalid cursor token: signature does not verify (tampered, or issued under a retired key)");
|
|
121
124
|
}
|
|
122
|
-
/**
|
|
123
|
-
* Whether cursor signing is switched on. Callers use it to refuse the shapes
|
|
124
|
-
* that BYPASS signing rather than break it — a bare ObjectId is a perfectly
|
|
125
|
-
* valid position that carries no signature at all.
|
|
126
|
-
*/
|
|
127
|
-
function signingRequired(secrets) {
|
|
128
|
-
return secrets !== void 0;
|
|
129
|
-
}
|
|
130
125
|
//#endregion
|
|
131
126
|
//#region src/pagination/defaults.ts
|
|
132
127
|
/**
|
|
@@ -191,6 +186,8 @@ function stripUndefined(input) {
|
|
|
191
186
|
if (input.cursorSecret !== void 0) out.cursorSecret = input.cursorSecret;
|
|
192
187
|
if (input.defaultMode !== void 0) out.defaultMode = input.defaultMode;
|
|
193
188
|
if (input.maxPage !== void 0) out.maxPage = input.maxPage;
|
|
189
|
+
if (input.maxOffset !== void 0) out.maxOffset = input.maxOffset;
|
|
190
|
+
if (input.countCacheTtlMs !== void 0) out.countCacheTtlMs = input.countCacheTtlMs;
|
|
194
191
|
return out;
|
|
195
192
|
}
|
|
196
193
|
/**
|
|
@@ -207,6 +204,8 @@ function bindPaginationDefaults(resolved, config) {
|
|
|
207
204
|
define(resolved, "cursorSecret", config.cursorSecret, void 0);
|
|
208
205
|
define(resolved, "defaultMode", config.defaultMode, void 0);
|
|
209
206
|
define(resolved, "maxPage", config.maxPage, 1e4);
|
|
207
|
+
define(resolved, "maxOffset", config.maxOffset, 1e5);
|
|
208
|
+
define(resolved, "countCacheTtlMs", config.countCacheTtlMs, 3e4);
|
|
210
209
|
return resolved;
|
|
211
210
|
}
|
|
212
211
|
function define(target, key, explicit, fallback) {
|
|
@@ -329,6 +328,125 @@ function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValue
|
|
|
329
328
|
return combine(baseFilters, { $or: orConditions });
|
|
330
329
|
}
|
|
331
330
|
//#endregion
|
|
331
|
+
//#region src/pagination/utils/guards.ts
|
|
332
|
+
/**
|
|
333
|
+
* Pagination guards shared by every paged read: the deep-offset refusal, the unique `_id`
|
|
334
|
+
* tiebreaker that makes a sort a total order, and the cursor SCOPE fingerprint — a hash of
|
|
335
|
+
* (collection, post-policy filter, collation) carried in every cursor, so a cursor replayed
|
|
336
|
+
* against another collection, tenant, filter or collation is refused, never re-anchored.
|
|
337
|
+
*/
|
|
338
|
+
const PAGINATION_ERROR_CODES = {
|
|
339
|
+
OFFSET_TOO_DEEP: "mongokit.pagination.offset_too_deep",
|
|
340
|
+
/** repo-core's shared cursor vocabulary (one code set across kits and merged cursors). */
|
|
341
|
+
CURSOR_INVALID: CURSOR_ERROR_CODES.INVALID,
|
|
342
|
+
CURSOR_SCOPE_MISMATCH: CURSOR_ERROR_CODES.SCOPE_MISMATCH
|
|
343
|
+
};
|
|
344
|
+
/** Refuse an offset page whose skip exceeds the cap: the scan cost grows with the skip. */
|
|
345
|
+
function assertOffsetWithinCap(skip, maxOffset) {
|
|
346
|
+
if (skip > maxOffset) throw createError(400, `Offset ${skip} exceeds the deepest allowed offset (${maxOffset}); page with keyset (sort + after) instead`, {
|
|
347
|
+
code: PAGINATION_ERROR_CODES.OFFSET_TOO_DEEP,
|
|
348
|
+
meta: {
|
|
349
|
+
skip,
|
|
350
|
+
maxOffset
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Append `_id` (in the last key's direction) unless the sort already ends on it. */
|
|
355
|
+
function withIdTiebreak(sort) {
|
|
356
|
+
const keys = Object.keys(sort);
|
|
357
|
+
if (keys.length === 0 || keys.includes("_id")) return sort;
|
|
358
|
+
return {
|
|
359
|
+
...sort,
|
|
360
|
+
_id: sort[keys[keys.length - 1]] ?? 1
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** Append each group key (ascending) that the sort does not already name: IR rows are unique per group. */
|
|
364
|
+
function withGroupTiebreak(sort, groupKeys) {
|
|
365
|
+
const out = { ...sort };
|
|
366
|
+
for (const key of groupKeys) if (!(key in out)) out[key] = 1;
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
function invalidCursor(message) {
|
|
370
|
+
return createError(400, `Invalid cursor: ${message}`, { code: PAGINATION_ERROR_CODES.CURSOR_INVALID });
|
|
371
|
+
}
|
|
372
|
+
function cursorScopeMismatch() {
|
|
373
|
+
return createError(400, "Invalid cursor: it was issued for a different collection, tenant, filter or collation", { code: PAGINATION_ERROR_CODES.CURSOR_SCOPE_MISMATCH });
|
|
374
|
+
}
|
|
375
|
+
function canonical(value) {
|
|
376
|
+
if (value === null || value === void 0) return null;
|
|
377
|
+
if (value instanceof Date) return { $date: value.toISOString() };
|
|
378
|
+
if (value instanceof RegExp) return { $regex: value.toString() };
|
|
379
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
380
|
+
if (typeof value === "object") {
|
|
381
|
+
const hex = value.toHexString;
|
|
382
|
+
if (typeof hex === "function") return { $oid: hex.call(value) };
|
|
383
|
+
const bsontype = value._bsontype;
|
|
384
|
+
if (bsontype) return { [`$${bsontype}`]: String(value) };
|
|
385
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
/** 16-hex fingerprint of what a cursor's position is relative to. */
|
|
390
|
+
function cursorScope(collection, filters, collation) {
|
|
391
|
+
return createHash("sha256").update(JSON.stringify([
|
|
392
|
+
collection,
|
|
393
|
+
canonical(filters),
|
|
394
|
+
canonical(collation)
|
|
395
|
+
])).digest("hex").slice(0, 16);
|
|
396
|
+
}
|
|
397
|
+
/** Stages after which `_id` no longer identifies one output row (or no longer exists as given). */
|
|
398
|
+
const IDENTITY_CHANGING = /* @__PURE__ */ new Set([
|
|
399
|
+
"$unwind",
|
|
400
|
+
"$group",
|
|
401
|
+
"$replaceRoot",
|
|
402
|
+
"$replaceWith",
|
|
403
|
+
"$unionWith",
|
|
404
|
+
"$bucket",
|
|
405
|
+
"$bucketAuto",
|
|
406
|
+
"$sortByCount",
|
|
407
|
+
"$facet",
|
|
408
|
+
"$densify",
|
|
409
|
+
"$fill",
|
|
410
|
+
"$documents"
|
|
411
|
+
]);
|
|
412
|
+
function rewritesId(stage) {
|
|
413
|
+
for (const op of [
|
|
414
|
+
"$project",
|
|
415
|
+
"$addFields",
|
|
416
|
+
"$set"
|
|
417
|
+
]) {
|
|
418
|
+
const body = stage[op];
|
|
419
|
+
if (body && typeof body === "object" && "_id" in body) return true;
|
|
420
|
+
}
|
|
421
|
+
return stage.$unset === "_id" || Array.isArray(stage.$unset) && stage.$unset.includes("_id");
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Whether an offset page over `pipeline` is PROVABLY totally ordered: its last `$sort` contains
|
|
425
|
+
* `_id`, and no stage before that sort changes what `_id` identifies. A heuristic: a sort can be
|
|
426
|
+
* total by domain knowledge (`{ date, entry, line }`), which only the caller can state.
|
|
427
|
+
*/
|
|
428
|
+
function pipelineSortIsTotal(pipeline) {
|
|
429
|
+
let lastSort = -1;
|
|
430
|
+
pipeline.forEach((s, i) => {
|
|
431
|
+
if (s && typeof s === "object" && "$sort" in s) lastSort = i;
|
|
432
|
+
});
|
|
433
|
+
if (lastSort === -1) return false;
|
|
434
|
+
if (!("_id" in pipeline[lastSort].$sort)) return false;
|
|
435
|
+
return pipeline.slice(0, lastSort).every((s) => {
|
|
436
|
+
const stage = s ?? {};
|
|
437
|
+
return !Object.keys(stage).some((k) => IDENTITY_CHANGING.has(k)) && !rewritesId(stage);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const warnedSortSites = /* @__PURE__ */ new Set();
|
|
441
|
+
/** Warn once per call site (model + pipeline shape) that an offset aggregate page may repeat or skip rows. */
|
|
442
|
+
function warnIfSortNotTotal(model, pipeline, warnFn) {
|
|
443
|
+
if (pipelineSortIsTotal(pipeline)) return;
|
|
444
|
+
const site = `${model}:${JSON.stringify(pipeline.map((s) => s && typeof s === "object" ? Object.keys(s) : s))}:${JSON.stringify(pipeline.filter((s) => s && typeof s === "object" && "$sort" in s))}`;
|
|
445
|
+
if (warnedSortSites.has(site)) return;
|
|
446
|
+
warnedSortSites.add(site);
|
|
447
|
+
warnFn(`[mongokit] aggregatePipelinePaginate on '${model}': the page sort is not provably total, so rows that tie can repeat or be skipped across pages. End the \$sort on a key unique per output row, then pass \`sortIsTotal: true\` (or sort on _id with no \$unwind/\$group/_id rewrite before the sort).`);
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
332
450
|
//#region src/pagination/utils/cursor.ts
|
|
333
451
|
/**
|
|
334
452
|
* Cursor Utilities
|
|
@@ -353,7 +471,7 @@ var cursor_exports = /* @__PURE__ */ __exportAll({
|
|
|
353
471
|
* @param version - Cursor version for forward compatibility
|
|
354
472
|
* @returns Base64-encoded cursor token
|
|
355
473
|
*/
|
|
356
|
-
function encodeCursor(doc, primaryField, sort, version
|
|
474
|
+
function encodeCursor(doc, primaryField, sort, version, scope, secret) {
|
|
357
475
|
const primaryValue = readSortValue(doc, primaryField);
|
|
358
476
|
const idValue = doc._id;
|
|
359
477
|
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
@@ -371,6 +489,7 @@ function encodeCursor(doc, primaryField, sort, version = 1, secret) {
|
|
|
371
489
|
idType: getValueType(idValue),
|
|
372
490
|
sort,
|
|
373
491
|
ver: version,
|
|
492
|
+
fp: scope,
|
|
374
493
|
...sortFields.length > 1 && {
|
|
375
494
|
vals,
|
|
376
495
|
types
|
|
@@ -396,30 +515,30 @@ function encodeCursor(doc, primaryField, sort, version = 1, secret) {
|
|
|
396
515
|
* @throws Error if token is invalid or malformed
|
|
397
516
|
*/
|
|
398
517
|
function decodeCursor(token, secret) {
|
|
399
|
-
|
|
400
|
-
let json;
|
|
518
|
+
let verified;
|
|
401
519
|
try {
|
|
402
|
-
|
|
403
|
-
} catch {
|
|
404
|
-
throw
|
|
520
|
+
verified = verifySignature(token, resolveCursorSecrets(secret));
|
|
521
|
+
} catch (err) {
|
|
522
|
+
throw invalidCursor(err instanceof Error ? err.message : "signature check failed");
|
|
405
523
|
}
|
|
406
524
|
let payload;
|
|
407
525
|
try {
|
|
408
|
-
payload = JSON.parse(
|
|
526
|
+
payload = JSON.parse(Buffer.from(verified, "base64").toString("utf-8"));
|
|
409
527
|
} catch {
|
|
410
|
-
throw
|
|
528
|
+
throw invalidCursor("not a cursor token");
|
|
411
529
|
}
|
|
412
|
-
if (!payload || typeof payload !== "object" || !("v" in payload) || !("t" in payload) || !("id" in payload) || !("idType" in payload) || !payload.sort || typeof payload.sort !== "object" || typeof payload.ver !== "number") throw
|
|
530
|
+
if (!payload || typeof payload !== "object" || !("v" in payload) || !("t" in payload) || !("id" in payload) || !("idType" in payload) || !payload.sort || typeof payload.sort !== "object" || typeof payload.ver !== "number" || typeof payload.fp !== "string") throw invalidCursor("malformed payload");
|
|
413
531
|
const VALID_TYPES = [
|
|
414
532
|
"date",
|
|
415
533
|
"objectid",
|
|
534
|
+
"decimal",
|
|
416
535
|
"boolean",
|
|
417
536
|
"number",
|
|
418
537
|
"string",
|
|
419
538
|
"null",
|
|
420
539
|
"unknown"
|
|
421
540
|
];
|
|
422
|
-
if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw
|
|
541
|
+
if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw invalidCursor("unrecognized value type");
|
|
423
542
|
try {
|
|
424
543
|
let values;
|
|
425
544
|
if (payload.vals && payload.types) {
|
|
@@ -431,10 +550,11 @@ function decodeCursor(token, secret) {
|
|
|
431
550
|
id: rehydrateValue(payload.id, payload.idType),
|
|
432
551
|
sort: payload.sort,
|
|
433
552
|
version: payload.ver,
|
|
553
|
+
scope: payload.fp,
|
|
434
554
|
...values && { values }
|
|
435
555
|
};
|
|
436
556
|
} catch {
|
|
437
|
-
throw
|
|
557
|
+
throw invalidCursor("failed to rehydrate values");
|
|
438
558
|
}
|
|
439
559
|
}
|
|
440
560
|
/**
|
|
@@ -445,7 +565,7 @@ function decodeCursor(token, secret) {
|
|
|
445
565
|
* @throws Error if sorts don't match
|
|
446
566
|
*/
|
|
447
567
|
function validateCursorSort(cursorSort, currentSort) {
|
|
448
|
-
if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw
|
|
568
|
+
if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw invalidCursor("its sort does not match the query sort");
|
|
449
569
|
}
|
|
450
570
|
/**
|
|
451
571
|
* Validates cursor version against the server's expected range.
|
|
@@ -458,8 +578,8 @@ function validateCursorSort(cursorSort, currentSort) {
|
|
|
458
578
|
* silently paginating from the wrong position.
|
|
459
579
|
*/
|
|
460
580
|
function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
|
|
461
|
-
if (cursorVersion > expectedVersion) throw
|
|
462
|
-
if (cursorVersion < minVersion) throw
|
|
581
|
+
if (cursorVersion > expectedVersion) throw invalidCursor(`version ${cursorVersion} is newer than expected version ${expectedVersion}`);
|
|
582
|
+
if (cursorVersion < minVersion) throw invalidCursor(`version ${cursorVersion} is older than minimum supported ${minVersion}; restart pagination`);
|
|
463
583
|
}
|
|
464
584
|
/**
|
|
465
585
|
* Read a sort field off the last document of a page, following a dotted path.
|
|
@@ -481,6 +601,10 @@ function readSortValue(doc, field) {
|
|
|
481
601
|
}
|
|
482
602
|
return current;
|
|
483
603
|
}
|
|
604
|
+
/** A BSON Decimal128 from any driver copy (lean reads return the driver's own class). */
|
|
605
|
+
function isDecimal(value) {
|
|
606
|
+
return value instanceof mongoose.Types.Decimal128 || typeof value === "object" && value !== null && value._bsontype === "Decimal128";
|
|
607
|
+
}
|
|
484
608
|
/**
|
|
485
609
|
* Serializes a value for cursor storage
|
|
486
610
|
*/
|
|
@@ -488,6 +612,7 @@ function serializeValue(value) {
|
|
|
488
612
|
if (value === null || value === void 0) return null;
|
|
489
613
|
if (value instanceof Date) return value.toISOString();
|
|
490
614
|
if (value instanceof mongoose.Types.ObjectId) return value.toString();
|
|
615
|
+
if (isDecimal(value)) return value.toString();
|
|
491
616
|
return value;
|
|
492
617
|
}
|
|
493
618
|
/**
|
|
@@ -497,6 +622,7 @@ function getValueType(value) {
|
|
|
497
622
|
if (value === null || value === void 0) return "null";
|
|
498
623
|
if (value instanceof Date) return "date";
|
|
499
624
|
if (value instanceof mongoose.Types.ObjectId) return "objectid";
|
|
625
|
+
if (isDecimal(value)) return "decimal";
|
|
500
626
|
if (typeof value === "boolean") return "boolean";
|
|
501
627
|
if (typeof value === "number") return "number";
|
|
502
628
|
if (typeof value === "string") return "string";
|
|
@@ -510,38 +636,21 @@ function rehydrateValue(serialized, type) {
|
|
|
510
636
|
switch (type) {
|
|
511
637
|
case "date": return new Date(serialized);
|
|
512
638
|
case "objectid": return new mongoose.Types.ObjectId(serialized);
|
|
639
|
+
case "decimal": return mongoose.Types.Decimal128.fromString(serialized);
|
|
513
640
|
case "boolean": return serialized === true || serialized === "true";
|
|
514
641
|
case "number": return Number(serialized);
|
|
515
642
|
default: return serialized;
|
|
516
643
|
}
|
|
517
644
|
}
|
|
518
645
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* - Plain 24-char hex ObjectId strings (fallback cursor)
|
|
524
|
-
* - Base64-encoded cursor tokens (standard cursor)
|
|
525
|
-
* - Cursor version and sort validation
|
|
646
|
+
* Resolve a cursor token into the keyset query filter. Shared by `PaginationEngine.stream()` and
|
|
647
|
+
* `Repository.lookupPopulate()`. The token must carry the SAME scope fingerprint as this call
|
|
648
|
+
* (`cursorScope`), else it is refused: a position is only meaningful within the collection,
|
|
649
|
+
* filter and collation that produced it. Every refusal is a 400 with a closed code.
|
|
526
650
|
*/
|
|
527
|
-
function resolveCursorFilter(after, sort, cursorVersion, baseFilters
|
|
528
|
-
const secrets = resolveCursorSecrets(secret);
|
|
529
|
-
if (/^[a-f0-9]{24}$/i.test(after)) {
|
|
530
|
-
/**
|
|
531
|
-
* The bare-ObjectId fallback is an UNSIGNED position by construction, so a
|
|
532
|
-
* deployment that signs must refuse it — otherwise the signature
|
|
533
|
-
* requirement ships with a documented bypass: send a raw id instead of a
|
|
534
|
-
* token and skip verification entirely.
|
|
535
|
-
*/
|
|
536
|
-
if (signingRequired(secrets)) throw new Error("Invalid cursor token: this deployment signs cursors, so a bare ObjectId is not accepted as one");
|
|
537
|
-
const objectId = new mongoose.Types.ObjectId(after);
|
|
538
|
-
const idOperator = (buildSort._id || -1) === 1 ? "$gt" : "$lt";
|
|
539
|
-
return {
|
|
540
|
-
...baseFilters,
|
|
541
|
-
_id: { [idOperator]: objectId }
|
|
542
|
-
};
|
|
543
|
-
}
|
|
651
|
+
function resolveCursorFilter(after, sort, cursorVersion, baseFilters, minCursorVersion, buildSort, scope, secret) {
|
|
544
652
|
const cursor = decodeCursor(after, secret);
|
|
653
|
+
if (cursor.scope !== scope) throw cursorScopeMismatch();
|
|
545
654
|
validateCursorVersion(cursor.version, cursorVersion, minCursorVersion);
|
|
546
655
|
validateCursorSort(cursor.sort, sort);
|
|
547
656
|
return buildKeysetFilter(baseFilters, buildSort, cursor.value, cursor.id, cursor.values);
|
|
@@ -602,6 +711,20 @@ function classifyFilterFields(filters) {
|
|
|
602
711
|
};
|
|
603
712
|
}
|
|
604
713
|
/**
|
|
714
|
+
* The ESR index to recommend for a keyset query: equality → sort (incl. the `_id`
|
|
715
|
+
* tiebreaker) → range, each field ONCE. A filtered field that is also a sort key
|
|
716
|
+
* takes its sort position — Mongo rejects an index naming a key twice.
|
|
717
|
+
*/
|
|
718
|
+
function recommendKeysetIndex(filters, sort) {
|
|
719
|
+
const { equality, range } = classifyFilterFields(filters);
|
|
720
|
+
const notSorted = (f) => !Object.hasOwn(sort, f);
|
|
721
|
+
return [
|
|
722
|
+
...equality.filter(notSorted).map((f) => [f, 1]),
|
|
723
|
+
...Object.entries(sort),
|
|
724
|
+
...range.filter(notSorted).map((f) => [f, 1])
|
|
725
|
+
];
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
605
728
|
* Read a Mongoose schema's declared indexes, defensively.
|
|
606
729
|
* Returns an empty array on any introspection failure.
|
|
607
730
|
*/
|
|
@@ -641,8 +764,9 @@ function readSchemaIndexes(Model) {
|
|
|
641
764
|
function hasCompatibleKeysetIndex(indexes, filterFields, sort) {
|
|
642
765
|
const sortKeys = Object.keys(sort);
|
|
643
766
|
if (sortKeys.length === 0) return true;
|
|
644
|
-
const
|
|
645
|
-
const
|
|
767
|
+
const prefixFields = filterFields.filter((f) => !Object.hasOwn(sort, f));
|
|
768
|
+
const filterSet = new Set(prefixFields);
|
|
769
|
+
const prefixLen = prefixFields.length;
|
|
646
770
|
for (const [spec] of indexes) {
|
|
647
771
|
if (!spec || typeof spec !== "object") continue;
|
|
648
772
|
const entries = Object.entries(spec);
|
|
@@ -761,6 +885,30 @@ function getPrimaryField(sort) {
|
|
|
761
885
|
//#endregion
|
|
762
886
|
//#region src/pagination/PaginationEngine.ts
|
|
763
887
|
/**
|
|
888
|
+
* Pagination Engine
|
|
889
|
+
*
|
|
890
|
+
* Production-grade pagination for MongoDB with support for:
|
|
891
|
+
* - Offset pagination (page-based) - Best for small datasets, random page access
|
|
892
|
+
* - Keyset pagination (cursor-based) - Best for large datasets, infinite scroll
|
|
893
|
+
* - Aggregate pagination - Best for complex queries requiring aggregation
|
|
894
|
+
*
|
|
895
|
+
* @example
|
|
896
|
+
* ```typescript
|
|
897
|
+
* const engine = new PaginationEngine(UserModel, {
|
|
898
|
+
* defaultLimit: 20,
|
|
899
|
+
* maxLimit: 100,
|
|
900
|
+
* useEstimatedCount: true
|
|
901
|
+
* });
|
|
902
|
+
*
|
|
903
|
+
* // Offset pagination
|
|
904
|
+
* const page1 = await engine.paginate({ page: 1, limit: 20 });
|
|
905
|
+
*
|
|
906
|
+
* // Keyset pagination (better for large datasets)
|
|
907
|
+
* const stream1 = await engine.stream({ sort: { createdAt: -1 }, limit: 20 });
|
|
908
|
+
* const stream2 = await engine.stream({ sort: { createdAt: -1 }, after: stream1.next });
|
|
909
|
+
* ```
|
|
910
|
+
*/
|
|
911
|
+
/**
|
|
764
912
|
* The sort fields a reader cares about, for MESSAGES only — never for deciding
|
|
765
913
|
* whether an index is adequate.
|
|
766
914
|
*
|
|
@@ -818,10 +966,6 @@ const DEFAULT_COUNT_LIMIT = 1e4;
|
|
|
818
966
|
function resolveCountLimit(value) {
|
|
819
967
|
return Number.isInteger(value) && value > 0 ? value : DEFAULT_COUNT_LIMIT;
|
|
820
968
|
}
|
|
821
|
-
/**
|
|
822
|
-
* Production-grade pagination engine for MongoDB
|
|
823
|
-
* Supports offset, keyset (cursor), and aggregate pagination
|
|
824
|
-
*/
|
|
825
969
|
var PaginationEngine = class {
|
|
826
970
|
Model;
|
|
827
971
|
config;
|
|
@@ -836,6 +980,23 @@ var PaginationEngine = class {
|
|
|
836
980
|
* @param Model - Mongoose model to paginate
|
|
837
981
|
* @param config - Pagination configuration
|
|
838
982
|
*/
|
|
983
|
+
/** Memoised capped counts for `countStrategy: 'cached'`, rebuilt when the TTL changes. */
|
|
984
|
+
_memo;
|
|
985
|
+
/** The `cached` count memo (repo-core `createTtlMemo`: single flight, a failed count caches nothing). */
|
|
986
|
+
_countMemo() {
|
|
987
|
+
const ttl = this.config.countCacheTtlMs;
|
|
988
|
+
if (!this._memo || this._memo.ttl !== ttl) this._memo = {
|
|
989
|
+
ttl,
|
|
990
|
+
memo: createTtlMemo(async (k) => ({
|
|
991
|
+
total: await k.run(),
|
|
992
|
+
countedAt: /* @__PURE__ */ new Date()
|
|
993
|
+
}), {
|
|
994
|
+
ttlMs: ttl,
|
|
995
|
+
keyOf: (k) => k.key
|
|
996
|
+
})
|
|
997
|
+
};
|
|
998
|
+
return this._memo.memo;
|
|
999
|
+
}
|
|
839
1000
|
constructor(Model, config = {}) {
|
|
840
1001
|
this.Model = Model;
|
|
841
1002
|
/**
|
|
@@ -882,73 +1043,67 @@ var PaginationEngine = class {
|
|
|
882
1043
|
* console.log(result.data, result.total, result.hasNext);
|
|
883
1044
|
*/
|
|
884
1045
|
async paginate(options = {}) {
|
|
885
|
-
const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint,
|
|
1046
|
+
const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit, collation } = options;
|
|
1047
|
+
const qo = options.queryOptions ?? resolveQueryOptions("read", options);
|
|
886
1048
|
const sanitizedPage = validatePage(page, this.config);
|
|
887
1049
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
888
1050
|
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
889
|
-
|
|
890
|
-
* Fetch limit+1 whenever the count cannot answer `hasNext`.
|
|
891
|
-
*
|
|
892
|
-
* `none` never counts. `capped` counts only to the ceiling, so once `total`
|
|
893
|
-
* sits AT the ceiling it no longer knows whether a further page exists —
|
|
894
|
-
* deriving `hasNext` from `page < pages` there reports "no more results" in
|
|
895
|
-
* the middle of a collection, which is a wrong answer that looks like a
|
|
896
|
-
* right one. One extra document is the whole cost of avoiding it.
|
|
897
|
-
*/
|
|
898
|
-
const countBounded = countStrategy === "none" || countStrategy === "capped";
|
|
899
|
-
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
1051
|
+
assertOffsetWithinCap(skip, this.config.maxOffset);
|
|
900
1052
|
let query = this.Model.find(filters);
|
|
901
1053
|
if (select) query = query.select(select);
|
|
902
1054
|
if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
|
|
903
|
-
if (sort) query = query.sort(sort);
|
|
904
|
-
query = query.skip(skip).limit(
|
|
1055
|
+
if (sort) query = query.sort(withIdTiebreak(sort));
|
|
1056
|
+
query = query.skip(skip).limit(sanitizedLimit + 1).lean(lean);
|
|
905
1057
|
if (collation) query = query.collation(collation);
|
|
906
1058
|
if (session) query = query.session(session);
|
|
907
1059
|
if (hint) query = query.hint(hint);
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const hasFilters = Object.keys(
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
936
|
-
|
|
1060
|
+
applyToQuery(query, qo);
|
|
1061
|
+
const countTarget = countFilters ?? filters;
|
|
1062
|
+
const hasFilters = Object.keys(countTarget).length > 0;
|
|
1063
|
+
const ceiling = resolveCountLimit(countLimit);
|
|
1064
|
+
const countQuery = (bounded) => {
|
|
1065
|
+
const q = this.Model.countDocuments(countTarget).session(session ?? null);
|
|
1066
|
+
if (bounded) q.limit(ceiling);
|
|
1067
|
+
if (collation) q.collation(collation);
|
|
1068
|
+
if (hint) q.hint(hint);
|
|
1069
|
+
applyToQuery(q, qo);
|
|
1070
|
+
return q.exec();
|
|
1071
|
+
};
|
|
1072
|
+
let runCount;
|
|
1073
|
+
const at = (total, totalIsEstimate) => ({
|
|
1074
|
+
total,
|
|
1075
|
+
totalIsEstimate,
|
|
1076
|
+
countedAt: /* @__PURE__ */ new Date()
|
|
1077
|
+
});
|
|
1078
|
+
if ((countStrategy === "estimated" || this.config.useEstimatedCount) && !hasFilters && !session) runCount = async () => at(await this.Model.estimatedDocumentCount(), true);
|
|
1079
|
+
else if (countStrategy === "none") runCount = async () => ({
|
|
1080
|
+
total: 0,
|
|
1081
|
+
totalIsEstimate: true,
|
|
1082
|
+
countedAt: null
|
|
1083
|
+
});
|
|
1084
|
+
else if (countStrategy === "capped" || countStrategy === "cached" && session) runCount = async () => {
|
|
1085
|
+
const total = await countQuery(true);
|
|
1086
|
+
return at(total, total >= ceiling);
|
|
1087
|
+
};
|
|
1088
|
+
else if (countStrategy === "cached") {
|
|
1089
|
+
const key = cursorScope(this.Model.collection.collectionName, [
|
|
1090
|
+
countTarget,
|
|
1091
|
+
ceiling,
|
|
1092
|
+
hint ?? null
|
|
1093
|
+
], collation);
|
|
1094
|
+
runCount = async () => ({
|
|
1095
|
+
...await this._countMemo().get({
|
|
1096
|
+
key,
|
|
1097
|
+
run: () => countQuery(true)
|
|
1098
|
+
}),
|
|
1099
|
+
totalIsEstimate: true
|
|
1100
|
+
});
|
|
1101
|
+
} else runCount = async () => at(await countQuery(false), false);
|
|
1102
|
+
const [data, counted] = session ? [await query.exec(), await runCount()] : await Promise.all([query.exec(), runCount()]);
|
|
1103
|
+
const { total, totalIsEstimate, countedAt } = counted;
|
|
937
1104
|
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
*
|
|
941
|
-
* Reported rather than inferred: `total === countLimit` is also what a
|
|
942
|
-
* collection of exactly that size legitimately returns, so a consumer
|
|
943
|
-
* cannot tell the two apart, and one that guesses renders `10,000+` over an
|
|
944
|
-
* exact 10,000 forever.
|
|
945
|
-
*/
|
|
946
|
-
const totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
|
|
947
|
-
let hasNext;
|
|
948
|
-
if (countBounded) {
|
|
949
|
-
hasNext = data.length > sanitizedLimit;
|
|
950
|
-
if (hasNext) data.pop();
|
|
951
|
-
} else hasNext = sanitizedPage < totalPages;
|
|
1105
|
+
const hasNext = data.length > sanitizedLimit;
|
|
1106
|
+
if (hasNext) data.pop();
|
|
952
1107
|
/**
|
|
953
1108
|
* A CLAMPED limit is reported, not swallowed.
|
|
954
1109
|
*
|
|
@@ -979,7 +1134,8 @@ var PaginationEngine = class {
|
|
|
979
1134
|
pages: totalPages,
|
|
980
1135
|
hasNext,
|
|
981
1136
|
hasPrev: sanitizedPage > 1,
|
|
982
|
-
|
|
1137
|
+
totalIsEstimate,
|
|
1138
|
+
countedAt,
|
|
983
1139
|
...warning && { warning }
|
|
984
1140
|
};
|
|
985
1141
|
}
|
|
@@ -1016,7 +1172,8 @@ var PaginationEngine = class {
|
|
|
1016
1172
|
* });
|
|
1017
1173
|
*/
|
|
1018
1174
|
async stream(options) {
|
|
1019
|
-
const { filters = {}, sort, after, before, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint,
|
|
1175
|
+
const { filters = {}, sort, after, before, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, collation } = options;
|
|
1176
|
+
const qo = options.queryOptions ?? resolveQueryOptions("read", options);
|
|
1020
1177
|
if (!sort) throw createError(400, "sort is required for keyset pagination");
|
|
1021
1178
|
if (after && before) throw createError(400, "Pass `after` or `before`, not both");
|
|
1022
1179
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
@@ -1025,13 +1182,9 @@ var PaginationEngine = class {
|
|
|
1025
1182
|
const effectiveSortFields = sortFieldsForMessage(normalizedSort);
|
|
1026
1183
|
if (process.env.NODE_ENV !== "test" && filterKeys.length > 0 && effectiveSortFields.length > 0) {
|
|
1027
1184
|
const indexes = this._getSchemaIndexes();
|
|
1028
|
-
const { equality
|
|
1185
|
+
const { equality } = classifyFilterFields(filters);
|
|
1029
1186
|
if (!(hasCompatibleKeysetIndex(indexes, filterKeys, normalizedSort) || equality.length !== filterKeys.length && hasCompatibleKeysetIndex(indexes, equality, normalizedSort))) {
|
|
1030
|
-
const indexFields = [
|
|
1031
|
-
...equality.map((f) => `${f}: 1`),
|
|
1032
|
-
...Object.keys(normalizedSort).map((f) => `${f}: ${normalizedSort[f]}`),
|
|
1033
|
-
...range.map((f) => `${f}: 1`)
|
|
1034
|
-
];
|
|
1187
|
+
const indexFields = recommendKeysetIndex(filters, normalizedSort).map(([f, dir]) => `${f}: ${dir}`);
|
|
1035
1188
|
warn(`[mongokit] Keyset pagination with filters [${filterKeys.join(", ")}] and sort [${effectiveSortFields.join(", ")}] has no matching schema-declared compound index. Without one, the tiebreaker forces a BLOCKING SORT over every matching row, on every page. Declare: { ${indexFields.join(", ")} }. (Collection-level indexes created outside the schema are not visible here.)`);
|
|
1036
1189
|
}
|
|
1037
1190
|
}
|
|
@@ -1050,7 +1203,8 @@ var PaginationEngine = class {
|
|
|
1050
1203
|
const querySort = backward ? invertSort(normalizedSort) : normalizedSort;
|
|
1051
1204
|
const cursor = backward ? before : after;
|
|
1052
1205
|
let query = { ...filters };
|
|
1053
|
-
|
|
1206
|
+
const scope = cursorScope(this.Model.collection.collectionName, filters, collation);
|
|
1207
|
+
if (cursor) query = resolveCursorFilter(cursor, normalizedSort, this.config.cursorVersion, query, this.config.minCursorVersion, querySort, scope, this.config.cursorSecret);
|
|
1054
1208
|
const effectiveSelect = ensureKeysetSelectIncludesCursorFields(select, normalizedSort);
|
|
1055
1209
|
let mongoQuery = this.Model.find(query);
|
|
1056
1210
|
if (effectiveSelect) mongoQuery = mongoQuery.select(effectiveSelect);
|
|
@@ -1059,8 +1213,7 @@ var PaginationEngine = class {
|
|
|
1059
1213
|
if (collation) mongoQuery = mongoQuery.collation(collation);
|
|
1060
1214
|
if (session) mongoQuery = mongoQuery.session(session);
|
|
1061
1215
|
if (hint) mongoQuery = mongoQuery.hint(hint);
|
|
1062
|
-
|
|
1063
|
-
if (readPreference) mongoQuery = mongoQuery.read(readPreference);
|
|
1216
|
+
applyToQuery(mongoQuery, qo);
|
|
1064
1217
|
const data = await mongoQuery.exec();
|
|
1065
1218
|
const moreThatWay = data.length > sanitizedLimit;
|
|
1066
1219
|
if (moreThatWay) data.pop();
|
|
@@ -1076,7 +1229,7 @@ var PaginationEngine = class {
|
|
|
1076
1229
|
const hasMore = backward ? true : moreThatWay;
|
|
1077
1230
|
const hasPrev = backward ? moreThatWay : Boolean(after);
|
|
1078
1231
|
const primaryField = getPrimaryField(normalizedSort);
|
|
1079
|
-
const mint = (doc) => doc ? encodeCursor(doc, primaryField, normalizedSort, this.config.cursorVersion, this.config.cursorSecret) : null;
|
|
1232
|
+
const mint = (doc) => doc ? encodeCursor(doc, primaryField, normalizedSort, this.config.cursorVersion, scope, this.config.cursorSecret) : null;
|
|
1080
1233
|
return {
|
|
1081
1234
|
method: "keyset",
|
|
1082
1235
|
data,
|
|
@@ -1084,7 +1237,8 @@ var PaginationEngine = class {
|
|
|
1084
1237
|
hasMore,
|
|
1085
1238
|
next: hasMore ? mint(data[data.length - 1]) : null,
|
|
1086
1239
|
prev: hasPrev ? mint(data[0]) : null,
|
|
1087
|
-
hasPrev
|
|
1240
|
+
hasPrev,
|
|
1241
|
+
end: mint(data[data.length - 1])
|
|
1088
1242
|
};
|
|
1089
1243
|
}
|
|
1090
1244
|
/**
|
|
@@ -1119,20 +1273,18 @@ var PaginationEngine = class {
|
|
|
1119
1273
|
* });
|
|
1120
1274
|
*/
|
|
1121
1275
|
async aggregatePaginate(options = {}) {
|
|
1122
|
-
const { pipeline = [], page = 1, limit = this.config.defaultLimit, session,
|
|
1276
|
+
const { pipeline = [], page = 1, limit = this.config.defaultLimit, session, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit } = options;
|
|
1277
|
+
const qo = options.queryOptions ?? resolveQueryOptions("aggregate", options);
|
|
1278
|
+
if (!options.sortIsTotal) warnIfSortNotTotal(this.Model.modelName, pipeline, warn);
|
|
1123
1279
|
const sanitizedPage = validatePage(page, this.config);
|
|
1124
1280
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
1125
1281
|
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
1126
|
-
|
|
1127
|
-
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
1282
|
+
assertOffsetWithinCap(skip, this.config.maxOffset);
|
|
1128
1283
|
/** Every execution option both pipelines must carry identically. */
|
|
1129
1284
|
const run = (stages) => {
|
|
1130
1285
|
const aggregation = this.Model.aggregate(stages);
|
|
1131
1286
|
if (session) aggregation.session(session);
|
|
1132
|
-
|
|
1133
|
-
if (maxTimeMS) aggregation.option({ maxTimeMS });
|
|
1134
|
-
if (readPreference) aggregation.read(readPreference);
|
|
1135
|
-
if (allowDiskUse) aggregation.allowDiskUse(true);
|
|
1287
|
+
applyToAggregate(aggregation, qo);
|
|
1136
1288
|
return aggregation.exec();
|
|
1137
1289
|
};
|
|
1138
1290
|
/**
|
|
@@ -1144,25 +1296,47 @@ var PaginationEngine = class {
|
|
|
1144
1296
|
* collection metadata and cannot see a pipeline), so it stays exact here —
|
|
1145
1297
|
* documented on `AggregatePaginationOptions.countStrategy`.
|
|
1146
1298
|
*/
|
|
1147
|
-
const
|
|
1299
|
+
const ceiling = resolveCountLimit(countLimit);
|
|
1300
|
+
const bounded = countStrategy === "capped" || countStrategy === "cached";
|
|
1301
|
+
const countStages = bounded ? [
|
|
1148
1302
|
...pipeline,
|
|
1149
|
-
{ $limit:
|
|
1303
|
+
{ $limit: ceiling },
|
|
1150
1304
|
{ $count: "count" }
|
|
1151
1305
|
] : [...pipeline, { $count: "count" }];
|
|
1152
|
-
const
|
|
1306
|
+
const countOnce = async () => (await run(countStages))[0]?.count ?? 0;
|
|
1307
|
+
const runData = () => run([
|
|
1153
1308
|
...pipeline,
|
|
1154
1309
|
{ $skip: skip },
|
|
1155
|
-
{ $limit:
|
|
1156
|
-
])
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1310
|
+
{ $limit: sanitizedLimit + 1 }
|
|
1311
|
+
]);
|
|
1312
|
+
const runCount = async () => {
|
|
1313
|
+
if (countStrategy === "none") return {
|
|
1314
|
+
total: 0,
|
|
1315
|
+
totalIsEstimate: true,
|
|
1316
|
+
countedAt: null
|
|
1317
|
+
};
|
|
1318
|
+
if (countStrategy === "cached" && !session) {
|
|
1319
|
+
const key = cursorScope(this.Model.collection.collectionName, [pipeline, ceiling], null);
|
|
1320
|
+
return {
|
|
1321
|
+
...await this._countMemo().get({
|
|
1322
|
+
key,
|
|
1323
|
+
run: countOnce
|
|
1324
|
+
}),
|
|
1325
|
+
totalIsEstimate: true
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
const total = await countOnce();
|
|
1329
|
+
return {
|
|
1330
|
+
total,
|
|
1331
|
+
totalIsEstimate: bounded && total >= ceiling,
|
|
1332
|
+
countedAt: /* @__PURE__ */ new Date()
|
|
1333
|
+
};
|
|
1334
|
+
};
|
|
1335
|
+
const [data, counted] = session ? [await runData(), await runCount()] : await Promise.all([runData(), runCount()]);
|
|
1336
|
+
const { total, totalIsEstimate, countedAt } = counted;
|
|
1159
1337
|
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
if (countBounded) {
|
|
1163
|
-
hasNext = data.length > sanitizedLimit;
|
|
1164
|
-
if (hasNext) data.pop();
|
|
1165
|
-
} else hasNext = sanitizedPage < totalPages;
|
|
1338
|
+
const hasNext = data.length > sanitizedLimit;
|
|
1339
|
+
if (hasNext) data.pop();
|
|
1166
1340
|
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
|
|
1167
1341
|
return {
|
|
1168
1342
|
method: "aggregate",
|
|
@@ -1173,10 +1347,11 @@ var PaginationEngine = class {
|
|
|
1173
1347
|
pages: totalPages,
|
|
1174
1348
|
hasNext,
|
|
1175
1349
|
hasPrev: sanitizedPage > 1,
|
|
1176
|
-
|
|
1350
|
+
totalIsEstimate,
|
|
1351
|
+
countedAt,
|
|
1177
1352
|
...warning && { warning }
|
|
1178
1353
|
};
|
|
1179
1354
|
}
|
|
1180
1355
|
};
|
|
1181
1356
|
//#endregion
|
|
1182
|
-
export {
|
|
1357
|
+
export { resolveCursorSecrets as _, cursor_exports as a, PAGINATION_ERROR_CODES as c, withGroupTiebreak as d, withIdTiebreak as f, attachSignature as g, resetPaginationDefaults as h, validateKeysetSort as i, assertOffsetWithinCap as l, getPaginationDefaults as m, getPrimaryField as n, encodeCursor as o, configurePaginationDefaults as p, sort_exports as r, resolveCursorFilter as s, PaginationEngine as t, cursorScope as u, verifySignature as v };
|