@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.
- package/README.md +221 -7
- package/dist/actions/index.d.ts +3 -0
- package/dist/actions/index.js +473 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/index-CgOJ2pqz.d.ts +337 -0
- package/dist/index.d.ts +239 -0
- package/dist/index.js +2108 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-cache-DG2oSSbx.d.ts +142 -0
- package/dist/pagination/PaginationEngine.d.ts +117 -0
- package/dist/pagination/PaginationEngine.js +369 -0
- package/dist/pagination/PaginationEngine.js.map +1 -0
- package/dist/plugins/index.d.ts +275 -0
- package/dist/plugins/index.js +857 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/types-Nxhmi1aI.d.ts +510 -0
- package/dist/utils/index.d.ts +189 -0
- package/dist/utils/index.js +643 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +38 -21
- package/src/Repository.js +0 -296
- package/src/actions/aggregate.js +0 -266
- package/src/actions/create.js +0 -59
- package/src/actions/delete.js +0 -88
- package/src/actions/index.js +0 -11
- package/src/actions/read.js +0 -188
- package/src/actions/update.js +0 -176
- package/src/hooks/lifecycle.js +0 -146
- package/src/index.js +0 -71
- package/src/pagination/PaginationEngine.js +0 -348
- package/src/pagination/utils/cursor.js +0 -119
- package/src/pagination/utils/filter.js +0 -42
- package/src/pagination/utils/limits.js +0 -82
- package/src/pagination/utils/sort.js +0 -101
- package/src/plugins/aggregate-helpers.plugin.js +0 -71
- package/src/plugins/audit-log.plugin.js +0 -60
- package/src/plugins/batch-operations.plugin.js +0 -66
- package/src/plugins/field-filter.plugin.js +0 -27
- package/src/plugins/index.js +0 -19
- package/src/plugins/method-registry.plugin.js +0 -140
- package/src/plugins/mongo-operations.plugin.js +0 -317
- package/src/plugins/soft-delete.plugin.js +0 -46
- package/src/plugins/subdocument.plugin.js +0 -66
- package/src/plugins/timestamp.plugin.js +0 -19
- package/src/plugins/validation-chain.plugin.js +0 -145
- package/src/types.d.ts +0 -87
- package/src/utils/error.js +0 -12
- package/src/utils/field-selection.js +0 -156
- package/src/utils/index.js +0 -12
- package/types/Repository.d.ts +0 -95
- package/types/Repository.d.ts.map +0 -1
- package/types/actions/aggregate.d.ts +0 -112
- package/types/actions/aggregate.d.ts.map +0 -1
- package/types/actions/create.d.ts +0 -21
- package/types/actions/create.d.ts.map +0 -1
- package/types/actions/delete.d.ts +0 -37
- package/types/actions/delete.d.ts.map +0 -1
- package/types/actions/index.d.ts +0 -6
- package/types/actions/index.d.ts.map +0 -1
- package/types/actions/read.d.ts +0 -135
- package/types/actions/read.d.ts.map +0 -1
- package/types/actions/update.d.ts +0 -58
- package/types/actions/update.d.ts.map +0 -1
- package/types/hooks/lifecycle.d.ts +0 -44
- package/types/hooks/lifecycle.d.ts.map +0 -1
- package/types/index.d.ts +0 -25
- package/types/index.d.ts.map +0 -1
- package/types/pagination/PaginationEngine.d.ts +0 -386
- package/types/pagination/PaginationEngine.d.ts.map +0 -1
- package/types/pagination/utils/cursor.d.ts +0 -40
- package/types/pagination/utils/cursor.d.ts.map +0 -1
- package/types/pagination/utils/filter.d.ts +0 -28
- package/types/pagination/utils/filter.d.ts.map +0 -1
- package/types/pagination/utils/limits.d.ts +0 -64
- package/types/pagination/utils/limits.d.ts.map +0 -1
- package/types/pagination/utils/sort.d.ts +0 -41
- package/types/pagination/utils/sort.d.ts.map +0 -1
- package/types/plugins/aggregate-helpers.plugin.d.ts +0 -6
- package/types/plugins/aggregate-helpers.plugin.d.ts.map +0 -1
- package/types/plugins/audit-log.plugin.d.ts +0 -6
- package/types/plugins/audit-log.plugin.d.ts.map +0 -1
- package/types/plugins/batch-operations.plugin.d.ts +0 -6
- package/types/plugins/batch-operations.plugin.d.ts.map +0 -1
- package/types/plugins/field-filter.plugin.d.ts +0 -6
- package/types/plugins/field-filter.plugin.d.ts.map +0 -1
- package/types/plugins/index.d.ts +0 -11
- package/types/plugins/index.d.ts.map +0 -1
- package/types/plugins/method-registry.plugin.d.ts +0 -3
- package/types/plugins/method-registry.plugin.d.ts.map +0 -1
- package/types/plugins/mongo-operations.plugin.d.ts +0 -4
- package/types/plugins/mongo-operations.plugin.d.ts.map +0 -1
- package/types/plugins/soft-delete.plugin.d.ts +0 -6
- package/types/plugins/soft-delete.plugin.d.ts.map +0 -1
- package/types/plugins/subdocument.plugin.d.ts +0 -6
- package/types/plugins/subdocument.plugin.d.ts.map +0 -1
- package/types/plugins/timestamp.plugin.d.ts +0 -6
- package/types/plugins/timestamp.plugin.d.ts.map +0 -1
- package/types/plugins/validation-chain.plugin.d.ts +0 -31
- package/types/plugins/validation-chain.plugin.d.ts.map +0 -1
- package/types/utils/error.d.ts +0 -11
- package/types/utils/error.d.ts.map +0 -1
- package/types/utils/field-selection.d.ts +0 -9
- package/types/utils/field-selection.d.ts.map +0 -1
- package/types/utils/index.d.ts +0 -2
- 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
|
-
}
|