@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.
Files changed (51) 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-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
  4. package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
  5. package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
  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-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  17. package/dist/index.d.mts +105 -39
  18. package/dist/index.mjs +332 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/join-scope-DSAznE8E.mjs +168 -0
  22. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  23. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
  24. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  25. package/dist/outbox/index.d.mts +1 -1
  26. package/dist/outbox/index.mjs +6 -4
  27. package/dist/pagination/PaginationEngine.d.mts +2 -2
  28. package/dist/pagination/PaginationEngine.mjs +1 -1
  29. package/dist/pagination/utils/limits.d.mts +1 -1
  30. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  31. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +98 -25
  34. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  35. package/dist/read-model/index.d.mts +66 -0
  36. package/dist/read-model/index.mjs +193 -0
  37. package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
  38. package/dist/sync/index.d.mts +53 -0
  39. package/dist/sync/index.mjs +371 -0
  40. package/dist/tenant/index.mjs +2 -0
  41. package/dist/testkit/index.d.mts +40 -3
  42. package/dist/testkit/index.mjs +113 -2
  43. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
  44. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  45. package/dist/transaction-BrRYtH_b.mjs +655 -0
  46. package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
  47. package/dist/utils/index.d.mts +1 -1
  48. package/dist/utils/index.mjs +1 -1
  49. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
  50. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
  51. 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 { 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);
@@ -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 filterSet = new Set(filterFields);
645
- const prefixLen = filterFields.length;
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, 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);
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(fetchLimit).lean(lean);
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
- if (maxTimeMS) query = query.maxTimeMS(maxTimeMS);
909
- if (readPreference) query = query.read(readPreference);
910
- const hasFilters = Object.keys(filters).length > 0;
911
- const useEstimated = this.config.useEstimatedCount && !hasFilters;
912
- let countPromise;
913
- if ((countStrategy === "estimated" || useEstimated) && !hasFilters) countPromise = this.Model.estimatedDocumentCount();
914
- else if (countStrategy === "none") countPromise = Promise.resolve(0);
915
- else if (countStrategy === "capped") {
916
- /**
917
- * `.limit(n)` on a count is MongoDB's own ceiling — the server stops
918
- * scanning at n and returns n, so the work is O(n) rather than O(matching
919
- * rows). Below the ceiling it returns the exact figure, so a small
920
- * collection is unaffected by the strategy being on.
921
- */
922
- const cappedTarget = countFilters ?? filters;
923
- const cappedQuery = this.Model.countDocuments(cappedTarget).limit(resolveCountLimit(countLimit)).session(session ?? null);
924
- if (hint) cappedQuery.hint(hint);
925
- if (maxTimeMS) cappedQuery.maxTimeMS(maxTimeMS);
926
- if (readPreference) cappedQuery.read(readPreference);
927
- countPromise = cappedQuery.exec();
928
- } else {
929
- const countTarget = countFilters ?? filters;
930
- const countQuery = this.Model.countDocuments(countTarget).session(session ?? null);
931
- if (hint) countQuery.hint(hint);
932
- if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
933
- if (readPreference) countQuery.read(readPreference);
934
- countPromise = countQuery.exec();
935
- }
936
- const [data, total] = await Promise.all([query.exec(), countPromise]);
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
- * A capped count that came back AT its ceiling is a floor, not a total.
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
- ...totalIsLowerBound && { totalIsLowerBound },
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, 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);
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, range } = classifyFilterFields(filters);
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
- 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);
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
- if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
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, 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);
1123
1279
  const sanitizedPage = validatePage(page, this.config);
1124
1280
  const sanitizedLimit = validateLimit(limit, this.config);
1125
1281
  const skip = calculateSkip(sanitizedPage, sanitizedLimit);
1126
- const countBounded = countStrategy === "none" || countStrategy === "capped";
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
- if (hint) aggregation.hint(hint);
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 countStages = countStrategy === "capped" ? [
1299
+ const ceiling = resolveCountLimit(countLimit);
1300
+ const bounded = countStrategy === "capped" || countStrategy === "cached";
1301
+ const countStages = bounded ? [
1148
1302
  ...pipeline,
1149
- { $limit: resolveCountLimit(countLimit) },
1303
+ { $limit: ceiling },
1150
1304
  { $count: "count" }
1151
1305
  ] : [...pipeline, { $count: "count" }];
1152
- const [dataRows, countRows] = await Promise.all([run([
1306
+ const countOnce = async () => (await run(countStages))[0]?.count ?? 0;
1307
+ const runData = () => run([
1153
1308
  ...pipeline,
1154
1309
  { $skip: skip },
1155
- { $limit: fetchLimit }
1156
- ]), countStrategy === "none" ? Promise.resolve([]) : run(countStages)]);
1157
- const data = dataRows;
1158
- const total = countRows[0]?.count || 0;
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 totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
1161
- let hasNext;
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
- ...totalIsLowerBound && { totalIsLowerBound },
1350
+ totalIsEstimate,
1351
+ countedAt,
1177
1352
  ...warning && { warning }
1178
1353
  };
1179
1354
  }
1180
1355
  };
1181
1356
  //#endregion
1182
- 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 };