@classytic/mongokit 2.0.0 → 3.0.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 (105) hide show
  1. package/README.md +221 -7
  2. package/dist/actions/index.d.ts +3 -0
  3. package/dist/actions/index.js +473 -0
  4. package/dist/actions/index.js.map +1 -0
  5. package/dist/index-CgOJ2pqz.d.ts +337 -0
  6. package/dist/index.d.ts +239 -0
  7. package/dist/index.js +2108 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/memory-cache-DG2oSSbx.d.ts +142 -0
  10. package/dist/pagination/PaginationEngine.d.ts +117 -0
  11. package/dist/pagination/PaginationEngine.js +369 -0
  12. package/dist/pagination/PaginationEngine.js.map +1 -0
  13. package/dist/plugins/index.d.ts +275 -0
  14. package/dist/plugins/index.js +857 -0
  15. package/dist/plugins/index.js.map +1 -0
  16. package/dist/types-Nxhmi1aI.d.ts +510 -0
  17. package/dist/utils/index.d.ts +189 -0
  18. package/dist/utils/index.js +643 -0
  19. package/dist/utils/index.js.map +1 -0
  20. package/package.json +38 -21
  21. package/src/Repository.js +0 -296
  22. package/src/actions/aggregate.js +0 -266
  23. package/src/actions/create.js +0 -59
  24. package/src/actions/delete.js +0 -88
  25. package/src/actions/index.js +0 -11
  26. package/src/actions/read.js +0 -188
  27. package/src/actions/update.js +0 -176
  28. package/src/hooks/lifecycle.js +0 -146
  29. package/src/index.js +0 -71
  30. package/src/pagination/PaginationEngine.js +0 -348
  31. package/src/pagination/utils/cursor.js +0 -119
  32. package/src/pagination/utils/filter.js +0 -42
  33. package/src/pagination/utils/limits.js +0 -82
  34. package/src/pagination/utils/sort.js +0 -101
  35. package/src/plugins/aggregate-helpers.plugin.js +0 -71
  36. package/src/plugins/audit-log.plugin.js +0 -60
  37. package/src/plugins/batch-operations.plugin.js +0 -66
  38. package/src/plugins/field-filter.plugin.js +0 -27
  39. package/src/plugins/index.js +0 -19
  40. package/src/plugins/method-registry.plugin.js +0 -140
  41. package/src/plugins/mongo-operations.plugin.js +0 -317
  42. package/src/plugins/soft-delete.plugin.js +0 -46
  43. package/src/plugins/subdocument.plugin.js +0 -66
  44. package/src/plugins/timestamp.plugin.js +0 -19
  45. package/src/plugins/validation-chain.plugin.js +0 -145
  46. package/src/types.d.ts +0 -87
  47. package/src/utils/error.js +0 -12
  48. package/src/utils/field-selection.js +0 -156
  49. package/src/utils/index.js +0 -12
  50. package/types/Repository.d.ts +0 -95
  51. package/types/Repository.d.ts.map +0 -1
  52. package/types/actions/aggregate.d.ts +0 -112
  53. package/types/actions/aggregate.d.ts.map +0 -1
  54. package/types/actions/create.d.ts +0 -21
  55. package/types/actions/create.d.ts.map +0 -1
  56. package/types/actions/delete.d.ts +0 -37
  57. package/types/actions/delete.d.ts.map +0 -1
  58. package/types/actions/index.d.ts +0 -6
  59. package/types/actions/index.d.ts.map +0 -1
  60. package/types/actions/read.d.ts +0 -135
  61. package/types/actions/read.d.ts.map +0 -1
  62. package/types/actions/update.d.ts +0 -58
  63. package/types/actions/update.d.ts.map +0 -1
  64. package/types/hooks/lifecycle.d.ts +0 -44
  65. package/types/hooks/lifecycle.d.ts.map +0 -1
  66. package/types/index.d.ts +0 -25
  67. package/types/index.d.ts.map +0 -1
  68. package/types/pagination/PaginationEngine.d.ts +0 -386
  69. package/types/pagination/PaginationEngine.d.ts.map +0 -1
  70. package/types/pagination/utils/cursor.d.ts +0 -40
  71. package/types/pagination/utils/cursor.d.ts.map +0 -1
  72. package/types/pagination/utils/filter.d.ts +0 -28
  73. package/types/pagination/utils/filter.d.ts.map +0 -1
  74. package/types/pagination/utils/limits.d.ts +0 -64
  75. package/types/pagination/utils/limits.d.ts.map +0 -1
  76. package/types/pagination/utils/sort.d.ts +0 -41
  77. package/types/pagination/utils/sort.d.ts.map +0 -1
  78. package/types/plugins/aggregate-helpers.plugin.d.ts +0 -6
  79. package/types/plugins/aggregate-helpers.plugin.d.ts.map +0 -1
  80. package/types/plugins/audit-log.plugin.d.ts +0 -6
  81. package/types/plugins/audit-log.plugin.d.ts.map +0 -1
  82. package/types/plugins/batch-operations.plugin.d.ts +0 -6
  83. package/types/plugins/batch-operations.plugin.d.ts.map +0 -1
  84. package/types/plugins/field-filter.plugin.d.ts +0 -6
  85. package/types/plugins/field-filter.plugin.d.ts.map +0 -1
  86. package/types/plugins/index.d.ts +0 -11
  87. package/types/plugins/index.d.ts.map +0 -1
  88. package/types/plugins/method-registry.plugin.d.ts +0 -3
  89. package/types/plugins/method-registry.plugin.d.ts.map +0 -1
  90. package/types/plugins/mongo-operations.plugin.d.ts +0 -4
  91. package/types/plugins/mongo-operations.plugin.d.ts.map +0 -1
  92. package/types/plugins/soft-delete.plugin.d.ts +0 -6
  93. package/types/plugins/soft-delete.plugin.d.ts.map +0 -1
  94. package/types/plugins/subdocument.plugin.d.ts +0 -6
  95. package/types/plugins/subdocument.plugin.d.ts.map +0 -1
  96. package/types/plugins/timestamp.plugin.d.ts +0 -6
  97. package/types/plugins/timestamp.plugin.d.ts.map +0 -1
  98. package/types/plugins/validation-chain.plugin.d.ts +0 -31
  99. package/types/plugins/validation-chain.plugin.d.ts.map +0 -1
  100. package/types/utils/error.d.ts +0 -11
  101. package/types/utils/error.d.ts.map +0 -1
  102. package/types/utils/field-selection.d.ts +0 -9
  103. package/types/utils/field-selection.d.ts.map +0 -1
  104. package/types/utils/index.d.ts +0 -2
  105. package/types/utils/index.d.ts.map +0 -1
@@ -1,348 +0,0 @@
1
- import { encodeCursor, decodeCursor, validateCursorSort, validateCursorVersion } from './utils/cursor.js';
2
- import { validateKeysetSort, getPrimaryField } from './utils/sort.js';
3
- import { buildKeysetFilter } from './utils/filter.js';
4
- import {
5
- validateLimit,
6
- validatePage,
7
- shouldWarnDeepPagination,
8
- calculateSkip,
9
- calculateTotalPages
10
- } from './utils/limits.js';
11
- import { createError } from '../utils/error.js';
12
-
13
- /**
14
- * @typedef {import('mongoose').Model} Model
15
- * @typedef {import('mongoose').PopulateOptions} PopulateOptions
16
- * @typedef {import('mongoose').ClientSession} ClientSession
17
- */
18
-
19
- /**
20
- * @typedef {Object} PaginationConfig
21
- * @property {number} [defaultLimit=10] - Default number of documents per page
22
- * @property {number} [maxLimit=100] - Maximum allowed limit
23
- * @property {number} [maxPage=10000] - Maximum allowed page number
24
- * @property {number} [deepPageThreshold=100] - Page number that triggers performance warning
25
- * @property {number} [cursorVersion=1] - Cursor version for forward compatibility
26
- * @property {boolean} [useEstimatedCount=false] - Use estimatedDocumentCount for faster counts on large collections
27
- */
28
-
29
- /**
30
- * @typedef {Object} OffsetPaginationOptions
31
- * @property {Record<string, any>} [filters={}] - MongoDB query filters
32
- * @property {Record<string, 1|-1>} [sort] - Sort specification
33
- * @property {number} [page=1] - Page number (1-indexed)
34
- * @property {number} [limit] - Number of documents per page
35
- * @property {string|string[]} [select] - Fields to select
36
- * @property {string|string[]|PopulateOptions|PopulateOptions[]} [populate] - Fields to populate
37
- * @property {boolean} [lean=true] - Return plain JavaScript objects
38
- * @property {ClientSession} [session] - MongoDB session for transactions
39
- */
40
-
41
- /**
42
- * @typedef {Object} KeysetPaginationOptions
43
- * @property {Record<string, any>} [filters={}] - MongoDB query filters
44
- * @property {Record<string, 1|-1>} [sort] - Sort specification (required at runtime)
45
- * @property {string} [after] - Cursor token for next page
46
- * @property {number} [limit] - Number of documents per page
47
- * @property {string|string[]} [select] - Fields to select
48
- * @property {string|string[]|PopulateOptions|PopulateOptions[]} [populate] - Fields to populate
49
- * @property {boolean} [lean=true] - Return plain JavaScript objects
50
- * @property {ClientSession} [session] - MongoDB session for transactions
51
- */
52
-
53
- /**
54
- * @typedef {Object} AggregatePaginationOptions
55
- * @property {any[]} [pipeline=[]] - Aggregation pipeline stages
56
- * @property {number} [page=1] - Page number (1-indexed)
57
- * @property {number} [limit] - Number of documents per page
58
- * @property {ClientSession} [session] - MongoDB session for transactions
59
- */
60
-
61
- /**
62
- * @typedef {Object} OffsetPaginationResult
63
- * @property {'offset'} method - Pagination method used
64
- * @property {any[]} docs - Array of documents
65
- * @property {number} page - Current page number
66
- * @property {number} limit - Documents per page
67
- * @property {number} total - Total document count
68
- * @property {number} pages - Total page count
69
- * @property {boolean} hasNext - Whether next page exists
70
- * @property {boolean} hasPrev - Whether previous page exists
71
- * @property {string} [warning] - Performance warning for deep pagination
72
- */
73
-
74
- /**
75
- * @typedef {Object} KeysetPaginationResult
76
- * @property {'keyset'} method - Pagination method used
77
- * @property {any[]} docs - Array of documents
78
- * @property {number} limit - Documents per page
79
- * @property {boolean} hasMore - Whether more documents exist
80
- * @property {string|null} next - Cursor token for next page
81
- */
82
-
83
- /**
84
- * @typedef {Object} AggregatePaginationResult
85
- * @property {'aggregate'} method - Pagination method used
86
- * @property {any[]} docs - Array of documents
87
- * @property {number} page - Current page number
88
- * @property {number} limit - Documents per page
89
- * @property {number} total - Total document count
90
- * @property {number} pages - Total page count
91
- * @property {boolean} hasNext - Whether next page exists
92
- * @property {boolean} hasPrev - Whether previous page exists
93
- * @property {string} [warning] - Performance warning for deep pagination
94
- */
95
-
96
- /**
97
- * Production-grade pagination engine for MongoDB
98
- * Supports offset, keyset (cursor), and aggregate pagination
99
- *
100
- * @example
101
- * const engine = new PaginationEngine(UserModel, {
102
- * defaultLimit: 20,
103
- * maxLimit: 100,
104
- * useEstimatedCount: true
105
- * });
106
- *
107
- * // Offset pagination
108
- * const page1 = await engine.paginate({ page: 1, limit: 20 });
109
- *
110
- * // Keyset pagination (better for large datasets)
111
- * const stream1 = await engine.stream({ sort: { createdAt: -1 }, limit: 20 });
112
- * const stream2 = await engine.stream({ sort: { createdAt: -1 }, after: stream1.next });
113
- */
114
- export class PaginationEngine {
115
- /**
116
- * Create a new pagination engine
117
- *
118
- * @param {Model} Model - Mongoose model to paginate
119
- * @param {PaginationConfig} [config={}] - Pagination configuration
120
- */
121
- constructor(Model, config = {}) {
122
- this.Model = Model;
123
- this.config = {
124
- defaultLimit: config.defaultLimit || 10,
125
- maxLimit: config.maxLimit || 100,
126
- maxPage: config.maxPage || 10000,
127
- deepPageThreshold: config.deepPageThreshold || 100,
128
- cursorVersion: config.cursorVersion || 1,
129
- useEstimatedCount: config.useEstimatedCount || false
130
- };
131
- }
132
-
133
- /**
134
- * Offset-based pagination using skip/limit
135
- * Best for small datasets and when users need random page access
136
- * O(n) performance - slower for deep pages
137
- *
138
- * @param {OffsetPaginationOptions} [options={}] - Pagination options
139
- * @returns {Promise<OffsetPaginationResult>} Pagination result with total count
140
- *
141
- * @example
142
- * const result = await engine.paginate({
143
- * filters: { status: 'active' },
144
- * sort: { createdAt: -1 },
145
- * page: 1,
146
- * limit: 20
147
- * });
148
- * console.log(result.docs, result.total, result.hasNext);
149
- */
150
- async paginate(options = {}) {
151
- const {
152
- filters = {},
153
- sort = { _id: -1 },
154
- page = 1,
155
- limit = this.config.defaultLimit,
156
- select,
157
- populate = [],
158
- lean = true,
159
- session
160
- } = options;
161
-
162
- const sanitizedPage = validatePage(page, this.config);
163
- const sanitizedLimit = validateLimit(limit, this.config);
164
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
165
-
166
- let query = this.Model.find(filters);
167
- if (select) query = query.select(select);
168
- if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
169
- query = query.sort(sort).skip(skip).limit(sanitizedLimit).lean(lean);
170
- if (session) query = query.session(session);
171
-
172
- const hasFilters = Object.keys(filters).length > 0;
173
- const useEstimated = this.config.useEstimatedCount && !hasFilters;
174
-
175
- // Note: estimatedDocumentCount() doesn't support sessions or filters
176
- // It reads collection metadata (O(1) instant), not actual documents
177
- // Falls back to countDocuments() when filters are present
178
- const [docs, total] = await Promise.all([
179
- query,
180
- useEstimated
181
- ? this.Model.estimatedDocumentCount()
182
- : this.Model.countDocuments(filters).session(session)
183
- ]);
184
-
185
- const totalPages = calculateTotalPages(total, sanitizedLimit);
186
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold)
187
- ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.`
188
- : undefined;
189
-
190
- return /** @type {const} */ ({
191
- method: 'offset',
192
- docs,
193
- page: sanitizedPage,
194
- limit: sanitizedLimit,
195
- total,
196
- pages: totalPages,
197
- hasNext: sanitizedPage < totalPages,
198
- hasPrev: sanitizedPage > 1,
199
- ...(warning && { warning })
200
- });
201
- }
202
-
203
- /**
204
- * Keyset (cursor-based) pagination for high-performance streaming
205
- * Best for large datasets, infinite scroll, real-time feeds
206
- * O(1) performance - consistent speed regardless of position
207
- *
208
- * @param {KeysetPaginationOptions} options - Pagination options (sort is required)
209
- * @returns {Promise<KeysetPaginationResult>} Pagination result with next cursor
210
- *
211
- * @example
212
- * // First page
213
- * const page1 = await engine.stream({
214
- * sort: { createdAt: -1 },
215
- * limit: 20
216
- * });
217
- *
218
- * // Next page using cursor
219
- * const page2 = await engine.stream({
220
- * sort: { createdAt: -1 },
221
- * after: page1.next,
222
- * limit: 20
223
- * });
224
- */
225
- async stream(options = {}) {
226
- const {
227
- filters = {},
228
- sort,
229
- after,
230
- limit = this.config.defaultLimit,
231
- select,
232
- populate = [],
233
- lean = true,
234
- session
235
- } = options;
236
-
237
- if (!sort) {
238
- throw createError(400, 'sort is required for keyset pagination');
239
- }
240
-
241
- const sanitizedLimit = validateLimit(limit, this.config);
242
- const normalizedSort = validateKeysetSort(sort);
243
-
244
- let query = { ...filters };
245
-
246
- if (after) {
247
- const cursor = decodeCursor(after);
248
- validateCursorVersion(cursor.version, this.config.cursorVersion);
249
- validateCursorSort(cursor.sort, normalizedSort);
250
- query = buildKeysetFilter(query, normalizedSort, cursor.value, cursor.id);
251
- }
252
-
253
- let mongoQuery = this.Model.find(query);
254
- if (select) mongoQuery = mongoQuery.select(select);
255
- if (populate && (Array.isArray(populate) ? populate.length : populate)) mongoQuery = mongoQuery.populate(populate);
256
- mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
257
- if (session) mongoQuery = mongoQuery.session(session);
258
-
259
- const docs = await mongoQuery;
260
-
261
- const hasMore = docs.length > sanitizedLimit;
262
- if (hasMore) docs.pop();
263
-
264
- const primaryField = getPrimaryField(normalizedSort);
265
- const nextCursor = hasMore && docs.length > 0
266
- ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion)
267
- : null;
268
-
269
- return /** @type {const} */ ({
270
- method: 'keyset',
271
- docs,
272
- limit: sanitizedLimit,
273
- hasMore,
274
- next: nextCursor
275
- });
276
- }
277
-
278
- /**
279
- * Aggregate pipeline with pagination
280
- * Best for complex queries requiring aggregation stages
281
- * Uses $facet to combine results and count in single query
282
- *
283
- * @param {AggregatePaginationOptions} [options={}] - Aggregation options
284
- * @returns {Promise<AggregatePaginationResult>} Pagination result with total count
285
- *
286
- * @example
287
- * const result = await engine.aggregatePaginate({
288
- * pipeline: [
289
- * { $match: { status: 'active' } },
290
- * { $group: { _id: '$category', count: { $sum: 1 } } },
291
- * { $sort: { count: -1 } }
292
- * ],
293
- * page: 1,
294
- * limit: 20
295
- * });
296
- */
297
- async aggregatePaginate(options = {}) {
298
- const {
299
- pipeline = [],
300
- page = 1,
301
- limit = this.config.defaultLimit,
302
- session
303
- } = options;
304
-
305
- const sanitizedPage = validatePage(page, this.config);
306
- const sanitizedLimit = validateLimit(limit, this.config);
307
- const skip = calculateSkip(sanitizedPage, sanitizedLimit);
308
-
309
- const facetPipeline = [
310
- ...pipeline,
311
- {
312
- $facet: {
313
- docs: [
314
- { $skip: skip },
315
- { $limit: sanitizedLimit }
316
- ],
317
- total: [
318
- { $count: 'count' }
319
- ]
320
- }
321
- }
322
- ];
323
-
324
- const aggregation = this.Model.aggregate(facetPipeline);
325
- if (session) aggregation.session(session);
326
-
327
- const [result] = await aggregation.exec();
328
- const docs = result.docs;
329
- const total = result.total[0]?.count || 0;
330
- const totalPages = calculateTotalPages(total, sanitizedLimit);
331
-
332
- const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold)
333
- ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.`
334
- : undefined;
335
-
336
- return /** @type {const} */ ({
337
- method: 'aggregate',
338
- docs,
339
- page: sanitizedPage,
340
- limit: sanitizedLimit,
341
- total,
342
- pages: totalPages,
343
- hasNext: sanitizedPage < totalPages,
344
- hasPrev: sanitizedPage > 1,
345
- ...(warning && { warning })
346
- });
347
- }
348
- }
@@ -1,119 +0,0 @@
1
- import mongoose from 'mongoose';
2
-
3
- /**
4
- * Encodes document values and sort metadata into a base64 cursor token
5
- *
6
- * @param {any} doc - Document to extract cursor values from
7
- * @param {string} primaryField - Primary sort field name
8
- * @param {Record<string, 1|-1>} sort - Normalized sort specification
9
- * @param {number} [version=1] - Cursor version for forward compatibility
10
- * @returns {string} Base64-encoded cursor token
11
- */
12
- export function encodeCursor(doc, primaryField, sort, version = 1) {
13
- const primaryValue = doc[primaryField];
14
- const idValue = doc._id;
15
-
16
- const payload = {
17
- v: serializeValue(primaryValue),
18
- t: getValueType(primaryValue),
19
- id: serializeValue(idValue),
20
- idType: getValueType(idValue),
21
- sort,
22
- ver: version
23
- };
24
-
25
- return Buffer.from(JSON.stringify(payload)).toString('base64');
26
- }
27
-
28
- /**
29
- * Decodes a cursor token back into document values and sort metadata
30
- *
31
- * @param {string} token - Base64-encoded cursor token
32
- * @returns {{value: any, id: any, sort: Record<string, 1|-1>, version: number}} Decoded cursor data
33
- * @throws {Error} If token is invalid or malformed
34
- */
35
- export function decodeCursor(token) {
36
- try {
37
- const json = Buffer.from(token, 'base64').toString('utf-8');
38
- const payload = JSON.parse(json);
39
-
40
- return {
41
- value: rehydrateValue(payload.v, payload.t),
42
- id: rehydrateValue(payload.id, payload.idType),
43
- sort: payload.sort,
44
- version: payload.ver
45
- };
46
- } catch (err) {
47
- throw new Error('Invalid cursor token');
48
- }
49
- }
50
-
51
- /**
52
- * Validates that cursor sort matches current query sort
53
- *
54
- * @param {Record<string, 1|-1>} cursorSort - Sort specification from cursor
55
- * @param {Record<string, 1|-1>} currentSort - Sort specification from query
56
- * @throws {Error} If sorts don't match
57
- */
58
- export function validateCursorSort(cursorSort, currentSort) {
59
- const cursorSortStr = JSON.stringify(cursorSort);
60
- const currentSortStr = JSON.stringify(currentSort);
61
-
62
- if (cursorSortStr !== currentSortStr) {
63
- throw new Error('Cursor sort does not match current query sort');
64
- }
65
- }
66
-
67
- /**
68
- * Validates cursor version matches expected version
69
- *
70
- * @param {number} cursorVersion - Version from cursor
71
- * @param {number} expectedVersion - Expected version from config
72
- * @throws {Error} If versions don't match
73
- */
74
- export function validateCursorVersion(cursorVersion, expectedVersion) {
75
- if (cursorVersion !== expectedVersion) {
76
- throw new Error(`Cursor version ${cursorVersion} does not match expected version ${expectedVersion}`);
77
- }
78
- }
79
-
80
- /**
81
- * Serializes a value for cursor storage
82
- * @param {any} value - Value to serialize
83
- * @returns {string|number|boolean} Serialized value
84
- */
85
- function serializeValue(value) {
86
- if (value instanceof Date) return value.toISOString();
87
- if (value instanceof mongoose.Types.ObjectId) return value.toString();
88
- return value;
89
- }
90
-
91
- /**
92
- * Gets the type identifier for a value
93
- * @param {any} value - Value to identify
94
- * @returns {'date'|'objectid'|'boolean'|'number'|'string'|'unknown'} Type identifier
95
- */
96
- function getValueType(value) {
97
- if (value instanceof Date) return 'date';
98
- if (value instanceof mongoose.Types.ObjectId) return 'objectid';
99
- if (typeof value === 'boolean') return 'boolean';
100
- if (typeof value === 'number') return 'number';
101
- if (typeof value === 'string') return 'string';
102
- return 'unknown';
103
- }
104
-
105
- /**
106
- * Rehydrates a serialized value back to its original type
107
- * @param {any} serialized - Serialized value
108
- * @param {string} type - Type identifier
109
- * @returns {any} Rehydrated value
110
- */
111
- function rehydrateValue(serialized, type) {
112
- switch (type) {
113
- case 'date': return new Date(serialized);
114
- case 'objectid': return new mongoose.Types.ObjectId(serialized);
115
- case 'boolean': return Boolean(serialized);
116
- case 'number': return Number(serialized);
117
- default: return serialized;
118
- }
119
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * Builds MongoDB filter for keyset pagination
3
- * Creates compound $or condition for proper cursor-based filtering
4
- *
5
- * @param {Record<string, any>} baseFilters - Existing query filters
6
- * @param {Record<string, 1|-1>} sort - Normalized sort specification
7
- * @param {any} cursorValue - Primary field value from cursor
8
- * @param {any} cursorId - _id value from cursor
9
- * @returns {Record<string, any>} MongoDB filter with keyset condition
10
- *
11
- * @example
12
- * buildKeysetFilter(
13
- * { status: 'active' },
14
- * { createdAt: -1, _id: -1 },
15
- * new Date('2024-01-01'),
16
- * new ObjectId('...')
17
- * )
18
- * // Returns:
19
- * // {
20
- * // status: 'active',
21
- * // $or: [
22
- * // { createdAt: { $lt: Date('2024-01-01') } },
23
- * // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
24
- * // ]
25
- * // }
26
- */
27
- export function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
28
- const primaryField = Object.keys(sort).find(k => k !== '_id') || '_id';
29
- const direction = sort[primaryField];
30
- const operator = direction === 1 ? '$gt' : '$lt';
31
-
32
- return {
33
- ...baseFilters,
34
- $or: [
35
- { [primaryField]: { [operator]: cursorValue } },
36
- {
37
- [primaryField]: cursorValue,
38
- _id: { [operator]: cursorId }
39
- }
40
- ]
41
- };
42
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * @typedef {Object} PaginationConfig
3
- * @property {number} defaultLimit - Default limit value
4
- * @property {number} maxLimit - Maximum allowed limit
5
- * @property {number} maxPage - Maximum allowed page number
6
- */
7
-
8
- /**
9
- * Validates and sanitizes limit value
10
- * Parses strings to numbers and prevents NaN bugs
11
- *
12
- * @param {number|string} limit - Requested limit
13
- * @param {PaginationConfig} config - Pagination configuration
14
- * @returns {number} Sanitized limit between 1 and maxLimit
15
- */
16
- export function validateLimit(limit, config) {
17
- const parsed = Number(limit);
18
-
19
- if (!Number.isFinite(parsed) || parsed < 1) {
20
- return config.defaultLimit || 10;
21
- }
22
-
23
- return Math.min(Math.floor(parsed), config.maxLimit);
24
- }
25
-
26
- /**
27
- * Validates and sanitizes page number
28
- * Parses strings to numbers and prevents NaN bugs
29
- *
30
- * @param {number|string} page - Requested page (1-indexed)
31
- * @param {PaginationConfig} config - Pagination configuration
32
- * @returns {number} Sanitized page number >= 1
33
- * @throws {Error} If page exceeds maxPage
34
- */
35
- export function validatePage(page, config) {
36
- const parsed = Number(page);
37
-
38
- if (!Number.isFinite(parsed) || parsed < 1) {
39
- return 1;
40
- }
41
-
42
- const sanitized = Math.floor(parsed);
43
-
44
- if (sanitized > config.maxPage) {
45
- throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage}`);
46
- }
47
-
48
- return sanitized;
49
- }
50
-
51
- /**
52
- * Checks if page number should trigger deep pagination warning
53
- *
54
- * @param {number} page - Current page number
55
- * @param {number} threshold - Warning threshold
56
- * @returns {boolean} True if warning should be shown
57
- */
58
- export function shouldWarnDeepPagination(page, threshold) {
59
- return page > threshold;
60
- }
61
-
62
- /**
63
- * Calculates number of documents to skip for offset pagination
64
- *
65
- * @param {number} page - Page number (1-indexed)
66
- * @param {number} limit - Documents per page
67
- * @returns {number} Number of documents to skip
68
- */
69
- export function calculateSkip(page, limit) {
70
- return (page - 1) * limit;
71
- }
72
-
73
- /**
74
- * Calculates total number of pages
75
- *
76
- * @param {number} total - Total document count
77
- * @param {number} limit - Documents per page
78
- * @returns {number} Total number of pages
79
- */
80
- export function calculateTotalPages(total, limit) {
81
- return Math.ceil(total / limit);
82
- }
@@ -1,101 +0,0 @@
1
- /**
2
- * Normalizes sort object to ensure stable key order
3
- * Primary fields first, _id last (not alphabetical)
4
- *
5
- * @param {Record<string, 1|-1>} sort - Sort specification
6
- * @returns {Record<string, 1|-1>} Normalized sort with stable key order
7
- */
8
- export function normalizeSort(sort) {
9
- /** @type {Record<string, 1|-1>} */
10
- const normalized = {};
11
-
12
- Object.keys(sort).forEach(key => {
13
- if (key !== '_id') normalized[key] = sort[key];
14
- });
15
-
16
- if (sort._id !== undefined) {
17
- normalized._id = sort._id;
18
- }
19
-
20
- return normalized;
21
- }
22
-
23
- /**
24
- * Validates and normalizes sort for keyset pagination
25
- * Auto-adds _id tie-breaker if needed
26
- * Ensures _id direction matches primary field
27
- *
28
- * @param {Record<string, 1|-1>} sort - Sort specification
29
- * @returns {Record<string, 1|-1>} Validated and normalized sort
30
- * @throws {Error} If sort is invalid for keyset pagination
31
- */
32
- export function validateKeysetSort(sort) {
33
- const keys = Object.keys(sort);
34
-
35
- if (keys.length === 1 && keys[0] !== '_id') {
36
- const field = keys[0];
37
- const direction = sort[field];
38
- return normalizeSort({ [field]: direction, _id: direction });
39
- }
40
-
41
- if (keys.length === 1 && keys[0] === '_id') {
42
- return normalizeSort(sort);
43
- }
44
-
45
- if (keys.length === 2) {
46
- if (!keys.includes('_id')) {
47
- throw new Error('Keyset pagination requires _id as tie-breaker');
48
- }
49
-
50
- const primaryField = keys.find(k => k !== '_id');
51
- const primaryDirection = sort[primaryField];
52
- const idDirection = sort._id;
53
-
54
- if (primaryDirection !== idDirection) {
55
- throw new Error('_id direction must match primary field direction');
56
- }
57
-
58
- return normalizeSort(sort);
59
- }
60
-
61
- throw new Error('Keyset pagination only supports single field + _id');
62
- }
63
-
64
- /**
65
- * Inverts sort directions (1 becomes -1, -1 becomes 1)
66
- *
67
- * @param {Record<string, 1|-1>} sort - Sort specification
68
- * @returns {Record<string, 1|-1>} Inverted sort
69
- */
70
- export function invertSort(sort) {
71
- /** @type {Record<string, 1|-1>} */
72
- const inverted = {};
73
-
74
- Object.keys(sort).forEach(key => {
75
- inverted[key] = sort[key] === 1 ? -1 : 1;
76
- });
77
-
78
- return inverted;
79
- }
80
-
81
- /**
82
- * Extracts primary sort field (first non-_id field)
83
- *
84
- * @param {Record<string, 1|-1>} sort - Sort specification
85
- * @returns {string} Primary field name
86
- */
87
- export function getPrimaryField(sort) {
88
- const keys = Object.keys(sort);
89
- return keys.find(k => k !== '_id') || '_id';
90
- }
91
-
92
- /**
93
- * Gets sort direction for a specific field
94
- *
95
- * @param {Record<string, 1|-1>} sort - Sort specification
96
- * @param {string} field - Field name
97
- * @returns {1|-1|undefined} Sort direction
98
- */
99
- export function getDirection(sort, field) {
100
- return sort[field];
101
- }