@classytic/mongokit 1.0.1 → 2.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 (87) hide show
  1. package/README.md +564 -157
  2. package/package.json +20 -12
  3. package/src/Repository.js +296 -225
  4. package/src/actions/aggregate.js +266 -191
  5. package/src/actions/create.js +59 -58
  6. package/src/actions/delete.js +88 -88
  7. package/src/actions/index.js +11 -11
  8. package/src/actions/read.js +188 -155
  9. package/src/actions/update.js +176 -172
  10. package/src/hooks/lifecycle.js +146 -146
  11. package/src/index.js +71 -60
  12. package/src/pagination/PaginationEngine.js +348 -0
  13. package/src/pagination/utils/cursor.js +119 -0
  14. package/src/pagination/utils/filter.js +42 -0
  15. package/src/pagination/utils/limits.js +82 -0
  16. package/src/pagination/utils/sort.js +101 -0
  17. package/src/plugins/aggregate-helpers.plugin.js +71 -71
  18. package/src/plugins/audit-log.plugin.js +60 -60
  19. package/src/plugins/batch-operations.plugin.js +66 -66
  20. package/src/plugins/field-filter.plugin.js +27 -27
  21. package/src/plugins/index.js +19 -19
  22. package/src/plugins/method-registry.plugin.js +140 -140
  23. package/src/plugins/mongo-operations.plugin.js +317 -313
  24. package/src/plugins/soft-delete.plugin.js +46 -46
  25. package/src/plugins/subdocument.plugin.js +66 -66
  26. package/src/plugins/timestamp.plugin.js +19 -19
  27. package/src/plugins/validation-chain.plugin.js +145 -145
  28. package/src/types.d.ts +87 -0
  29. package/src/utils/error.js +12 -0
  30. package/src/utils/field-selection.js +156 -156
  31. package/src/utils/index.js +12 -12
  32. package/types/Repository.d.ts +95 -0
  33. package/types/Repository.d.ts.map +1 -0
  34. package/types/actions/aggregate.d.ts +112 -0
  35. package/types/actions/aggregate.d.ts.map +1 -0
  36. package/types/actions/create.d.ts +21 -0
  37. package/types/actions/create.d.ts.map +1 -0
  38. package/types/actions/delete.d.ts +37 -0
  39. package/types/actions/delete.d.ts.map +1 -0
  40. package/types/actions/index.d.ts +6 -113
  41. package/types/actions/index.d.ts.map +1 -0
  42. package/types/actions/read.d.ts +135 -0
  43. package/types/actions/read.d.ts.map +1 -0
  44. package/types/actions/update.d.ts +58 -0
  45. package/types/actions/update.d.ts.map +1 -0
  46. package/types/hooks/lifecycle.d.ts +44 -0
  47. package/types/hooks/lifecycle.d.ts.map +1 -0
  48. package/types/index.d.ts +25 -96
  49. package/types/index.d.ts.map +1 -0
  50. package/types/pagination/PaginationEngine.d.ts +386 -0
  51. package/types/pagination/PaginationEngine.d.ts.map +1 -0
  52. package/types/pagination/utils/cursor.d.ts +40 -0
  53. package/types/pagination/utils/cursor.d.ts.map +1 -0
  54. package/types/pagination/utils/filter.d.ts +28 -0
  55. package/types/pagination/utils/filter.d.ts.map +1 -0
  56. package/types/pagination/utils/limits.d.ts +64 -0
  57. package/types/pagination/utils/limits.d.ts.map +1 -0
  58. package/types/pagination/utils/sort.d.ts +41 -0
  59. package/types/pagination/utils/sort.d.ts.map +1 -0
  60. package/types/plugins/aggregate-helpers.plugin.d.ts +6 -0
  61. package/types/plugins/aggregate-helpers.plugin.d.ts.map +1 -0
  62. package/types/plugins/audit-log.plugin.d.ts +6 -0
  63. package/types/plugins/audit-log.plugin.d.ts.map +1 -0
  64. package/types/plugins/batch-operations.plugin.d.ts +6 -0
  65. package/types/plugins/batch-operations.plugin.d.ts.map +1 -0
  66. package/types/plugins/field-filter.plugin.d.ts +6 -0
  67. package/types/plugins/field-filter.plugin.d.ts.map +1 -0
  68. package/types/plugins/index.d.ts +11 -88
  69. package/types/plugins/index.d.ts.map +1 -0
  70. package/types/plugins/method-registry.plugin.d.ts +3 -0
  71. package/types/plugins/method-registry.plugin.d.ts.map +1 -0
  72. package/types/plugins/mongo-operations.plugin.d.ts +4 -0
  73. package/types/plugins/mongo-operations.plugin.d.ts.map +1 -0
  74. package/types/plugins/soft-delete.plugin.d.ts +6 -0
  75. package/types/plugins/soft-delete.plugin.d.ts.map +1 -0
  76. package/types/plugins/subdocument.plugin.d.ts +6 -0
  77. package/types/plugins/subdocument.plugin.d.ts.map +1 -0
  78. package/types/plugins/timestamp.plugin.d.ts +6 -0
  79. package/types/plugins/timestamp.plugin.d.ts.map +1 -0
  80. package/types/plugins/validation-chain.plugin.d.ts +31 -0
  81. package/types/plugins/validation-chain.plugin.d.ts.map +1 -0
  82. package/types/utils/error.d.ts +11 -0
  83. package/types/utils/error.d.ts.map +1 -0
  84. package/types/utils/field-selection.d.ts +9 -0
  85. package/types/utils/field-selection.d.ts.map +1 -0
  86. package/types/utils/index.d.ts +2 -24
  87. package/types/utils/index.d.ts.map +1 -0
package/src/index.js CHANGED
@@ -1,60 +1,71 @@
1
- /**
2
- * Repository Pattern - Data Access Layer
3
- *
4
- * Event-driven, plugin-based abstraction for MongoDB operations
5
- * Inspired by Meta & Stripe's repository patterns
6
- *
7
- * @module common/repositories
8
- *
9
- * Documentation:
10
- * - README.md - Main documentation (concise overview)
11
- * - QUICK_REFERENCE.md - One-page cheat sheet
12
- * - EXAMPLES.md - Detailed examples and patterns
13
- */
14
-
15
- /**
16
- * MongoKit - Event-driven repository pattern for MongoDB
17
- *
18
- * @module @classytic/mongokit
19
- * @author Sadman Chowdhury (Github: @siam923)
20
- * @license MIT
21
- */
22
-
23
- export { Repository } from './Repository.js';
24
-
25
- // Plugins
26
- export { fieldFilterPlugin } from './plugins/field-filter.plugin.js';
27
- export { timestampPlugin } from './plugins/timestamp.plugin.js';
28
- export { auditLogPlugin } from './plugins/audit-log.plugin.js';
29
- export { softDeletePlugin } from './plugins/soft-delete.plugin.js';
30
- export { methodRegistryPlugin } from './plugins/method-registry.plugin.js';
31
- export {
32
- validationChainPlugin,
33
- blockIf,
34
- requireField,
35
- autoInject,
36
- immutableField,
37
- uniqueField,
38
- } from './plugins/validation-chain.plugin.js';
39
- export { mongoOperationsPlugin } from './plugins/mongo-operations.plugin.js';
40
- export { batchOperationsPlugin } from './plugins/batch-operations.plugin.js';
41
- export { aggregateHelpersPlugin } from './plugins/aggregate-helpers.plugin.js';
42
- export { subdocumentPlugin } from './plugins/subdocument.plugin.js';
43
-
44
- // Utilities
45
- export {
46
- getFieldsForUser,
47
- getMongooseProjection,
48
- filterResponseData,
49
- createFieldPreset,
50
- } from './utils/field-selection.js';
51
-
52
- export * as actions from './actions/index.js';
53
-
54
- import { Repository } from './Repository.js';
55
-
56
- export const createRepository = (Model, plugins = []) => {
57
- return new Repository(Model, plugins);
58
- };
59
-
60
- export default Repository;
1
+ /**
2
+ * Repository Pattern - Data Access Layer
3
+ *
4
+ * Event-driven, plugin-based abstraction for MongoDB operations
5
+ * Inspired by Meta & Stripe's repository patterns
6
+ *
7
+ * @module common/repositories
8
+ *
9
+ * Documentation:
10
+ * - README.md - Main documentation (concise overview)
11
+ * - QUICK_REFERENCE.md - One-page cheat sheet
12
+ * - EXAMPLES.md - Detailed examples and patterns
13
+ */
14
+
15
+ /**
16
+ * MongoKit - Event-driven repository pattern for MongoDB
17
+ *
18
+ * @module @classytic/mongokit
19
+ * @author Sadman Chowdhury (Github: @siam923)
20
+ * @license MIT
21
+ */
22
+
23
+ /**
24
+ * @typedef {import('./pagination/PaginationEngine.js').PaginationConfig} PaginationConfig
25
+ * @typedef {import('./pagination/PaginationEngine.js').OffsetPaginationOptions} OffsetPaginationOptions
26
+ * @typedef {import('./pagination/PaginationEngine.js').KeysetPaginationOptions} KeysetPaginationOptions
27
+ * @typedef {import('./pagination/PaginationEngine.js').AggregatePaginationOptions} AggregatePaginationOptions
28
+ * @typedef {import('./pagination/PaginationEngine.js').OffsetPaginationResult} OffsetPaginationResult
29
+ * @typedef {import('./pagination/PaginationEngine.js').KeysetPaginationResult} KeysetPaginationResult
30
+ * @typedef {import('./pagination/PaginationEngine.js').AggregatePaginationResult} AggregatePaginationResult
31
+ */
32
+
33
+ export { Repository } from './Repository.js';
34
+ export { PaginationEngine } from './pagination/PaginationEngine.js';
35
+
36
+ // Plugins
37
+ export { fieldFilterPlugin } from './plugins/field-filter.plugin.js';
38
+ export { timestampPlugin } from './plugins/timestamp.plugin.js';
39
+ export { auditLogPlugin } from './plugins/audit-log.plugin.js';
40
+ export { softDeletePlugin } from './plugins/soft-delete.plugin.js';
41
+ export { methodRegistryPlugin } from './plugins/method-registry.plugin.js';
42
+ export {
43
+ validationChainPlugin,
44
+ blockIf,
45
+ requireField,
46
+ autoInject,
47
+ immutableField,
48
+ uniqueField,
49
+ } from './plugins/validation-chain.plugin.js';
50
+ export { mongoOperationsPlugin } from './plugins/mongo-operations.plugin.js';
51
+ export { batchOperationsPlugin } from './plugins/batch-operations.plugin.js';
52
+ export { aggregateHelpersPlugin } from './plugins/aggregate-helpers.plugin.js';
53
+ export { subdocumentPlugin } from './plugins/subdocument.plugin.js';
54
+
55
+ // Utilities
56
+ export {
57
+ getFieldsForUser,
58
+ getMongooseProjection,
59
+ filterResponseData,
60
+ createFieldPreset,
61
+ } from './utils/field-selection.js';
62
+
63
+ export * as actions from './actions/index.js';
64
+
65
+ import { Repository } from './Repository.js';
66
+
67
+ export const createRepository = (Model, plugins = []) => {
68
+ return new Repository(Model, plugins);
69
+ };
70
+
71
+ export default Repository;
@@ -0,0 +1,348 @@
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
+ }
@@ -0,0 +1,119 @@
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
+ }
@@ -0,0 +1,42 @@
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
+ }