@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.
Files changed (46) hide show
  1. package/README.md +71 -0
  2. package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
  3. package/dist/{PaginationEngine-Dvl8jcRY.mjs → PaginationEngine-DZnkbSrr.mjs} +299 -140
  4. package/dist/{Repository-BMgPSkqZ.mjs → Repository-DlhPbXlG.mjs} +571 -175
  5. package/dist/{Repository-CR8Y9g2E.d.mts → Repository-pnLw1rG5.d.mts} +57 -16
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +1 -1
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-CAle1MSU.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-BzBUMvd1.mjs → create-repository-Bg0H5rte.mjs} +2 -2
  14. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  15. package/dist/index.d.mts +69 -15
  16. package/dist/index.mjs +57 -11
  17. package/dist/join-scope-DSAznE8E.mjs +168 -0
  18. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  19. package/dist/{multi-tenant.plugin-CBgCo9im.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +2 -2
  20. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  21. package/dist/outbox/index.d.mts +1 -1
  22. package/dist/outbox/index.mjs +1 -1
  23. package/dist/pagination/PaginationEngine.d.mts +2 -2
  24. package/dist/pagination/PaginationEngine.mjs +1 -1
  25. package/dist/pagination/utils/limits.d.mts +1 -1
  26. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  27. package/dist/{plugin-options-Zk46lD6F.d.mts → plugin-options-CRhIvSzx.d.mts} +2 -2
  28. package/dist/plugins/index.d.mts +3 -3
  29. package/dist/plugins/index.mjs +2 -2
  30. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  31. package/dist/read-model/index.d.mts +66 -0
  32. package/dist/read-model/index.mjs +193 -0
  33. package/dist/{repository-odl03582.d.mts → repository-DNHfjAZX.d.mts} +16 -2
  34. package/dist/sync/index.d.mts +15 -2
  35. package/dist/sync/index.mjs +38 -1
  36. package/dist/testkit/index.d.mts +40 -3
  37. package/dist/testkit/index.mjs +113 -2
  38. package/dist/{timestamp.plugin-DF0gJB6p.mjs → timestamp.plugin-DvZlnKEc.mjs} +50 -31
  39. package/dist/{to-plain-k7SjNlSS.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  40. package/dist/{transaction-B5L9glO0.mjs → transaction-DmiB4k2P.mjs} +155 -6
  41. package/dist/{update-DG0fCwRJ.mjs → update-WgeAUJ_I.mjs} +44 -22
  42. package/dist/utils/index.d.mts +1 -1
  43. package/dist/utils/index.mjs +1 -1
  44. package/dist/{validation-chain.plugin-DYp9E7K2.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +4 -4
  45. package/dist/{validation-chain.plugin-BLY2gLEk.mjs → validation-chain.plugin-D1WsGCYr.mjs} +15 -3
  46. 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 { createHmac, timingSafeEqual } from "node:crypto";
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 = 1, secret) {
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
- const verified = verifySignature(token, resolveCursorSecrets(secret));
400
- let json;
518
+ let verified;
401
519
  try {
402
- json = Buffer.from(verified, "base64").toString("utf-8");
403
- } catch {
404
- throw new Error("Invalid cursor token: not valid base64");
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(json);
526
+ payload = JSON.parse(Buffer.from(verified, "base64").toString("utf-8"));
409
527
  } catch {
410
- throw new Error("Invalid cursor token: not valid JSON");
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 new Error("Invalid cursor token: malformed payload structure");
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 new Error("Invalid cursor token: unrecognized value type");
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 new Error("Invalid cursor token: failed to rehydrate values");
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 new Error("Cursor sort does not match current query sort");
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 new Error(`Cursor version ${cursorVersion} is newer than expected version ${expectedVersion}. Please upgrade.`);
462
- if (cursorVersion < minVersion) throw new Error(`Cursor version ${cursorVersion} is older than minimum supported ${minVersion}. Pagination must restart.`);
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
- * Resolves cursor token into MongoDB query filters.
520
- * Shared by PaginationEngine.stream() and Repository.lookupPopulate() keyset path.
521
- *
522
- * Handles:
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 = {}, minCursorVersion = 1, buildSort = sort, secret) {
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, maxTimeMS, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit, readPreference, collation } = options;
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
- * Fetch limit+1 whenever the count cannot answer `hasNext`.
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(fetchLimit).lean(lean);
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
- if (maxTimeMS) query = query.maxTimeMS(maxTimeMS);
924
- if (readPreference) query = query.read(readPreference);
925
- const hasFilters = Object.keys(filters).length > 0;
926
- const useEstimated = this.config.useEstimatedCount && !hasFilters;
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
- if ((countStrategy === "estimated" || useEstimated) && !hasFilters && !session) runCount = () => Promise.resolve(this.Model.estimatedDocumentCount());
929
- else if (countStrategy === "none") runCount = () => Promise.resolve(0);
930
- else if (countStrategy === "capped") {
931
- /**
932
- * `.limit(n)` on a count is MongoDB's own ceiling — the server stops
933
- * scanning at n and returns n, so the work is O(n) rather than O(matching
934
- * rows). Below the ceiling it returns the exact figure, so a small
935
- * collection is unaffected by the strategy being on.
936
- */
937
- const cappedTarget = countFilters ?? filters;
938
- const cappedQuery = this.Model.countDocuments(cappedTarget).limit(resolveCountLimit(countLimit)).session(session ?? null);
939
- if (collation) cappedQuery.collation(collation);
940
- if (hint) cappedQuery.hint(hint);
941
- if (maxTimeMS) cappedQuery.maxTimeMS(maxTimeMS);
942
- if (readPreference) cappedQuery.read(readPreference);
943
- runCount = () => cappedQuery.exec();
944
- } else {
945
- const countTarget = countFilters ?? filters;
946
- const countQuery = this.Model.countDocuments(countTarget).session(session ?? null);
947
- if (collation) countQuery.collation(collation);
948
- if (hint) countQuery.hint(hint);
949
- if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
950
- if (readPreference) countQuery.read(readPreference);
951
- runCount = () => countQuery.exec();
952
- }
953
- const [data, total] = session ? [await query.exec(), await runCount()] : await Promise.all([query.exec(), 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;
954
1104
  const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
955
- /**
956
- * A capped count that came back AT its ceiling is a floor, not a total.
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
- ...totalIsLowerBound && { totalIsLowerBound },
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, maxTimeMS, readPreference, collation } = options;
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
- if (cursor) query = resolveCursorFilter(cursor, normalizedSort, this.config.cursorVersion, query, this.config.minCursorVersion, querySort, this.config.cursorSecret);
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).lean(lean);
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
- if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
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, hint, maxTimeMS, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit, readPreference, allowDiskUse } = options;
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
- const countBounded = countStrategy === "none" || countStrategy === "capped";
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
- if (hint) aggregation.hint(hint);
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 countStages = countStrategy === "capped" ? [
1299
+ const ceiling = resolveCountLimit(countLimit);
1300
+ const bounded = countStrategy === "capped" || countStrategy === "cached";
1301
+ const countStages = bounded ? [
1162
1302
  ...pipeline,
1163
- { $limit: resolveCountLimit(countLimit) },
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: fetchLimit }
1310
+ { $limit: sanitizedLimit + 1 }
1170
1311
  ]);
1171
- const runCount = () => countStrategy === "none" ? Promise.resolve([]) : run(countStages);
1172
- const [dataRows, countRows] = session ? [await runData(), await runCount()] : await Promise.all([runData(), runCount()]);
1173
- const data = dataRows;
1174
- const total = countRows[0]?.count || 0;
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 totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
1177
- let hasNext;
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
- ...totalIsLowerBound && { totalIsLowerBound },
1350
+ totalIsEstimate,
1351
+ countedAt,
1193
1352
  ...warning && { warning }
1194
1353
  };
1195
1354
  }
1196
1355
  };
1197
1356
  //#endregion
1198
- export { getPaginationDefaults as a, resolveCursorSecrets as c, configurePaginationDefaults as i, verifySignature as l, sort_exports as n, resetPaginationDefaults as o, cursor_exports as r, attachSignature as s, PaginationEngine as t };
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 };