@classytic/mongokit 3.40.0 → 3.41.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 +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-Dvl8jcRY.mjs → PaginationEngine-DZnkbSrr.mjs} +299 -140
- package/dist/{Repository-BMgPSkqZ.mjs → Repository-DlhPbXlG.mjs} +571 -175
- package/dist/{Repository-CR8Y9g2E.d.mts → Repository-pnLw1rG5.d.mts} +57 -16
- 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-CAle1MSU.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-BzBUMvd1.mjs → create-repository-Bg0H5rte.mjs} +2 -2
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +69 -15
- package/dist/index.mjs +57 -11
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-CBgCo9im.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +2 -2
- 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 +1 -1
- 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-Zk46lD6F.d.mts → plugin-options-CRhIvSzx.d.mts} +2 -2
- package/dist/plugins/index.d.mts +3 -3
- package/dist/plugins/index.mjs +2 -2
- 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-odl03582.d.mts → repository-DNHfjAZX.d.mts} +16 -2
- package/dist/sync/index.d.mts +15 -2
- package/dist/sync/index.mjs +38 -1
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-DF0gJB6p.mjs → timestamp.plugin-DvZlnKEc.mjs} +50 -31
- package/dist/{to-plain-k7SjNlSS.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/{transaction-B5L9glO0.mjs → transaction-DmiB4k2P.mjs} +155 -6
- package/dist/{update-DG0fCwRJ.mjs → update-WgeAUJ_I.mjs} +44 -22
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-DYp9E7K2.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +4 -4
- package/dist/{validation-chain.plugin-BLY2gLEk.mjs → validation-chain.plugin-D1WsGCYr.mjs} +15 -3
- package/package.json +10 -5
|
@@ -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);
|
|
@@ -776,6 +885,30 @@ function getPrimaryField(sort) {
|
|
|
776
885
|
//#endregion
|
|
777
886
|
//#region src/pagination/PaginationEngine.ts
|
|
778
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
|
+
/**
|
|
779
912
|
* The sort fields a reader cares about, for MESSAGES only — never for deciding
|
|
780
913
|
* whether an index is adequate.
|
|
781
914
|
*
|
|
@@ -833,10 +966,6 @@ const DEFAULT_COUNT_LIMIT = 1e4;
|
|
|
833
966
|
function resolveCountLimit(value) {
|
|
834
967
|
return Number.isInteger(value) && value > 0 ? value : DEFAULT_COUNT_LIMIT;
|
|
835
968
|
}
|
|
836
|
-
/**
|
|
837
|
-
* Production-grade pagination engine for MongoDB
|
|
838
|
-
* Supports offset, keyset (cursor), and aggregate pagination
|
|
839
|
-
*/
|
|
840
969
|
var PaginationEngine = class {
|
|
841
970
|
Model;
|
|
842
971
|
config;
|
|
@@ -851,6 +980,23 @@ var PaginationEngine = class {
|
|
|
851
980
|
* @param Model - Mongoose model to paginate
|
|
852
981
|
* @param config - Pagination configuration
|
|
853
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
|
+
}
|
|
854
1000
|
constructor(Model, config = {}) {
|
|
855
1001
|
this.Model = Model;
|
|
856
1002
|
/**
|
|
@@ -897,75 +1043,67 @@ var PaginationEngine = class {
|
|
|
897
1043
|
* console.log(result.data, result.total, result.hasNext);
|
|
898
1044
|
*/
|
|
899
1045
|
async paginate(options = {}) {
|
|
900
|
-
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);
|
|
901
1048
|
const sanitizedPage = validatePage(page, this.config);
|
|
902
1049
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
903
1050
|
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
*
|
|
907
|
-
* `none` never counts. `capped` counts only to the ceiling, so once `total`
|
|
908
|
-
* sits AT the ceiling it no longer knows whether a further page exists —
|
|
909
|
-
* deriving `hasNext` from `page < pages` there reports "no more results" in
|
|
910
|
-
* the middle of a collection, which is a wrong answer that looks like a
|
|
911
|
-
* right one. One extra document is the whole cost of avoiding it.
|
|
912
|
-
*/
|
|
913
|
-
const countBounded = countStrategy === "none" || countStrategy === "capped";
|
|
914
|
-
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
915
|
-
let query = this.Model.find(filters);
|
|
1051
|
+
assertOffsetWithinCap(skip, this.config.maxOffset);
|
|
1052
|
+
let query = this.Model.find(filters).lean(lean);
|
|
916
1053
|
if (select) query = query.select(select);
|
|
917
1054
|
if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
|
|
918
|
-
if (sort) query = query.sort(sort);
|
|
919
|
-
query = query.skip(skip).limit(
|
|
1055
|
+
if (sort) query = query.sort(withIdTiebreak(sort));
|
|
1056
|
+
query = query.skip(skip).limit(sanitizedLimit + 1);
|
|
920
1057
|
if (collation) query = query.collation(collation);
|
|
921
1058
|
if (session) query = query.session(session);
|
|
922
1059
|
if (hint) query = query.hint(hint);
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const hasFilters = Object.keys(
|
|
926
|
-
const
|
|
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
|
+
};
|
|
927
1072
|
let runCount;
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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;
|
|
954
1104
|
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
*
|
|
958
|
-
* Reported rather than inferred: `total === countLimit` is also what a
|
|
959
|
-
* collection of exactly that size legitimately returns, so a consumer
|
|
960
|
-
* cannot tell the two apart, and one that guesses renders `10,000+` over an
|
|
961
|
-
* exact 10,000 forever.
|
|
962
|
-
*/
|
|
963
|
-
const totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
|
|
964
|
-
let hasNext;
|
|
965
|
-
if (countBounded) {
|
|
966
|
-
hasNext = data.length > sanitizedLimit;
|
|
967
|
-
if (hasNext) data.pop();
|
|
968
|
-
} else hasNext = sanitizedPage < totalPages;
|
|
1105
|
+
const hasNext = data.length > sanitizedLimit;
|
|
1106
|
+
if (hasNext) data.pop();
|
|
969
1107
|
/**
|
|
970
1108
|
* A CLAMPED limit is reported, not swallowed.
|
|
971
1109
|
*
|
|
@@ -996,7 +1134,8 @@ var PaginationEngine = class {
|
|
|
996
1134
|
pages: totalPages,
|
|
997
1135
|
hasNext,
|
|
998
1136
|
hasPrev: sanitizedPage > 1,
|
|
999
|
-
|
|
1137
|
+
totalIsEstimate,
|
|
1138
|
+
countedAt,
|
|
1000
1139
|
...warning && { warning }
|
|
1001
1140
|
};
|
|
1002
1141
|
}
|
|
@@ -1033,7 +1172,8 @@ var PaginationEngine = class {
|
|
|
1033
1172
|
* });
|
|
1034
1173
|
*/
|
|
1035
1174
|
async stream(options) {
|
|
1036
|
-
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);
|
|
1037
1177
|
if (!sort) throw createError(400, "sort is required for keyset pagination");
|
|
1038
1178
|
if (after && before) throw createError(400, "Pass `after` or `before`, not both");
|
|
1039
1179
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
@@ -1063,17 +1203,17 @@ var PaginationEngine = class {
|
|
|
1063
1203
|
const querySort = backward ? invertSort(normalizedSort) : normalizedSort;
|
|
1064
1204
|
const cursor = backward ? before : after;
|
|
1065
1205
|
let query = { ...filters };
|
|
1066
|
-
|
|
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);
|
|
1067
1208
|
const effectiveSelect = ensureKeysetSelectIncludesCursorFields(select, normalizedSort);
|
|
1068
|
-
let mongoQuery = this.Model.find(query);
|
|
1209
|
+
let mongoQuery = this.Model.find(query).lean(lean);
|
|
1069
1210
|
if (effectiveSelect) mongoQuery = mongoQuery.select(effectiveSelect);
|
|
1070
1211
|
if (populate && (Array.isArray(populate) ? populate.length : populate)) mongoQuery = mongoQuery.populate(populate);
|
|
1071
|
-
mongoQuery = mongoQuery.sort(querySort).limit(sanitizedLimit + 1)
|
|
1212
|
+
mongoQuery = mongoQuery.sort(querySort).limit(sanitizedLimit + 1);
|
|
1072
1213
|
if (collation) mongoQuery = mongoQuery.collation(collation);
|
|
1073
1214
|
if (session) mongoQuery = mongoQuery.session(session);
|
|
1074
1215
|
if (hint) mongoQuery = mongoQuery.hint(hint);
|
|
1075
|
-
|
|
1076
|
-
if (readPreference) mongoQuery = mongoQuery.read(readPreference);
|
|
1216
|
+
applyToQuery(mongoQuery, qo);
|
|
1077
1217
|
const data = await mongoQuery.exec();
|
|
1078
1218
|
const moreThatWay = data.length > sanitizedLimit;
|
|
1079
1219
|
if (moreThatWay) data.pop();
|
|
@@ -1089,7 +1229,7 @@ var PaginationEngine = class {
|
|
|
1089
1229
|
const hasMore = backward ? true : moreThatWay;
|
|
1090
1230
|
const hasPrev = backward ? moreThatWay : Boolean(after);
|
|
1091
1231
|
const primaryField = getPrimaryField(normalizedSort);
|
|
1092
|
-
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;
|
|
1093
1233
|
return {
|
|
1094
1234
|
method: "keyset",
|
|
1095
1235
|
data,
|
|
@@ -1133,20 +1273,18 @@ var PaginationEngine = class {
|
|
|
1133
1273
|
* });
|
|
1134
1274
|
*/
|
|
1135
1275
|
async aggregatePaginate(options = {}) {
|
|
1136
|
-
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);
|
|
1137
1279
|
const sanitizedPage = validatePage(page, this.config);
|
|
1138
1280
|
const sanitizedLimit = validateLimit(limit, this.config);
|
|
1139
1281
|
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
1140
|
-
|
|
1141
|
-
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
1282
|
+
assertOffsetWithinCap(skip, this.config.maxOffset);
|
|
1142
1283
|
/** Every execution option both pipelines must carry identically. */
|
|
1143
1284
|
const run = (stages) => {
|
|
1144
1285
|
const aggregation = this.Model.aggregate(stages);
|
|
1145
1286
|
if (session) aggregation.session(session);
|
|
1146
|
-
|
|
1147
|
-
if (maxTimeMS) aggregation.option({ maxTimeMS });
|
|
1148
|
-
if (readPreference) aggregation.read(readPreference);
|
|
1149
|
-
if (allowDiskUse) aggregation.allowDiskUse(true);
|
|
1287
|
+
applyToAggregate(aggregation, qo);
|
|
1150
1288
|
return aggregation.exec();
|
|
1151
1289
|
};
|
|
1152
1290
|
/**
|
|
@@ -1158,27 +1296,47 @@ var PaginationEngine = class {
|
|
|
1158
1296
|
* collection metadata and cannot see a pipeline), so it stays exact here —
|
|
1159
1297
|
* documented on `AggregatePaginationOptions.countStrategy`.
|
|
1160
1298
|
*/
|
|
1161
|
-
const
|
|
1299
|
+
const ceiling = resolveCountLimit(countLimit);
|
|
1300
|
+
const bounded = countStrategy === "capped" || countStrategy === "cached";
|
|
1301
|
+
const countStages = bounded ? [
|
|
1162
1302
|
...pipeline,
|
|
1163
|
-
{ $limit:
|
|
1303
|
+
{ $limit: ceiling },
|
|
1164
1304
|
{ $count: "count" }
|
|
1165
1305
|
] : [...pipeline, { $count: "count" }];
|
|
1306
|
+
const countOnce = async () => (await run(countStages))[0]?.count ?? 0;
|
|
1166
1307
|
const runData = () => run([
|
|
1167
1308
|
...pipeline,
|
|
1168
1309
|
{ $skip: skip },
|
|
1169
|
-
{ $limit:
|
|
1310
|
+
{ $limit: sanitizedLimit + 1 }
|
|
1170
1311
|
]);
|
|
1171
|
-
const runCount = () =>
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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;
|
|
1175
1337
|
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
if (countBounded) {
|
|
1179
|
-
hasNext = data.length > sanitizedLimit;
|
|
1180
|
-
if (hasNext) data.pop();
|
|
1181
|
-
} else hasNext = sanitizedPage < totalPages;
|
|
1338
|
+
const hasNext = data.length > sanitizedLimit;
|
|
1339
|
+
if (hasNext) data.pop();
|
|
1182
1340
|
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
|
|
1183
1341
|
return {
|
|
1184
1342
|
method: "aggregate",
|
|
@@ -1189,10 +1347,11 @@ var PaginationEngine = class {
|
|
|
1189
1347
|
pages: totalPages,
|
|
1190
1348
|
hasNext,
|
|
1191
1349
|
hasPrev: sanitizedPage > 1,
|
|
1192
|
-
|
|
1350
|
+
totalIsEstimate,
|
|
1351
|
+
countedAt,
|
|
1193
1352
|
...warning && { warning }
|
|
1194
1353
|
};
|
|
1195
1354
|
}
|
|
1196
1355
|
};
|
|
1197
1356
|
//#endregion
|
|
1198
|
-
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 };
|