@classytic/mongokit 1.0.2 → 2.1.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 +772 -151
- package/dist/actions/index.cjs +479 -0
- package/dist/actions/index.cjs.map +1 -0
- package/dist/actions/index.d.cts +3 -0
- 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-BfVJZF-3.d.cts +337 -0
- package/dist/index-CgOJ2pqz.d.ts +337 -0
- package/dist/index.cjs +2142 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +239 -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/memory-cache-DqfFfKes.d.cts +142 -0
- package/dist/pagination/PaginationEngine.cjs +375 -0
- package/dist/pagination/PaginationEngine.cjs.map +1 -0
- package/dist/pagination/PaginationEngine.d.cts +117 -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.cjs +874 -0
- package/dist/plugins/index.cjs.map +1 -0
- package/dist/plugins/index.d.cts +275 -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.cts +510 -0
- package/dist/types-Nxhmi1aI.d.ts +510 -0
- package/dist/utils/index.cjs +667 -0
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +189 -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 +54 -24
- package/src/Repository.js +0 -225
- package/src/actions/aggregate.js +0 -191
- 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 -156
- package/src/actions/update.js +0 -176
- package/src/hooks/lifecycle.js +0 -146
- package/src/index.js +0 -60
- 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 -313
- 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/utils/field-selection.js +0 -156
- package/src/utils/index.js +0 -12
- package/types/actions/index.d.ts +0 -121
- package/types/index.d.ts +0 -104
- package/types/plugins/index.d.ts +0 -88
- package/types/utils/index.d.ts +0 -24
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { o as UserContext, F as FieldPreset, H as HttpError, N as CacheAdapter } from './types-Nxhmi1aI.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Field Selection Utilities
|
|
5
|
+
*
|
|
6
|
+
* Provides explicit, performant field filtering using Mongoose projections.
|
|
7
|
+
*
|
|
8
|
+
* Philosophy:
|
|
9
|
+
* - Explicit is better than implicit
|
|
10
|
+
* - Filter at DB level (10x faster than in-memory)
|
|
11
|
+
* - Progressive disclosure (show more fields as trust increases)
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // For Mongoose queries (PREFERRED - 90% of cases)
|
|
16
|
+
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
17
|
+
* const plans = await GymPlan.find().select(projection).lean();
|
|
18
|
+
*
|
|
19
|
+
* // For complex data (10% of cases - aggregations, multiple sources)
|
|
20
|
+
* const filtered = filterResponseData(complexData, fieldPresets.gymPlans, request.user);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get allowed fields for a user based on their context
|
|
26
|
+
*
|
|
27
|
+
* @param user - User object from request.user (or null for public)
|
|
28
|
+
* @param preset - Field preset configuration
|
|
29
|
+
* @returns Array of allowed field names
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const fields = getFieldsForUser(request.user, {
|
|
33
|
+
* public: ['id', 'name', 'price'],
|
|
34
|
+
* authenticated: ['description', 'features'],
|
|
35
|
+
* admin: ['createdAt', 'internalNotes']
|
|
36
|
+
* });
|
|
37
|
+
*/
|
|
38
|
+
declare function getFieldsForUser(user: UserContext | null | undefined, preset: FieldPreset): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Get Mongoose projection string for query .select()
|
|
41
|
+
*
|
|
42
|
+
* @param user - User object from request.user
|
|
43
|
+
* @param preset - Field preset configuration
|
|
44
|
+
* @returns Space-separated field names for Mongoose .select()
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
48
|
+
* const plans = await GymPlan.find({ organizationId }).select(projection).lean();
|
|
49
|
+
*/
|
|
50
|
+
declare function getMongooseProjection(user: UserContext | null | undefined, preset: FieldPreset): string;
|
|
51
|
+
/**
|
|
52
|
+
* Filter response data to include only allowed fields
|
|
53
|
+
*
|
|
54
|
+
* Use this for complex responses where Mongoose projections aren't applicable:
|
|
55
|
+
* - Aggregation pipeline results
|
|
56
|
+
* - Data from multiple sources
|
|
57
|
+
* - Custom computed fields
|
|
58
|
+
*
|
|
59
|
+
* For simple DB queries, prefer getMongooseProjection() (10x faster)
|
|
60
|
+
*
|
|
61
|
+
* @param data - Data to filter
|
|
62
|
+
* @param preset - Field preset configuration
|
|
63
|
+
* @param user - User object from request.user
|
|
64
|
+
* @returns Filtered data
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const stats = await calculateComplexStats();
|
|
68
|
+
* const filtered = filterResponseData(stats, fieldPresets.dashboard, request.user);
|
|
69
|
+
* return reply.send(filtered);
|
|
70
|
+
*/
|
|
71
|
+
declare function filterResponseData<T extends Record<string, unknown>>(data: T | T[], preset: FieldPreset, user?: UserContext | null): Partial<T> | Partial<T>[];
|
|
72
|
+
/**
|
|
73
|
+
* Helper to create field presets (module-level)
|
|
74
|
+
*
|
|
75
|
+
* Each module should define its own field preset in its own directory.
|
|
76
|
+
* This keeps modules independent and self-contained.
|
|
77
|
+
*
|
|
78
|
+
* @param config - Field configuration
|
|
79
|
+
* @returns Field preset
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* // In modules/gym-plan/gym-plan.fields.ts
|
|
83
|
+
* export const gymPlanFieldPreset = createFieldPreset({
|
|
84
|
+
* public: ['id', 'name', 'price'],
|
|
85
|
+
* authenticated: ['features', 'description'],
|
|
86
|
+
* admin: ['createdAt', 'updatedAt', 'internalNotes']
|
|
87
|
+
* });
|
|
88
|
+
*/
|
|
89
|
+
declare function createFieldPreset(config: Partial<FieldPreset>): FieldPreset;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Error Utilities
|
|
93
|
+
*
|
|
94
|
+
* HTTP-compatible error creation for repository operations
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates an error with HTTP status code
|
|
99
|
+
*
|
|
100
|
+
* @param status - HTTP status code
|
|
101
|
+
* @param message - Error message
|
|
102
|
+
* @returns Error with status property
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* throw createError(404, 'Document not found');
|
|
106
|
+
* throw createError(400, 'Invalid input');
|
|
107
|
+
* throw createError(403, 'Access denied');
|
|
108
|
+
*/
|
|
109
|
+
declare function createError(status: number, message: string): HttpError;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* In-Memory Cache Adapter
|
|
113
|
+
*
|
|
114
|
+
* Simple cache adapter for development and testing.
|
|
115
|
+
* NOT recommended for production - use Redis or similar.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
|
|
120
|
+
*
|
|
121
|
+
* const repo = new Repository(UserModel, [
|
|
122
|
+
* cachePlugin({
|
|
123
|
+
* adapter: createMemoryCache(),
|
|
124
|
+
* ttl: 60,
|
|
125
|
+
* })
|
|
126
|
+
* ]);
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates an in-memory cache adapter
|
|
132
|
+
*
|
|
133
|
+
* Features:
|
|
134
|
+
* - Automatic TTL expiration
|
|
135
|
+
* - Pattern-based clearing (simple glob with *)
|
|
136
|
+
* - Max entries limit to prevent memory leaks
|
|
137
|
+
*
|
|
138
|
+
* @param maxEntries - Maximum cache entries before oldest are evicted (default: 1000)
|
|
139
|
+
*/
|
|
140
|
+
declare function createMemoryCache(maxEntries?: number): CacheAdapter;
|
|
141
|
+
|
|
142
|
+
export { getMongooseProjection as a, createError as b, createFieldPreset as c, createMemoryCache as d, filterResponseData as f, getFieldsForUser as g };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { o as UserContext, F as FieldPreset, H as HttpError, N as CacheAdapter } from './types-Nxhmi1aI.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Field Selection Utilities
|
|
5
|
+
*
|
|
6
|
+
* Provides explicit, performant field filtering using Mongoose projections.
|
|
7
|
+
*
|
|
8
|
+
* Philosophy:
|
|
9
|
+
* - Explicit is better than implicit
|
|
10
|
+
* - Filter at DB level (10x faster than in-memory)
|
|
11
|
+
* - Progressive disclosure (show more fields as trust increases)
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // For Mongoose queries (PREFERRED - 90% of cases)
|
|
16
|
+
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
17
|
+
* const plans = await GymPlan.find().select(projection).lean();
|
|
18
|
+
*
|
|
19
|
+
* // For complex data (10% of cases - aggregations, multiple sources)
|
|
20
|
+
* const filtered = filterResponseData(complexData, fieldPresets.gymPlans, request.user);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get allowed fields for a user based on their context
|
|
26
|
+
*
|
|
27
|
+
* @param user - User object from request.user (or null for public)
|
|
28
|
+
* @param preset - Field preset configuration
|
|
29
|
+
* @returns Array of allowed field names
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const fields = getFieldsForUser(request.user, {
|
|
33
|
+
* public: ['id', 'name', 'price'],
|
|
34
|
+
* authenticated: ['description', 'features'],
|
|
35
|
+
* admin: ['createdAt', 'internalNotes']
|
|
36
|
+
* });
|
|
37
|
+
*/
|
|
38
|
+
declare function getFieldsForUser(user: UserContext | null | undefined, preset: FieldPreset): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Get Mongoose projection string for query .select()
|
|
41
|
+
*
|
|
42
|
+
* @param user - User object from request.user
|
|
43
|
+
* @param preset - Field preset configuration
|
|
44
|
+
* @returns Space-separated field names for Mongoose .select()
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
48
|
+
* const plans = await GymPlan.find({ organizationId }).select(projection).lean();
|
|
49
|
+
*/
|
|
50
|
+
declare function getMongooseProjection(user: UserContext | null | undefined, preset: FieldPreset): string;
|
|
51
|
+
/**
|
|
52
|
+
* Filter response data to include only allowed fields
|
|
53
|
+
*
|
|
54
|
+
* Use this for complex responses where Mongoose projections aren't applicable:
|
|
55
|
+
* - Aggregation pipeline results
|
|
56
|
+
* - Data from multiple sources
|
|
57
|
+
* - Custom computed fields
|
|
58
|
+
*
|
|
59
|
+
* For simple DB queries, prefer getMongooseProjection() (10x faster)
|
|
60
|
+
*
|
|
61
|
+
* @param data - Data to filter
|
|
62
|
+
* @param preset - Field preset configuration
|
|
63
|
+
* @param user - User object from request.user
|
|
64
|
+
* @returns Filtered data
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const stats = await calculateComplexStats();
|
|
68
|
+
* const filtered = filterResponseData(stats, fieldPresets.dashboard, request.user);
|
|
69
|
+
* return reply.send(filtered);
|
|
70
|
+
*/
|
|
71
|
+
declare function filterResponseData<T extends Record<string, unknown>>(data: T | T[], preset: FieldPreset, user?: UserContext | null): Partial<T> | Partial<T>[];
|
|
72
|
+
/**
|
|
73
|
+
* Helper to create field presets (module-level)
|
|
74
|
+
*
|
|
75
|
+
* Each module should define its own field preset in its own directory.
|
|
76
|
+
* This keeps modules independent and self-contained.
|
|
77
|
+
*
|
|
78
|
+
* @param config - Field configuration
|
|
79
|
+
* @returns Field preset
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* // In modules/gym-plan/gym-plan.fields.ts
|
|
83
|
+
* export const gymPlanFieldPreset = createFieldPreset({
|
|
84
|
+
* public: ['id', 'name', 'price'],
|
|
85
|
+
* authenticated: ['features', 'description'],
|
|
86
|
+
* admin: ['createdAt', 'updatedAt', 'internalNotes']
|
|
87
|
+
* });
|
|
88
|
+
*/
|
|
89
|
+
declare function createFieldPreset(config: Partial<FieldPreset>): FieldPreset;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Error Utilities
|
|
93
|
+
*
|
|
94
|
+
* HTTP-compatible error creation for repository operations
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates an error with HTTP status code
|
|
99
|
+
*
|
|
100
|
+
* @param status - HTTP status code
|
|
101
|
+
* @param message - Error message
|
|
102
|
+
* @returns Error with status property
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* throw createError(404, 'Document not found');
|
|
106
|
+
* throw createError(400, 'Invalid input');
|
|
107
|
+
* throw createError(403, 'Access denied');
|
|
108
|
+
*/
|
|
109
|
+
declare function createError(status: number, message: string): HttpError;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* In-Memory Cache Adapter
|
|
113
|
+
*
|
|
114
|
+
* Simple cache adapter for development and testing.
|
|
115
|
+
* NOT recommended for production - use Redis or similar.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
|
|
120
|
+
*
|
|
121
|
+
* const repo = new Repository(UserModel, [
|
|
122
|
+
* cachePlugin({
|
|
123
|
+
* adapter: createMemoryCache(),
|
|
124
|
+
* ttl: 60,
|
|
125
|
+
* })
|
|
126
|
+
* ]);
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates an in-memory cache adapter
|
|
132
|
+
*
|
|
133
|
+
* Features:
|
|
134
|
+
* - Automatic TTL expiration
|
|
135
|
+
* - Pattern-based clearing (simple glob with *)
|
|
136
|
+
* - Max entries limit to prevent memory leaks
|
|
137
|
+
*
|
|
138
|
+
* @param maxEntries - Maximum cache entries before oldest are evicted (default: 1000)
|
|
139
|
+
*/
|
|
140
|
+
declare function createMemoryCache(maxEntries?: number): CacheAdapter;
|
|
141
|
+
|
|
142
|
+
export { getMongooseProjection as a, createError as b, createFieldPreset as c, createMemoryCache as d, filterResponseData as f, getFieldsForUser as g };
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var mongoose = require('mongoose');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var mongoose__default = /*#__PURE__*/_interopDefault(mongoose);
|
|
8
|
+
|
|
9
|
+
// src/pagination/utils/cursor.ts
|
|
10
|
+
function encodeCursor(doc, primaryField, sort, version = 1) {
|
|
11
|
+
const primaryValue = doc[primaryField];
|
|
12
|
+
const idValue = doc._id;
|
|
13
|
+
const payload = {
|
|
14
|
+
v: serializeValue(primaryValue),
|
|
15
|
+
t: getValueType(primaryValue),
|
|
16
|
+
id: serializeValue(idValue),
|
|
17
|
+
idType: getValueType(idValue),
|
|
18
|
+
sort,
|
|
19
|
+
ver: version
|
|
20
|
+
};
|
|
21
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
22
|
+
}
|
|
23
|
+
function decodeCursor(token) {
|
|
24
|
+
try {
|
|
25
|
+
const json = Buffer.from(token, "base64").toString("utf-8");
|
|
26
|
+
const payload = JSON.parse(json);
|
|
27
|
+
return {
|
|
28
|
+
value: rehydrateValue(payload.v, payload.t),
|
|
29
|
+
id: rehydrateValue(payload.id, payload.idType),
|
|
30
|
+
sort: payload.sort,
|
|
31
|
+
version: payload.ver
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("Invalid cursor token");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function validateCursorSort(cursorSort, currentSort) {
|
|
38
|
+
const cursorSortStr = JSON.stringify(cursorSort);
|
|
39
|
+
const currentSortStr = JSON.stringify(currentSort);
|
|
40
|
+
if (cursorSortStr !== currentSortStr) {
|
|
41
|
+
throw new Error("Cursor sort does not match current query sort");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateCursorVersion(cursorVersion, expectedVersion) {
|
|
45
|
+
if (cursorVersion !== expectedVersion) {
|
|
46
|
+
throw new Error(`Cursor version ${cursorVersion} does not match expected version ${expectedVersion}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function serializeValue(value) {
|
|
50
|
+
if (value instanceof Date) return value.toISOString();
|
|
51
|
+
if (value instanceof mongoose__default.default.Types.ObjectId) return value.toString();
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function getValueType(value) {
|
|
55
|
+
if (value instanceof Date) return "date";
|
|
56
|
+
if (value instanceof mongoose__default.default.Types.ObjectId) return "objectid";
|
|
57
|
+
if (typeof value === "boolean") return "boolean";
|
|
58
|
+
if (typeof value === "number") return "number";
|
|
59
|
+
if (typeof value === "string") return "string";
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
function rehydrateValue(serialized, type) {
|
|
63
|
+
switch (type) {
|
|
64
|
+
case "date":
|
|
65
|
+
return new Date(serialized);
|
|
66
|
+
case "objectid":
|
|
67
|
+
return new mongoose__default.default.Types.ObjectId(serialized);
|
|
68
|
+
case "boolean":
|
|
69
|
+
return Boolean(serialized);
|
|
70
|
+
case "number":
|
|
71
|
+
return Number(serialized);
|
|
72
|
+
default:
|
|
73
|
+
return serialized;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/pagination/utils/sort.ts
|
|
78
|
+
function normalizeSort(sort) {
|
|
79
|
+
const normalized = {};
|
|
80
|
+
Object.keys(sort).forEach((key) => {
|
|
81
|
+
if (key !== "_id") normalized[key] = sort[key];
|
|
82
|
+
});
|
|
83
|
+
if (sort._id !== void 0) {
|
|
84
|
+
normalized._id = sort._id;
|
|
85
|
+
}
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
function validateKeysetSort(sort) {
|
|
89
|
+
const keys = Object.keys(sort);
|
|
90
|
+
if (keys.length === 1 && keys[0] !== "_id") {
|
|
91
|
+
const field = keys[0];
|
|
92
|
+
const direction = sort[field];
|
|
93
|
+
return normalizeSort({ [field]: direction, _id: direction });
|
|
94
|
+
}
|
|
95
|
+
if (keys.length === 1 && keys[0] === "_id") {
|
|
96
|
+
return normalizeSort(sort);
|
|
97
|
+
}
|
|
98
|
+
if (keys.length === 2) {
|
|
99
|
+
if (!keys.includes("_id")) {
|
|
100
|
+
throw new Error("Keyset pagination requires _id as tie-breaker");
|
|
101
|
+
}
|
|
102
|
+
const primaryField = keys.find((k) => k !== "_id");
|
|
103
|
+
const primaryDirection = sort[primaryField];
|
|
104
|
+
const idDirection = sort._id;
|
|
105
|
+
if (primaryDirection !== idDirection) {
|
|
106
|
+
throw new Error("_id direction must match primary field direction");
|
|
107
|
+
}
|
|
108
|
+
return normalizeSort(sort);
|
|
109
|
+
}
|
|
110
|
+
throw new Error("Keyset pagination only supports single field + _id");
|
|
111
|
+
}
|
|
112
|
+
function getPrimaryField(sort) {
|
|
113
|
+
const keys = Object.keys(sort);
|
|
114
|
+
return keys.find((k) => k !== "_id") || "_id";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/pagination/utils/filter.ts
|
|
118
|
+
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
|
|
119
|
+
const primaryField = Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
120
|
+
const direction = sort[primaryField];
|
|
121
|
+
const operator = direction === 1 ? "$gt" : "$lt";
|
|
122
|
+
return {
|
|
123
|
+
...baseFilters,
|
|
124
|
+
$or: [
|
|
125
|
+
{ [primaryField]: { [operator]: cursorValue } },
|
|
126
|
+
{
|
|
127
|
+
[primaryField]: cursorValue,
|
|
128
|
+
_id: { [operator]: cursorId }
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/pagination/utils/limits.ts
|
|
135
|
+
function validateLimit(limit, config) {
|
|
136
|
+
const parsed = Number(limit);
|
|
137
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
138
|
+
return config.defaultLimit || 10;
|
|
139
|
+
}
|
|
140
|
+
return Math.min(Math.floor(parsed), config.maxLimit || 100);
|
|
141
|
+
}
|
|
142
|
+
function validatePage(page, config) {
|
|
143
|
+
const parsed = Number(page);
|
|
144
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const sanitized = Math.floor(parsed);
|
|
148
|
+
if (sanitized > (config.maxPage || 1e4)) {
|
|
149
|
+
throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage || 1e4}`);
|
|
150
|
+
}
|
|
151
|
+
return sanitized;
|
|
152
|
+
}
|
|
153
|
+
function shouldWarnDeepPagination(page, threshold) {
|
|
154
|
+
return page > threshold;
|
|
155
|
+
}
|
|
156
|
+
function calculateSkip(page, limit) {
|
|
157
|
+
return (page - 1) * limit;
|
|
158
|
+
}
|
|
159
|
+
function calculateTotalPages(total, limit) {
|
|
160
|
+
return Math.ceil(total / limit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/utils/error.ts
|
|
164
|
+
function createError(status, message) {
|
|
165
|
+
const error = new Error(message);
|
|
166
|
+
error.status = status;
|
|
167
|
+
return error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/pagination/PaginationEngine.ts
|
|
171
|
+
var PaginationEngine = class {
|
|
172
|
+
Model;
|
|
173
|
+
config;
|
|
174
|
+
/**
|
|
175
|
+
* Create a new pagination engine
|
|
176
|
+
*
|
|
177
|
+
* @param Model - Mongoose model to paginate
|
|
178
|
+
* @param config - Pagination configuration
|
|
179
|
+
*/
|
|
180
|
+
constructor(Model, config = {}) {
|
|
181
|
+
this.Model = Model;
|
|
182
|
+
this.config = {
|
|
183
|
+
defaultLimit: config.defaultLimit || 10,
|
|
184
|
+
maxLimit: config.maxLimit || 100,
|
|
185
|
+
maxPage: config.maxPage || 1e4,
|
|
186
|
+
deepPageThreshold: config.deepPageThreshold || 100,
|
|
187
|
+
cursorVersion: config.cursorVersion || 1,
|
|
188
|
+
useEstimatedCount: config.useEstimatedCount || false
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Offset-based pagination using skip/limit
|
|
193
|
+
* Best for small datasets and when users need random page access
|
|
194
|
+
* O(n) performance - slower for deep pages
|
|
195
|
+
*
|
|
196
|
+
* @param options - Pagination options
|
|
197
|
+
* @returns Pagination result with total count
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* const result = await engine.paginate({
|
|
201
|
+
* filters: { status: 'active' },
|
|
202
|
+
* sort: { createdAt: -1 },
|
|
203
|
+
* page: 1,
|
|
204
|
+
* limit: 20
|
|
205
|
+
* });
|
|
206
|
+
* console.log(result.docs, result.total, result.hasNext);
|
|
207
|
+
*/
|
|
208
|
+
async paginate(options = {}) {
|
|
209
|
+
const {
|
|
210
|
+
filters = {},
|
|
211
|
+
sort = { _id: -1 },
|
|
212
|
+
page = 1,
|
|
213
|
+
limit = this.config.defaultLimit,
|
|
214
|
+
select,
|
|
215
|
+
populate = [],
|
|
216
|
+
lean = true,
|
|
217
|
+
session
|
|
218
|
+
} = options;
|
|
219
|
+
const sanitizedPage = validatePage(page, this.config);
|
|
220
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
221
|
+
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
222
|
+
let query = this.Model.find(filters);
|
|
223
|
+
if (select) query = query.select(select);
|
|
224
|
+
if (populate && (Array.isArray(populate) ? populate.length : populate)) {
|
|
225
|
+
query = query.populate(populate);
|
|
226
|
+
}
|
|
227
|
+
query = query.sort(sort).skip(skip).limit(sanitizedLimit).lean(lean);
|
|
228
|
+
if (session) query = query.session(session);
|
|
229
|
+
const hasFilters = Object.keys(filters).length > 0;
|
|
230
|
+
const useEstimated = this.config.useEstimatedCount && !hasFilters;
|
|
231
|
+
const [docs, total] = await Promise.all([
|
|
232
|
+
query.exec(),
|
|
233
|
+
useEstimated ? this.Model.estimatedDocumentCount() : this.Model.countDocuments(filters).session(session ?? null)
|
|
234
|
+
]);
|
|
235
|
+
const totalPages = calculateTotalPages(total, sanitizedLimit);
|
|
236
|
+
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : void 0;
|
|
237
|
+
return {
|
|
238
|
+
method: "offset",
|
|
239
|
+
docs,
|
|
240
|
+
page: sanitizedPage,
|
|
241
|
+
limit: sanitizedLimit,
|
|
242
|
+
total,
|
|
243
|
+
pages: totalPages,
|
|
244
|
+
hasNext: sanitizedPage < totalPages,
|
|
245
|
+
hasPrev: sanitizedPage > 1,
|
|
246
|
+
...warning && { warning }
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Keyset (cursor-based) pagination for high-performance streaming
|
|
251
|
+
* Best for large datasets, infinite scroll, real-time feeds
|
|
252
|
+
* O(1) performance - consistent speed regardless of position
|
|
253
|
+
*
|
|
254
|
+
* @param options - Pagination options (sort is required)
|
|
255
|
+
* @returns Pagination result with next cursor
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* // First page
|
|
259
|
+
* const page1 = await engine.stream({
|
|
260
|
+
* sort: { createdAt: -1 },
|
|
261
|
+
* limit: 20
|
|
262
|
+
* });
|
|
263
|
+
*
|
|
264
|
+
* // Next page using cursor
|
|
265
|
+
* const page2 = await engine.stream({
|
|
266
|
+
* sort: { createdAt: -1 },
|
|
267
|
+
* after: page1.next,
|
|
268
|
+
* limit: 20
|
|
269
|
+
* });
|
|
270
|
+
*/
|
|
271
|
+
async stream(options) {
|
|
272
|
+
const {
|
|
273
|
+
filters = {},
|
|
274
|
+
sort,
|
|
275
|
+
after,
|
|
276
|
+
limit = this.config.defaultLimit,
|
|
277
|
+
select,
|
|
278
|
+
populate = [],
|
|
279
|
+
lean = true,
|
|
280
|
+
session
|
|
281
|
+
} = options;
|
|
282
|
+
if (!sort) {
|
|
283
|
+
throw createError(400, "sort is required for keyset pagination");
|
|
284
|
+
}
|
|
285
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
286
|
+
const normalizedSort = validateKeysetSort(sort);
|
|
287
|
+
let query = { ...filters };
|
|
288
|
+
if (after) {
|
|
289
|
+
const cursor = decodeCursor(after);
|
|
290
|
+
validateCursorVersion(cursor.version, this.config.cursorVersion);
|
|
291
|
+
validateCursorSort(cursor.sort, normalizedSort);
|
|
292
|
+
query = buildKeysetFilter(query, normalizedSort, cursor.value, cursor.id);
|
|
293
|
+
}
|
|
294
|
+
let mongoQuery = this.Model.find(query);
|
|
295
|
+
if (select) mongoQuery = mongoQuery.select(select);
|
|
296
|
+
if (populate && (Array.isArray(populate) ? populate.length : populate)) {
|
|
297
|
+
mongoQuery = mongoQuery.populate(populate);
|
|
298
|
+
}
|
|
299
|
+
mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
|
|
300
|
+
if (session) mongoQuery = mongoQuery.session(session);
|
|
301
|
+
const docs = await mongoQuery.exec();
|
|
302
|
+
const hasMore = docs.length > sanitizedLimit;
|
|
303
|
+
if (hasMore) docs.pop();
|
|
304
|
+
const primaryField = getPrimaryField(normalizedSort);
|
|
305
|
+
const nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null;
|
|
306
|
+
return {
|
|
307
|
+
method: "keyset",
|
|
308
|
+
docs,
|
|
309
|
+
limit: sanitizedLimit,
|
|
310
|
+
hasMore,
|
|
311
|
+
next: nextCursor
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Aggregate pipeline with pagination
|
|
316
|
+
* Best for complex queries requiring aggregation stages
|
|
317
|
+
* Uses $facet to combine results and count in single query
|
|
318
|
+
*
|
|
319
|
+
* @param options - Aggregation options
|
|
320
|
+
* @returns Pagination result with total count
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* const result = await engine.aggregatePaginate({
|
|
324
|
+
* pipeline: [
|
|
325
|
+
* { $match: { status: 'active' } },
|
|
326
|
+
* { $group: { _id: '$category', count: { $sum: 1 } } },
|
|
327
|
+
* { $sort: { count: -1 } }
|
|
328
|
+
* ],
|
|
329
|
+
* page: 1,
|
|
330
|
+
* limit: 20
|
|
331
|
+
* });
|
|
332
|
+
*/
|
|
333
|
+
async aggregatePaginate(options = {}) {
|
|
334
|
+
const {
|
|
335
|
+
pipeline = [],
|
|
336
|
+
page = 1,
|
|
337
|
+
limit = this.config.defaultLimit,
|
|
338
|
+
session
|
|
339
|
+
} = options;
|
|
340
|
+
const sanitizedPage = validatePage(page, this.config);
|
|
341
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
342
|
+
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
343
|
+
const facetPipeline = [
|
|
344
|
+
...pipeline,
|
|
345
|
+
{
|
|
346
|
+
$facet: {
|
|
347
|
+
docs: [{ $skip: skip }, { $limit: sanitizedLimit }],
|
|
348
|
+
total: [{ $count: "count" }]
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
];
|
|
352
|
+
const aggregation = this.Model.aggregate(facetPipeline);
|
|
353
|
+
if (session) aggregation.session(session);
|
|
354
|
+
const [result] = await aggregation.exec();
|
|
355
|
+
const docs = result.docs;
|
|
356
|
+
const total = result.total[0]?.count || 0;
|
|
357
|
+
const totalPages = calculateTotalPages(total, sanitizedLimit);
|
|
358
|
+
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
|
|
359
|
+
return {
|
|
360
|
+
method: "aggregate",
|
|
361
|
+
docs,
|
|
362
|
+
page: sanitizedPage,
|
|
363
|
+
limit: sanitizedLimit,
|
|
364
|
+
total,
|
|
365
|
+
pages: totalPages,
|
|
366
|
+
hasNext: sanitizedPage < totalPages,
|
|
367
|
+
hasPrev: sanitizedPage > 1,
|
|
368
|
+
...warning && { warning }
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
exports.PaginationEngine = PaginationEngine;
|
|
374
|
+
//# sourceMappingURL=PaginationEngine.cjs.map
|
|
375
|
+
//# sourceMappingURL=PaginationEngine.cjs.map
|