@classytic/mongokit 3.36.0 → 3.39.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 (47) hide show
  1. package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
  2. package/dist/PaginationEngine-DQ7beTCR.mjs +1182 -0
  3. package/dist/{Repository-BArvU32x.mjs → Repository-D7fDpyFx.mjs} +405 -37
  4. package/dist/{Repository-BdSkQ8vc.d.mts → Repository-IxI983_m.d.mts} +55 -8
  5. package/dist/actions/index.d.mts +1 -1
  6. package/dist/actions/index.mjs +1 -1
  7. package/dist/adapter/index.d.mts +21 -0
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.d.mts +78 -1
  12. package/dist/better-auth/index.mjs +233 -3
  13. package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CRXjDa3N.d.mts} +5 -5
  14. package/dist/{create-repository-CPNbktFG.mjs → create-repository-DOe9E_5F.mjs} +3 -3
  15. package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
  16. package/dist/index.d.mts +77 -13
  17. package/dist/index.mjs +31 -8
  18. package/dist/indexes/verify-indexes.d.mts +69 -0
  19. package/dist/indexes/verify-indexes.mjs +76 -0
  20. package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
  21. package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-B-tzNeYL.d.mts} +60 -4
  22. package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
  23. package/dist/outbox/index.d.mts +1 -1
  24. package/dist/outbox/index.mjs +2 -2
  25. package/dist/pagination/PaginationEngine.d.mts +1 -1
  26. package/dist/pagination/PaginationEngine.mjs +1 -1
  27. package/dist/pagination/utils/limits.d.mts +47 -0
  28. package/dist/pagination/utils/limits.mjs +85 -0
  29. package/dist/pagination-BpIv2bdS.d.mts +364 -0
  30. package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-BuvtOSZf.d.mts} +2 -2
  31. package/dist/plugins/index.d.mts +3 -3
  32. package/dist/plugins/index.mjs +3 -3
  33. package/dist/{repository-H_ffGgpE.d.mts → repository-ZbLtL7Nk.d.mts} +30 -4
  34. package/dist/tenant/index.d.mts +89 -0
  35. package/dist/tenant/index.mjs +94 -0
  36. package/dist/testkit/index.d.mts +2 -2
  37. package/dist/testkit/index.mjs +1 -1
  38. package/dist/{timestamp.plugin-BEc6TANm.mjs → timestamp.plugin-B5eiaItQ.mjs} +75 -8
  39. package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-B8ufk3Qz.d.mts} +2 -2
  40. package/dist/{update-jSXp3nu3.mjs → update-c_01A0if.mjs} +126 -73
  41. package/dist/utils/index.d.mts +1 -1
  42. package/dist/{validation-chain.plugin-CyK1s9Ze.mjs → validation-chain.plugin-Bju1D1Sn.mjs} +2 -2
  43. package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DvrLJIbJ.d.mts} +3 -19
  44. package/package.json +15 -3
  45. package/dist/PaginationEngine-GdyX29tE.mjs +0 -797
  46. package/dist/core-62oo3N1C.d.mts +0 -50
  47. package/dist/pagination-jSUdrACK.d.mts +0 -158
@@ -1,797 +0,0 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { n as warn } from "./logger-K7dPmu2U.mjs";
3
- import { t as createError } from "./error-BuByZwKU.mjs";
4
- import mongoose from "mongoose";
5
- //#region src/pagination/utils/filter.ts
6
- /**
7
- * Builds MongoDB filter for keyset pagination
8
- * Creates compound $or condition for proper cursor-based filtering
9
- *
10
- * @param baseFilters - Existing query filters
11
- * @param sort - Normalized sort specification
12
- * @param cursorValue - Primary field value from cursor
13
- * @param cursorId - _id value from cursor
14
- * @returns MongoDB filter with keyset condition
15
- *
16
- * @example
17
- * buildKeysetFilter(
18
- * { status: 'active' },
19
- * { createdAt: -1, _id: -1 },
20
- * new Date('2024-01-01'),
21
- * new ObjectId('...')
22
- * )
23
- * // Returns:
24
- * // {
25
- * // status: 'active',
26
- * // $or: [
27
- * // { createdAt: { $lt: Date('2024-01-01') } },
28
- * // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
29
- * // ]
30
- * // }
31
- */
32
- function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValues) {
33
- const sortFields = Object.keys(sort).filter((k) => k !== "_id");
34
- if (sortFields.length <= 1 && !cursorValues) {
35
- const primaryField = sortFields[0] || "_id";
36
- const direction = sort[primaryField];
37
- const operator = direction === 1 ? "$gt" : "$lt";
38
- if (cursorValue === null || cursorValue === void 0) {
39
- if (direction === 1) return {
40
- ...baseFilters,
41
- $or: [{
42
- [primaryField]: null,
43
- _id: { $gt: cursorId }
44
- }, { [primaryField]: { $ne: null } }]
45
- };
46
- else return {
47
- ...baseFilters,
48
- [primaryField]: null,
49
- _id: { $lt: cursorId }
50
- };
51
- }
52
- return {
53
- ...baseFilters,
54
- $or: [{ [primaryField]: { [operator]: cursorValue } }, {
55
- [primaryField]: cursorValue,
56
- _id: { [operator]: cursorId }
57
- }]
58
- };
59
- }
60
- const values = cursorValues || { [sortFields[0]]: cursorValue };
61
- const allFields = [...sortFields, "_id"];
62
- const allValues = {
63
- ...values,
64
- _id: cursorId
65
- };
66
- const orConditions = [];
67
- for (let i = 0; i < allFields.length; i++) {
68
- const field = allFields[i];
69
- const operator = (sort[field] ?? sort[sortFields[0]]) === 1 ? "$gt" : "$lt";
70
- const condition = {};
71
- for (let j = 0; j < i; j++) condition[allFields[j]] = allValues[allFields[j]];
72
- condition[field] = { [operator]: allValues[field] };
73
- orConditions.push(condition);
74
- }
75
- return {
76
- ...baseFilters,
77
- $or: orConditions
78
- };
79
- }
80
- //#endregion
81
- //#region src/pagination/utils/cursor.ts
82
- /**
83
- * Cursor Utilities
84
- *
85
- * Encoding and decoding of cursor tokens for keyset pagination.
86
- * Cursors are base64-encoded JSON containing position data and metadata.
87
- */
88
- var cursor_exports = /* @__PURE__ */ __exportAll({
89
- decodeCursor: () => decodeCursor,
90
- encodeCursor: () => encodeCursor,
91
- resolveCursorFilter: () => resolveCursorFilter,
92
- validateCursorSort: () => validateCursorSort,
93
- validateCursorVersion: () => validateCursorVersion
94
- });
95
- /**
96
- * Encodes document values and sort metadata into a base64 cursor token
97
- *
98
- * @param doc - Document to extract cursor values from
99
- * @param primaryField - Primary sort field name
100
- * @param sort - Normalized sort specification
101
- * @param version - Cursor version for forward compatibility
102
- * @returns Base64-encoded cursor token
103
- */
104
- function encodeCursor(doc, primaryField, sort, version = 1) {
105
- const primaryValue = doc[primaryField];
106
- const idValue = doc._id;
107
- const sortFields = Object.keys(sort).filter((k) => k !== "_id");
108
- const vals = {};
109
- const types = {};
110
- for (const field of sortFields) {
111
- vals[field] = serializeValue(doc[field]);
112
- types[field] = getValueType(doc[field]);
113
- }
114
- const payload = {
115
- v: serializeValue(primaryValue),
116
- t: getValueType(primaryValue),
117
- id: serializeValue(idValue),
118
- idType: getValueType(idValue),
119
- sort,
120
- ver: version,
121
- ...sortFields.length > 1 && {
122
- vals,
123
- types
124
- }
125
- };
126
- return Buffer.from(JSON.stringify(payload)).toString("base64");
127
- }
128
- /**
129
- * Decodes a cursor token back into document values and sort metadata
130
- *
131
- * @param token - Base64-encoded cursor token
132
- * @returns Decoded cursor data
133
- * @throws Error if token is invalid or malformed
134
- */
135
- function decodeCursor(token) {
136
- let json;
137
- try {
138
- json = Buffer.from(token, "base64").toString("utf-8");
139
- } catch {
140
- throw new Error("Invalid cursor token: not valid base64");
141
- }
142
- let payload;
143
- try {
144
- payload = JSON.parse(json);
145
- } catch {
146
- throw new Error("Invalid cursor token: not valid JSON");
147
- }
148
- 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");
149
- const VALID_TYPES = [
150
- "date",
151
- "objectid",
152
- "boolean",
153
- "number",
154
- "string",
155
- "null",
156
- "unknown"
157
- ];
158
- if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw new Error("Invalid cursor token: unrecognized value type");
159
- try {
160
- let values;
161
- if (payload.vals && payload.types) {
162
- values = {};
163
- for (const [field, serialized] of Object.entries(payload.vals)) values[field] = rehydrateValue(serialized, payload.types[field]);
164
- }
165
- return {
166
- value: rehydrateValue(payload.v, payload.t),
167
- id: rehydrateValue(payload.id, payload.idType),
168
- sort: payload.sort,
169
- version: payload.ver,
170
- ...values && { values }
171
- };
172
- } catch {
173
- throw new Error("Invalid cursor token: failed to rehydrate values");
174
- }
175
- }
176
- /**
177
- * Validates that cursor sort matches current query sort
178
- *
179
- * @param cursorSort - Sort specification from cursor
180
- * @param currentSort - Sort specification from query
181
- * @throws Error if sorts don't match
182
- */
183
- function validateCursorSort(cursorSort, currentSort) {
184
- if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw new Error("Cursor sort does not match current query sort");
185
- }
186
- /**
187
- * Validates cursor version against the server's expected range.
188
- *
189
- * - Cursors newer than `expectedVersion` are rejected (client ahead of server).
190
- * - Cursors older than `minVersion` are rejected (client cached a cursor from
191
- * a pre-breaking-change deploy). Default `minVersion = 1` keeps the legacy
192
- * "accept anything <= expected" behavior; bump it when you ship a breaking
193
- * cursor format change so old clients restart pagination cleanly instead of
194
- * silently paginating from the wrong position.
195
- */
196
- function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
197
- if (cursorVersion > expectedVersion) throw new Error(`Cursor version ${cursorVersion} is newer than expected version ${expectedVersion}. Please upgrade.`);
198
- if (cursorVersion < minVersion) throw new Error(`Cursor version ${cursorVersion} is older than minimum supported ${minVersion}. Pagination must restart.`);
199
- }
200
- /**
201
- * Serializes a value for cursor storage
202
- */
203
- function serializeValue(value) {
204
- if (value === null || value === void 0) return null;
205
- if (value instanceof Date) return value.toISOString();
206
- if (value instanceof mongoose.Types.ObjectId) return value.toString();
207
- return value;
208
- }
209
- /**
210
- * Gets the type identifier for a value
211
- */
212
- function getValueType(value) {
213
- if (value === null || value === void 0) return "null";
214
- if (value instanceof Date) return "date";
215
- if (value instanceof mongoose.Types.ObjectId) return "objectid";
216
- if (typeof value === "boolean") return "boolean";
217
- if (typeof value === "number") return "number";
218
- if (typeof value === "string") return "string";
219
- return "unknown";
220
- }
221
- /**
222
- * Rehydrates a serialized value back to its original type
223
- */
224
- function rehydrateValue(serialized, type) {
225
- if (type === "null" || serialized === null) return null;
226
- switch (type) {
227
- case "date": return new Date(serialized);
228
- case "objectid": return new mongoose.Types.ObjectId(serialized);
229
- case "boolean": return serialized === true || serialized === "true";
230
- case "number": return Number(serialized);
231
- default: return serialized;
232
- }
233
- }
234
- /**
235
- * Resolves cursor token into MongoDB query filters.
236
- * Shared by PaginationEngine.stream() and Repository.lookupPopulate() keyset path.
237
- *
238
- * Handles:
239
- * - Plain 24-char hex ObjectId strings (fallback cursor)
240
- * - Base64-encoded cursor tokens (standard cursor)
241
- * - Cursor version and sort validation
242
- */
243
- function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}, minCursorVersion = 1) {
244
- if (/^[a-f0-9]{24}$/i.test(after)) {
245
- const objectId = new mongoose.Types.ObjectId(after);
246
- const idOperator = (sort._id || -1) === 1 ? "$gt" : "$lt";
247
- return {
248
- ...baseFilters,
249
- _id: { [idOperator]: objectId }
250
- };
251
- }
252
- const cursor = decodeCursor(after);
253
- validateCursorVersion(cursor.version, cursorVersion, minCursorVersion);
254
- validateCursorSort(cursor.sort, sort);
255
- return buildKeysetFilter(baseFilters, sort, cursor.value, cursor.id, cursor.values);
256
- }
257
- //#endregion
258
- //#region src/pagination/utils/index-hint.ts
259
- /**
260
- * The only operators that give the query planner a *bounded equality* it can
261
- * lead an index with. Everything else (`$ne`, `$nin`, `$gt(e)`, `$lt(e)`,
262
- * `$all`, `$elemMatch`, `$regex`, `$exists`, …) is a range predicate and, per ESR,
263
- * must come AFTER the sort keys — never in the leading prefix.
264
- *
265
- * `$all` is intentionally excluded: although it combines equality matches,
266
- * it targets multikey data, whose bounded index prefix cannot generally
267
- * provide a subsequent index sort. Treating it as residual avoids claiming
268
- * that a multikey-prefix compound index satisfies the sort.
269
- */
270
- const EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
271
- /**
272
- * Is a top-level filter value a RANGE predicate (as opposed to equality)?
273
- *
274
- * Only a plain operator object — one whose own keys start with `$` — can be a
275
- * range. Scalars, `Date`, `ObjectId`, arrays, and embedded-doc equality
276
- * matches (`{ nested: { a: 1 } }`) are all equality for index-leading
277
- * purposes. An operator object is equality iff every `$`-operator it uses is
278
- * in {@link EQUALITY_OPERATORS}.
279
- */
280
- function isRangePredicate(value) {
281
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
282
- const operatorKeys = Object.keys(value).filter((k) => k.startsWith("$"));
283
- if (operatorKeys.length === 0) return false;
284
- return operatorKeys.some((k) => !EQUALITY_OPERATORS.has(k));
285
- }
286
- /**
287
- * Split top-level filter fields into ESR equality vs range predicates.
288
- *
289
- * The keyset index check follows MongoDB's ESR rule (Equality → Sort →
290
- * Range). Only equality predicates belong in an index's leading prefix; a
291
- * range predicate placed there stops the planner from using any later key for
292
- * bounds OR for satisfying the sort. So the compatibility check and the
293
- * recommended-index shape must treat the two classes differently — a field
294
- * filtered by a range should TRAIL the sort keys, not lead them.
295
- *
296
- * `$`-prefixed top-level keys (`$and`/`$or`/…) are ignored (the caller decides
297
- * how to handle logical combinators).
298
- */
299
- function classifyFilterFields(filters) {
300
- const equality = [];
301
- const range = [];
302
- for (const [key, value] of Object.entries(filters)) {
303
- if (key.startsWith("$")) continue;
304
- if (isRangePredicate(value)) range.push(key);
305
- else equality.push(key);
306
- }
307
- return {
308
- equality,
309
- range
310
- };
311
- }
312
- /**
313
- * Read a Mongoose schema's declared indexes, defensively.
314
- * Returns an empty array on any introspection failure.
315
- */
316
- function readSchemaIndexes(Model) {
317
- try {
318
- const schema = Model.schema;
319
- if (!schema || typeof schema.indexes !== "function") return [];
320
- const raw = schema.indexes();
321
- return Array.isArray(raw) ? raw : [];
322
- } catch {
323
- return [];
324
- }
325
- }
326
- /**
327
- * Does any schema-declared compound index satisfy ESR for the given
328
- * equality filter fields + sort?
329
- *
330
- * Rules:
331
- * 1. The first N keys of the index (N = filterFields.length) must be
332
- * exactly the equality filter fields in any order (set equality).
333
- * 2. The next M keys (M = sortKeys.length) must match the sort keys in
334
- * exact declared order.
335
- * 3. Directions must all match OR all be inverse (MongoDB can walk an
336
- * index in reverse, but only if every sort field flips together).
337
- * 4. Indexes with more total keys than needed are still accepted when
338
- * the above prefix holds — Mongo just ignores trailing fields.
339
- *
340
- * Notes:
341
- * - Hashed / text / 2dsphere indexes are skipped (not usable for sort).
342
- * - Unique, partial, sparse options don't affect compatibility.
343
- * - When `filterFields` is empty, a sort-only prefix match is sufficient.
344
- *
345
- * @param indexes Raw `[spec, options?]` tuples from `schema.indexes()`.
346
- * @param filterFields Top-level equality filter keys (already stripped of `$*`).
347
- * @param sort Normalized sort spec `{ field: 1 | -1 }`.
348
- */
349
- function hasCompatibleKeysetIndex(indexes, filterFields, sort) {
350
- const sortKeys = Object.keys(sort);
351
- if (sortKeys.length === 0) return true;
352
- const filterSet = new Set(filterFields);
353
- const prefixLen = filterFields.length;
354
- for (const [spec] of indexes) {
355
- if (!spec || typeof spec !== "object") continue;
356
- const entries = Object.entries(spec);
357
- if (entries.length < prefixLen + sortKeys.length) continue;
358
- if (entries.some(([, dir]) => typeof dir !== "number")) continue;
359
- const equalityPrefix = entries.slice(0, prefixLen).map(([k]) => k);
360
- if (equalityPrefix.length !== prefixLen) continue;
361
- if (!(equalityPrefix.every((k) => filterSet.has(k)) && new Set(equalityPrefix).size === prefixLen)) continue;
362
- const sortSuffix = entries.slice(prefixLen, prefixLen + sortKeys.length);
363
- if (sortSuffix.length !== sortKeys.length) continue;
364
- let namesMatch = true;
365
- for (let i = 0; i < sortKeys.length; i++) if (sortSuffix[i][0] !== sortKeys[i]) {
366
- namesMatch = false;
367
- break;
368
- }
369
- if (!namesMatch) continue;
370
- const forward = sortKeys.every((k, i) => sortSuffix[i][1] === sort[k]);
371
- const reverse = sortKeys.every((k, i) => sortSuffix[i][1] === -sort[k]);
372
- if (forward || reverse) return true;
373
- }
374
- return false;
375
- }
376
- //#endregion
377
- //#region src/pagination/utils/limits.ts
378
- /**
379
- * Validates and sanitizes limit value
380
- * Parses strings to numbers and prevents NaN bugs
381
- *
382
- * @param limit - Requested limit
383
- * @param config - Pagination configuration
384
- * @returns Sanitized limit between 1 and maxLimit
385
- */
386
- function validateLimit(limit, config) {
387
- const parsed = Number(limit);
388
- if (!Number.isFinite(parsed) || parsed < 1) return config.defaultLimit ?? 10;
389
- const max = config.maxLimit ?? 100;
390
- if (max === 0) return Math.floor(parsed);
391
- return Math.min(Math.floor(parsed), max);
392
- }
393
- /**
394
- * Validates and sanitizes page number
395
- * Parses strings to numbers and prevents NaN bugs
396
- *
397
- * @param page - Requested page (1-indexed)
398
- * @param config - Pagination configuration
399
- * @returns Sanitized page number >= 1
400
- * @throws Error if page exceeds maxPage
401
- */
402
- function validatePage(page, config) {
403
- const parsed = Number(page);
404
- if (!Number.isFinite(parsed) || parsed < 1) return 1;
405
- const sanitized = Math.floor(parsed);
406
- if (sanitized > (config.maxPage || 1e4)) throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage || 1e4}`);
407
- return sanitized;
408
- }
409
- /**
410
- * Checks if page number should trigger deep pagination warning
411
- *
412
- * @param page - Current page number
413
- * @param threshold - Warning threshold
414
- * @returns True if warning should be shown
415
- */
416
- function shouldWarnDeepPagination(page, threshold) {
417
- return page > threshold;
418
- }
419
- /**
420
- * Calculates number of documents to skip for offset pagination
421
- *
422
- * @param page - Page number (1-indexed)
423
- * @param limit - Documents per page
424
- * @returns Number of documents to skip
425
- */
426
- function calculateSkip(page, limit) {
427
- return (page - 1) * limit;
428
- }
429
- /**
430
- * Calculates total number of pages
431
- *
432
- * @param total - Total document count
433
- * @param limit - Documents per page
434
- * @returns Total number of pages
435
- */
436
- function calculateTotalPages(total, limit) {
437
- return Math.ceil(total / limit);
438
- }
439
- //#endregion
440
- //#region src/pagination/utils/sort.ts
441
- var sort_exports = /* @__PURE__ */ __exportAll({
442
- getPrimaryField: () => getPrimaryField,
443
- normalizeSort: () => normalizeSort,
444
- validateKeysetSort: () => validateKeysetSort
445
- });
446
- /**
447
- * Normalizes sort object to ensure stable key order
448
- * Primary fields first, _id last (not alphabetical)
449
- *
450
- * @param sort - Sort specification
451
- * @returns Normalized sort with stable key order
452
- */
453
- function normalizeSort(sort) {
454
- const normalized = {};
455
- Object.keys(sort).forEach((key) => {
456
- if (key !== "_id") normalized[key] = sort[key];
457
- });
458
- if (sort._id !== void 0) normalized._id = sort._id;
459
- return normalized;
460
- }
461
- /**
462
- * Validates and normalizes sort for keyset pagination.
463
- * Auto-adds `_id` tie-breaker if needed; ensures `_id` direction matches the
464
- * primary field.
465
- *
466
- * **Known limitation (MongoDB mixed-type sort):** keyset pagination across a
467
- * field that can hold both typed values (Date, Number) and `null`/`undefined`
468
- * is lossy — `$lt/$gt` don't cross the type boundary in BSON comparison, so
469
- * some docs at the null/non-null boundary are unreachable. If your sort field
470
- * is nullable, either:
471
- * - sort by `_id` alone (always safe), or
472
- * - use `allowedPrimaryFields` to lock keyset sort to fields your schema
473
- * guarantees non-null (see `PaginationConfig.strictKeysetSortFields`).
474
- *
475
- * @param sort - Sort specification
476
- * @param allowedPrimaryFields - Optional allowlist of primary (non-_id) sort
477
- * field names. When set, rejects sorts whose primary field isn't listed.
478
- * `_id` is always allowed.
479
- * @returns Validated and normalized sort
480
- * @throws Error if sort is invalid for keyset pagination
481
- */
482
- function validateKeysetSort(sort, allowedPrimaryFields) {
483
- const keys = Object.keys(sort);
484
- if (keys.length === 0) throw new Error("Keyset pagination requires at least one sort field");
485
- if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
486
- for (const key of keys) if (sort[key] !== 1 && sort[key] !== -1) throw new Error(`Invalid sort direction for "${key}": must be 1 or -1, got ${sort[key]}`);
487
- const nonIdKeys = keys.filter((k) => k !== "_id");
488
- const primaryDirection = sort[nonIdKeys[0]];
489
- if (allowedPrimaryFields && allowedPrimaryFields.length > 0) {
490
- for (const key of nonIdKeys) if (!allowedPrimaryFields.includes(key)) throw new Error(`Keyset sort field "${key}" is not in the strictKeysetSortFields allowlist. Allowed: ${allowedPrimaryFields.join(", ")}. (See PaginationConfig.strictKeysetSortFields — protects against lossy null/non-null keyset boundaries.)`);
491
- }
492
- for (const key of nonIdKeys) if (sort[key] !== primaryDirection) throw new Error("All sort fields must share the same direction for keyset pagination");
493
- if (keys.includes("_id") && sort._id !== primaryDirection) throw new Error("_id direction must match primary field direction");
494
- if (!keys.includes("_id")) return normalizeSort({
495
- ...sort,
496
- _id: primaryDirection
497
- });
498
- return normalizeSort(sort);
499
- }
500
- /**
501
- * Extracts primary sort field (first non-_id field)
502
- *
503
- * @param sort - Sort specification
504
- * @returns Primary field name
505
- */
506
- function getPrimaryField(sort) {
507
- return Object.keys(sort).find((k) => k !== "_id") || "_id";
508
- }
509
- //#endregion
510
- //#region src/pagination/PaginationEngine.ts
511
- /**
512
- * Strip a trailing `_id` tiebreaker from a normalized sort spec.
513
- *
514
- * `validateKeysetSort` always appends `_id` to the end of the sort object as
515
- * a stable-order guarantee. For index-compatibility matching we only care
516
- * about the primary ordering fields — an index covering those is efficient
517
- * even without `_id` in the index itself.
518
- *
519
- * Returns the sort unchanged if `_id` is the only field (degenerate case).
520
- */
521
- function stripTrailingIdTiebreaker(sort) {
522
- const keys = Object.keys(sort);
523
- if (keys.length <= 1) return sort;
524
- if (keys[keys.length - 1] !== "_id") return sort;
525
- const out = {};
526
- for (let i = 0; i < keys.length - 1; i++) out[keys[i]] = sort[keys[i]];
527
- return out;
528
- }
529
- function ensureKeysetSelectIncludesCursorFields(select, sort) {
530
- if (!select) return select;
531
- const requiredFields = /* @__PURE__ */ new Set([...Object.keys(sort), "_id"]);
532
- if (typeof select === "string") {
533
- const fields = select.split(/[,\s]+/).map((field) => field.trim()).filter(Boolean);
534
- if (fields.length > 0 && fields.every((field) => field.startsWith("-"))) return select;
535
- const merged = new Set(fields);
536
- for (const field of requiredFields) merged.add(field);
537
- return Array.from(merged).join(" ");
538
- }
539
- if (Array.isArray(select)) {
540
- const fields = select.map((field) => field.trim()).filter(Boolean);
541
- if (fields.length > 0 && fields.every((field) => field.startsWith("-"))) return select;
542
- const merged = new Set(fields);
543
- for (const field of requiredFields) merged.add(field);
544
- return Array.from(merged);
545
- }
546
- const projection = { ...select };
547
- if (!Object.values(projection).some((value) => value === 1)) return select;
548
- for (const field of requiredFields) projection[field] = 1;
549
- return projection;
550
- }
551
- /**
552
- * Production-grade pagination engine for MongoDB
553
- * Supports offset, keyset (cursor), and aggregate pagination
554
- */
555
- var PaginationEngine = class {
556
- Model;
557
- config;
558
- /**
559
- * Lazily-cached schema index snapshot used by stream() to decide whether
560
- * to emit the "missing compound index" warning. Computed on first use.
561
- */
562
- _cachedSchemaIndexes = null;
563
- /**
564
- * Create a new pagination engine
565
- *
566
- * @param Model - Mongoose model to paginate
567
- * @param config - Pagination configuration
568
- */
569
- constructor(Model, config = {}) {
570
- this.Model = Model;
571
- this.config = {
572
- defaultLimit: config.defaultLimit ?? 10,
573
- maxLimit: config.maxLimit ?? 100,
574
- maxPage: config.maxPage ?? 1e4,
575
- deepPageThreshold: config.deepPageThreshold ?? 100,
576
- cursorVersion: config.cursorVersion ?? 1,
577
- minCursorVersion: config.minCursorVersion ?? 1,
578
- strictKeysetSortFields: config.strictKeysetSortFields,
579
- useEstimatedCount: config.useEstimatedCount ?? false
580
- };
581
- }
582
- /** Memoized schema index lookup — avoids re-walking schema on every stream(). */
583
- _getSchemaIndexes() {
584
- if (this._cachedSchemaIndexes !== null) return this._cachedSchemaIndexes;
585
- this._cachedSchemaIndexes = readSchemaIndexes(this.Model);
586
- return this._cachedSchemaIndexes;
587
- }
588
- /**
589
- * Offset-based pagination using skip/limit
590
- * Best for small datasets and when users need random page access
591
- * O(n) performance - slower for deep pages
592
- *
593
- * @param options - Pagination options
594
- * @returns Pagination result with total count
595
- *
596
- * @example
597
- * const result = await engine.paginate({
598
- * filters: { status: 'active' },
599
- * sort: { createdAt: -1 },
600
- * page: 1,
601
- * limit: 20
602
- * });
603
- * console.log(result.data, result.total, result.hasNext);
604
- */
605
- async paginate(options = {}) {
606
- const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = "exact", readPreference, collation } = options;
607
- const sanitizedPage = validatePage(page, this.config);
608
- const sanitizedLimit = validateLimit(limit, this.config);
609
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
610
- const fetchLimit = countStrategy === "none" ? sanitizedLimit + 1 : sanitizedLimit;
611
- let query = this.Model.find(filters);
612
- if (select) query = query.select(select);
613
- if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
614
- if (sort) query = query.sort(sort);
615
- query = query.skip(skip).limit(fetchLimit).lean(lean);
616
- if (collation) query = query.collation(collation);
617
- if (session) query = query.session(session);
618
- if (hint) query = query.hint(hint);
619
- if (maxTimeMS) query = query.maxTimeMS(maxTimeMS);
620
- if (readPreference) query = query.read(readPreference);
621
- const hasFilters = Object.keys(filters).length > 0;
622
- const useEstimated = this.config.useEstimatedCount && !hasFilters;
623
- let countPromise;
624
- if ((countStrategy === "estimated" || useEstimated) && !hasFilters) countPromise = this.Model.estimatedDocumentCount();
625
- else if (countStrategy === "none") countPromise = Promise.resolve(0);
626
- else {
627
- const countTarget = countFilters ?? filters;
628
- const countQuery = this.Model.countDocuments(countTarget).session(session ?? null);
629
- if (hint) countQuery.hint(hint);
630
- if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
631
- if (readPreference) countQuery.read(readPreference);
632
- countPromise = countQuery.exec();
633
- }
634
- const [data, total] = await Promise.all([query.exec(), countPromise]);
635
- const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
636
- let hasNext;
637
- if (countStrategy === "none") {
638
- hasNext = data.length > sanitizedLimit;
639
- if (hasNext) data.pop();
640
- } else hasNext = sanitizedPage < totalPages;
641
- /**
642
- * A CLAMPED limit is reported, not swallowed.
643
- *
644
- * `validateLimit` caps silently by design — an over-ask is benign and rejecting it
645
- * would be worse (that contradiction cost a day: the querystring schema's `maximum`
646
- * made the parser's documented clamp unreachable, so `?limit=200` 400'd and two UI
647
- * callers rendered the failure as an empty list).
648
- *
649
- * But clamping quietly has its own cost. A caller asking for 1000 and receiving 100
650
- * gets an arbitrary slice with no signal — which is exactly how a bank-account
651
- * picker rendered "No accounts found" against 696 accounts, because the rows it
652
- * wanted were outside the first page. `limit` in the response already carries the
653
- * effective value; nobody compares it. A warning is the channel that is actually
654
- * read.
655
- *
656
- * Deep-pagination takes precedence when both apply: it is the more actionable of
657
- * the two, and stacking warnings turns a signal into noise.
658
- */
659
- const requestedLimit = Number(limit);
660
- const wasClamped = Number.isFinite(requestedLimit) && requestedLimit > sanitizedLimit;
661
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : wasClamped ? `Requested limit ${Math.floor(requestedLimit)} exceeds this repository's cap; returning ${sanitizedLimit}. Filter server-side or sort deterministically — the returned page is otherwise an arbitrary slice.` : void 0;
662
- return {
663
- method: "offset",
664
- data,
665
- page: sanitizedPage,
666
- limit: sanitizedLimit,
667
- total,
668
- pages: totalPages,
669
- hasNext,
670
- hasPrev: sanitizedPage > 1,
671
- ...warning && { warning }
672
- };
673
- }
674
- /**
675
- * Keyset (cursor-based) pagination for high-performance streaming
676
- * Best for large datasets, infinite scroll, real-time feeds
677
- * O(1) performance - consistent speed regardless of position
678
- *
679
- * @param options - Pagination options (sort is required)
680
- * @returns Pagination result with next cursor
681
- *
682
- * @example
683
- * // First page
684
- * const page1 = await engine.stream({
685
- * sort: { createdAt: -1 },
686
- * limit: 20
687
- * });
688
- *
689
- * // Next page using cursor
690
- * const page2 = await engine.stream({
691
- * sort: { createdAt: -1 },
692
- * after: page1.next,
693
- * limit: 20
694
- * });
695
- */
696
- async stream(options) {
697
- const { filters = {}, sort, after, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, readPreference, collation } = options;
698
- if (!sort) throw createError(400, "sort is required for keyset pagination");
699
- const sanitizedLimit = validateLimit(limit, this.config);
700
- const normalizedSort = validateKeysetSort(sort, this.config.strictKeysetSortFields);
701
- const sortWithoutIdTail = stripTrailingIdTiebreaker(normalizedSort);
702
- const filterKeys = Object.keys(filters).filter((k) => !k.startsWith("$"));
703
- const effectiveSortFields = Object.keys(sortWithoutIdTail);
704
- if (process.env.NODE_ENV !== "test" && filterKeys.length > 0 && effectiveSortFields.length > 0) {
705
- const indexes = this._getSchemaIndexes();
706
- const { equality, range } = classifyFilterFields(filters);
707
- if (!(hasCompatibleKeysetIndex(indexes, filterKeys, sortWithoutIdTail) || equality.length !== filterKeys.length && hasCompatibleKeysetIndex(indexes, equality, sortWithoutIdTail))) {
708
- const indexFields = [
709
- ...equality.map((f) => `${f}: 1`),
710
- ...effectiveSortFields.map((f) => `${f}: ${sortWithoutIdTail[f]}`),
711
- ...range.map((f) => `${f}: 1`)
712
- ];
713
- warn(`[mongokit] Keyset pagination with filters [${filterKeys.join(", ")}] and sort [${effectiveSortFields.join(", ")}] has no matching schema-declared compound index. For O(1) performance, declare: { ${indexFields.join(", ")} }. (Collection-level indexes created outside the schema are not visible here.)`);
714
- }
715
- }
716
- let query = { ...filters };
717
- if (after) query = resolveCursorFilter(after, normalizedSort, this.config.cursorVersion, query, this.config.minCursorVersion);
718
- const effectiveSelect = ensureKeysetSelectIncludesCursorFields(select, normalizedSort);
719
- let mongoQuery = this.Model.find(query);
720
- if (effectiveSelect) mongoQuery = mongoQuery.select(effectiveSelect);
721
- if (populate && (Array.isArray(populate) ? populate.length : populate)) mongoQuery = mongoQuery.populate(populate);
722
- mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
723
- if (collation) mongoQuery = mongoQuery.collation(collation);
724
- if (session) mongoQuery = mongoQuery.session(session);
725
- if (hint) mongoQuery = mongoQuery.hint(hint);
726
- if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
727
- if (readPreference) mongoQuery = mongoQuery.read(readPreference);
728
- const data = await mongoQuery.exec();
729
- const hasMore = data.length > sanitizedLimit;
730
- if (hasMore) data.pop();
731
- const primaryField = getPrimaryField(normalizedSort);
732
- return {
733
- method: "keyset",
734
- data,
735
- limit: sanitizedLimit,
736
- hasMore,
737
- next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null
738
- };
739
- }
740
- /**
741
- * Aggregate pipeline with pagination
742
- * Best for complex queries requiring aggregation stages
743
- * Uses $facet to combine results and count in single query
744
- *
745
- * @param options - Aggregation options
746
- * @returns Pagination result with total count
747
- *
748
- * @example
749
- * const result = await engine.aggregatePaginate({
750
- * pipeline: [
751
- * { $match: { status: 'active' } },
752
- * { $group: { _id: '$category', count: { $sum: 1 } } },
753
- * { $sort: { count: -1 } }
754
- * ],
755
- * page: 1,
756
- * limit: 20
757
- * });
758
- */
759
- async aggregatePaginate(options = {}) {
760
- const { pipeline = [], page = 1, limit = this.config.defaultLimit, session, hint, maxTimeMS, countStrategy = "exact", readPreference } = options;
761
- const sanitizedPage = validatePage(page, this.config);
762
- const sanitizedLimit = validateLimit(limit, this.config);
763
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
764
- const fetchLimit = countStrategy === "none" ? sanitizedLimit + 1 : sanitizedLimit;
765
- const facetStages = { data: [{ $skip: skip }, { $limit: fetchLimit }] };
766
- if (countStrategy !== "none") facetStages.total = [{ $count: "count" }];
767
- const facetPipeline = [...pipeline, { $facet: facetStages }];
768
- const aggregation = this.Model.aggregate(facetPipeline);
769
- if (session) aggregation.session(session);
770
- if (hint) aggregation.hint(hint);
771
- if (maxTimeMS) aggregation.option({ maxTimeMS });
772
- if (readPreference) aggregation.read(readPreference);
773
- const [result] = await aggregation.exec();
774
- const data = result.data;
775
- const total = result.total?.[0]?.count || 0;
776
- const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
777
- let hasNext;
778
- if (countStrategy === "none") {
779
- hasNext = data.length > sanitizedLimit;
780
- if (hasNext) data.pop();
781
- } else hasNext = sanitizedPage < totalPages;
782
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
783
- return {
784
- method: "aggregate",
785
- data,
786
- page: sanitizedPage,
787
- limit: sanitizedLimit,
788
- total,
789
- pages: totalPages,
790
- hasNext,
791
- hasPrev: sanitizedPage > 1,
792
- ...warning && { warning }
793
- };
794
- }
795
- };
796
- //#endregion
797
- export { cursor_exports as i, sort_exports as n, calculateTotalPages as r, PaginationEngine as t };