@classytic/mongokit 3.3.2 → 3.4.1
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 +137 -7
- package/dist/PaginationEngine-nY04eGUM.mjs +290 -0
- package/dist/actions/index.d.mts +2 -9
- package/dist/actions/index.mjs +3 -5
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +3 -3
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/{limits-s1-d8rWb.mjs → cursor-CHToazHy.mjs} +122 -171
- package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
- package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-reyDRzXf.mjs} +110 -112
- package/dist/{aggregate-BkOG9qwr.d.mts → index-BuoZIZ15.d.mts} +132 -129
- package/dist/index.d.mts +549 -543
- package/dist/index.mjs +33 -101
- package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-B6Qyl8BK.mjs} +13 -12
- package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-RX9YfJLu.d.mts} +24 -17
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +2 -209
- package/dist/plugins/index.d.mts +1 -2
- package/dist/plugins/index.mjs +2 -3
- package/dist/sort-C-BJEWUZ.mjs +57 -0
- package/dist/{types-pVY0w1Pp.d.mts → types-COINbsdL.d.mts} +57 -27
- package/dist/{aggregate-BClp040M.mjs → update-DGKMmBgG.mjs} +575 -565
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +4 -5
- package/dist/{custom-id.plugin-BJ3FSnzt.d.mts → validation-chain.plugin-BNoaKDOm.d.mts} +832 -832
- package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-da3fOo8A.mjs} +2410 -2246
- package/package.json +11 -6
- package/dist/chunk-DQk6qfdC.mjs +0 -18
|
@@ -1,12 +1,93 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
|
|
1
2
|
import mongoose from "mongoose";
|
|
2
|
-
|
|
3
|
+
//#region src/pagination/utils/filter.ts
|
|
4
|
+
/**
|
|
5
|
+
* Builds MongoDB filter for keyset pagination
|
|
6
|
+
* Creates compound $or condition for proper cursor-based filtering
|
|
7
|
+
*
|
|
8
|
+
* @param baseFilters - Existing query filters
|
|
9
|
+
* @param sort - Normalized sort specification
|
|
10
|
+
* @param cursorValue - Primary field value from cursor
|
|
11
|
+
* @param cursorId - _id value from cursor
|
|
12
|
+
* @returns MongoDB filter with keyset condition
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* buildKeysetFilter(
|
|
16
|
+
* { status: 'active' },
|
|
17
|
+
* { createdAt: -1, _id: -1 },
|
|
18
|
+
* new Date('2024-01-01'),
|
|
19
|
+
* new ObjectId('...')
|
|
20
|
+
* )
|
|
21
|
+
* // Returns:
|
|
22
|
+
* // {
|
|
23
|
+
* // status: 'active',
|
|
24
|
+
* // $or: [
|
|
25
|
+
* // { createdAt: { $lt: Date('2024-01-01') } },
|
|
26
|
+
* // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
|
|
27
|
+
* // ]
|
|
28
|
+
* // }
|
|
29
|
+
*/
|
|
30
|
+
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValues) {
|
|
31
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
32
|
+
if (sortFields.length <= 1 && !cursorValues) {
|
|
33
|
+
const primaryField = sortFields[0] || "_id";
|
|
34
|
+
const direction = sort[primaryField];
|
|
35
|
+
const operator = direction === 1 ? "$gt" : "$lt";
|
|
36
|
+
if (cursorValue === null || cursorValue === void 0) if (direction === 1) return {
|
|
37
|
+
...baseFilters,
|
|
38
|
+
$or: [{
|
|
39
|
+
[primaryField]: null,
|
|
40
|
+
_id: { $gt: cursorId }
|
|
41
|
+
}, { [primaryField]: { $ne: null } }]
|
|
42
|
+
};
|
|
43
|
+
else return {
|
|
44
|
+
...baseFilters,
|
|
45
|
+
[primaryField]: null,
|
|
46
|
+
_id: { $lt: cursorId }
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
...baseFilters,
|
|
50
|
+
$or: [{ [primaryField]: { [operator]: cursorValue } }, {
|
|
51
|
+
[primaryField]: cursorValue,
|
|
52
|
+
_id: { [operator]: cursorId }
|
|
53
|
+
}]
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const values = cursorValues || { [sortFields[0]]: cursorValue };
|
|
57
|
+
const allFields = [...sortFields, "_id"];
|
|
58
|
+
const allValues = {
|
|
59
|
+
...values,
|
|
60
|
+
_id: cursorId
|
|
61
|
+
};
|
|
62
|
+
const orConditions = [];
|
|
63
|
+
for (let i = 0; i < allFields.length; i++) {
|
|
64
|
+
const field = allFields[i];
|
|
65
|
+
const operator = (sort[field] ?? sort[sortFields[0]]) === 1 ? "$gt" : "$lt";
|
|
66
|
+
const condition = {};
|
|
67
|
+
for (let j = 0; j < i; j++) condition[allFields[j]] = allValues[allFields[j]];
|
|
68
|
+
condition[field] = { [operator]: allValues[field] };
|
|
69
|
+
orConditions.push(condition);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...baseFilters,
|
|
73
|
+
$or: orConditions
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
3
77
|
//#region src/pagination/utils/cursor.ts
|
|
4
78
|
/**
|
|
5
79
|
* Cursor Utilities
|
|
6
|
-
*
|
|
80
|
+
*
|
|
7
81
|
* Encoding and decoding of cursor tokens for keyset pagination.
|
|
8
82
|
* Cursors are base64-encoded JSON containing position data and metadata.
|
|
9
83
|
*/
|
|
84
|
+
var cursor_exports = /* @__PURE__ */ __exportAll({
|
|
85
|
+
decodeCursor: () => decodeCursor,
|
|
86
|
+
encodeCursor: () => encodeCursor,
|
|
87
|
+
resolveCursorFilter: () => resolveCursorFilter,
|
|
88
|
+
validateCursorSort: () => validateCursorSort,
|
|
89
|
+
validateCursorVersion: () => validateCursorVersion
|
|
90
|
+
});
|
|
10
91
|
/**
|
|
11
92
|
* Encodes document values and sort metadata into a base64 cursor token
|
|
12
93
|
*
|
|
@@ -19,13 +100,24 @@ import mongoose from "mongoose";
|
|
|
19
100
|
function encodeCursor(doc, primaryField, sort, version = 1) {
|
|
20
101
|
const primaryValue = doc[primaryField];
|
|
21
102
|
const idValue = doc._id;
|
|
103
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
104
|
+
const vals = {};
|
|
105
|
+
const types = {};
|
|
106
|
+
for (const field of sortFields) {
|
|
107
|
+
vals[field] = serializeValue(doc[field]);
|
|
108
|
+
types[field] = getValueType(doc[field]);
|
|
109
|
+
}
|
|
22
110
|
const payload = {
|
|
23
111
|
v: serializeValue(primaryValue),
|
|
24
112
|
t: getValueType(primaryValue),
|
|
25
113
|
id: serializeValue(idValue),
|
|
26
114
|
idType: getValueType(idValue),
|
|
27
115
|
sort,
|
|
28
|
-
ver: version
|
|
116
|
+
ver: version,
|
|
117
|
+
...sortFields.length > 1 && {
|
|
118
|
+
vals,
|
|
119
|
+
types
|
|
120
|
+
}
|
|
29
121
|
};
|
|
30
122
|
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
31
123
|
}
|
|
@@ -61,11 +153,17 @@ function decodeCursor(token) {
|
|
|
61
153
|
];
|
|
62
154
|
if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw new Error("Invalid cursor token: unrecognized value type");
|
|
63
155
|
try {
|
|
156
|
+
let values;
|
|
157
|
+
if (payload.vals && payload.types) {
|
|
158
|
+
values = {};
|
|
159
|
+
for (const [field, serialized] of Object.entries(payload.vals)) values[field] = rehydrateValue(serialized, payload.types[field]);
|
|
160
|
+
}
|
|
64
161
|
return {
|
|
65
162
|
value: rehydrateValue(payload.v, payload.t),
|
|
66
163
|
id: rehydrateValue(payload.id, payload.idType),
|
|
67
164
|
sort: payload.sort,
|
|
68
|
-
version: payload.ver
|
|
165
|
+
version: payload.ver,
|
|
166
|
+
...values && { values }
|
|
69
167
|
};
|
|
70
168
|
} catch {
|
|
71
169
|
throw new Error("Invalid cursor token: failed to rehydrate values");
|
|
@@ -125,175 +223,28 @@ function rehydrateValue(serialized, type) {
|
|
|
125
223
|
default: return serialized;
|
|
126
224
|
}
|
|
127
225
|
}
|
|
128
|
-
|
|
129
|
-
//#endregion
|
|
130
|
-
//#region src/pagination/utils/sort.ts
|
|
131
|
-
/**
|
|
132
|
-
* Normalizes sort object to ensure stable key order
|
|
133
|
-
* Primary fields first, _id last (not alphabetical)
|
|
134
|
-
*
|
|
135
|
-
* @param sort - Sort specification
|
|
136
|
-
* @returns Normalized sort with stable key order
|
|
137
|
-
*/
|
|
138
|
-
function normalizeSort(sort) {
|
|
139
|
-
const normalized = {};
|
|
140
|
-
Object.keys(sort).forEach((key) => {
|
|
141
|
-
if (key !== "_id") normalized[key] = sort[key];
|
|
142
|
-
});
|
|
143
|
-
if (sort._id !== void 0) normalized._id = sort._id;
|
|
144
|
-
return normalized;
|
|
145
|
-
}
|
|
146
226
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* Ensures _id direction matches primary field
|
|
227
|
+
* Resolves cursor token into MongoDB query filters.
|
|
228
|
+
* Shared by PaginationEngine.stream() and Repository.lookupPopulate() keyset path.
|
|
150
229
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
_id:
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
|
|
166
|
-
if (keys.length === 2) {
|
|
167
|
-
if (!keys.includes("_id")) throw new Error("Keyset pagination requires _id as tie-breaker");
|
|
168
|
-
if (sort[keys.find((k) => k !== "_id")] !== sort._id) throw new Error("_id direction must match primary field direction");
|
|
169
|
-
return normalizeSort(sort);
|
|
230
|
+
* Handles:
|
|
231
|
+
* - Plain 24-char hex ObjectId strings (fallback cursor)
|
|
232
|
+
* - Base64-encoded cursor tokens (standard cursor)
|
|
233
|
+
* - Cursor version and sort validation
|
|
234
|
+
*/
|
|
235
|
+
function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}) {
|
|
236
|
+
if (/^[a-f0-9]{24}$/i.test(after)) {
|
|
237
|
+
const objectId = new mongoose.Types.ObjectId(after);
|
|
238
|
+
const idOperator = (sort._id || -1) === 1 ? "$gt" : "$lt";
|
|
239
|
+
return {
|
|
240
|
+
...baseFilters,
|
|
241
|
+
_id: { [idOperator]: objectId }
|
|
242
|
+
};
|
|
170
243
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
*
|
|
176
|
-
* @param sort - Sort specification
|
|
177
|
-
* @returns Primary field name
|
|
178
|
-
*/
|
|
179
|
-
function getPrimaryField(sort) {
|
|
180
|
-
return Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
//#endregion
|
|
184
|
-
//#region src/pagination/utils/filter.ts
|
|
185
|
-
/**
|
|
186
|
-
* Builds MongoDB filter for keyset pagination
|
|
187
|
-
* Creates compound $or condition for proper cursor-based filtering
|
|
188
|
-
*
|
|
189
|
-
* @param baseFilters - Existing query filters
|
|
190
|
-
* @param sort - Normalized sort specification
|
|
191
|
-
* @param cursorValue - Primary field value from cursor
|
|
192
|
-
* @param cursorId - _id value from cursor
|
|
193
|
-
* @returns MongoDB filter with keyset condition
|
|
194
|
-
*
|
|
195
|
-
* @example
|
|
196
|
-
* buildKeysetFilter(
|
|
197
|
-
* { status: 'active' },
|
|
198
|
-
* { createdAt: -1, _id: -1 },
|
|
199
|
-
* new Date('2024-01-01'),
|
|
200
|
-
* new ObjectId('...')
|
|
201
|
-
* )
|
|
202
|
-
* // Returns:
|
|
203
|
-
* // {
|
|
204
|
-
* // status: 'active',
|
|
205
|
-
* // $or: [
|
|
206
|
-
* // { createdAt: { $lt: Date('2024-01-01') } },
|
|
207
|
-
* // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
|
|
208
|
-
* // ]
|
|
209
|
-
* // }
|
|
210
|
-
*/
|
|
211
|
-
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
|
|
212
|
-
const primaryField = Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
213
|
-
const direction = sort[primaryField];
|
|
214
|
-
const operator = direction === 1 ? "$gt" : "$lt";
|
|
215
|
-
if (cursorValue === null || cursorValue === void 0) if (direction === 1) return {
|
|
216
|
-
...baseFilters,
|
|
217
|
-
$or: [{
|
|
218
|
-
[primaryField]: null,
|
|
219
|
-
_id: { $gt: cursorId }
|
|
220
|
-
}, { [primaryField]: { $ne: null } }]
|
|
221
|
-
};
|
|
222
|
-
else return {
|
|
223
|
-
...baseFilters,
|
|
224
|
-
[primaryField]: null,
|
|
225
|
-
_id: { $lt: cursorId }
|
|
226
|
-
};
|
|
227
|
-
return {
|
|
228
|
-
...baseFilters,
|
|
229
|
-
$or: [{ [primaryField]: { [operator]: cursorValue } }, {
|
|
230
|
-
[primaryField]: cursorValue,
|
|
231
|
-
_id: { [operator]: cursorId }
|
|
232
|
-
}]
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
//#endregion
|
|
237
|
-
//#region src/pagination/utils/limits.ts
|
|
238
|
-
/**
|
|
239
|
-
* Validates and sanitizes limit value
|
|
240
|
-
* Parses strings to numbers and prevents NaN bugs
|
|
241
|
-
*
|
|
242
|
-
* @param limit - Requested limit
|
|
243
|
-
* @param config - Pagination configuration
|
|
244
|
-
* @returns Sanitized limit between 1 and maxLimit
|
|
245
|
-
*/
|
|
246
|
-
function validateLimit(limit, config) {
|
|
247
|
-
const parsed = Number(limit);
|
|
248
|
-
if (!Number.isFinite(parsed) || parsed < 1) return config.defaultLimit || 10;
|
|
249
|
-
return Math.min(Math.floor(parsed), config.maxLimit || 100);
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Validates and sanitizes page number
|
|
253
|
-
* Parses strings to numbers and prevents NaN bugs
|
|
254
|
-
*
|
|
255
|
-
* @param page - Requested page (1-indexed)
|
|
256
|
-
* @param config - Pagination configuration
|
|
257
|
-
* @returns Sanitized page number >= 1
|
|
258
|
-
* @throws Error if page exceeds maxPage
|
|
259
|
-
*/
|
|
260
|
-
function validatePage(page, config) {
|
|
261
|
-
const parsed = Number(page);
|
|
262
|
-
if (!Number.isFinite(parsed) || parsed < 1) return 1;
|
|
263
|
-
const sanitized = Math.floor(parsed);
|
|
264
|
-
if (sanitized > (config.maxPage || 1e4)) throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage || 1e4}`);
|
|
265
|
-
return sanitized;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Checks if page number should trigger deep pagination warning
|
|
269
|
-
*
|
|
270
|
-
* @param page - Current page number
|
|
271
|
-
* @param threshold - Warning threshold
|
|
272
|
-
* @returns True if warning should be shown
|
|
273
|
-
*/
|
|
274
|
-
function shouldWarnDeepPagination(page, threshold) {
|
|
275
|
-
return page > threshold;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Calculates number of documents to skip for offset pagination
|
|
279
|
-
*
|
|
280
|
-
* @param page - Page number (1-indexed)
|
|
281
|
-
* @param limit - Documents per page
|
|
282
|
-
* @returns Number of documents to skip
|
|
283
|
-
*/
|
|
284
|
-
function calculateSkip(page, limit) {
|
|
285
|
-
return (page - 1) * limit;
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* Calculates total number of pages
|
|
289
|
-
*
|
|
290
|
-
* @param total - Total document count
|
|
291
|
-
* @param limit - Documents per page
|
|
292
|
-
* @returns Total number of pages
|
|
293
|
-
*/
|
|
294
|
-
function calculateTotalPages(total, limit) {
|
|
295
|
-
return Math.ceil(total / limit);
|
|
244
|
+
const cursor = decodeCursor(after);
|
|
245
|
+
validateCursorVersion(cursor.version, cursorVersion);
|
|
246
|
+
validateCursorSort(cursor.sort, sort);
|
|
247
|
+
return buildKeysetFilter(baseFilters, sort, cursor.value, cursor.id, cursor.values);
|
|
296
248
|
}
|
|
297
|
-
|
|
298
249
|
//#endregion
|
|
299
|
-
export {
|
|
250
|
+
export { encodeCursor as n, resolveCursorFilter as r, cursor_exports as t };
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
//#region src/utils/error.ts
|
|
2
|
-
/**
|
|
3
|
-
* Creates an error with HTTP status code
|
|
4
|
-
*
|
|
5
|
-
* @param status - HTTP status code
|
|
6
|
-
* @param message - Error message
|
|
7
|
-
* @returns Error with status property
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* throw createError(404, 'Document not found');
|
|
11
|
-
* throw createError(400, 'Invalid input');
|
|
12
|
-
* throw createError(403, 'Access denied');
|
|
13
|
-
*/
|
|
14
|
-
function createError(status, message) {
|
|
15
|
-
const error = new Error(message);
|
|
16
|
-
error.status = status;
|
|
17
|
-
return error;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
//#endregion
|
|
21
1
|
//#region src/utils/logger.ts
|
|
22
2
|
const noop = () => {};
|
|
23
3
|
let current = {
|
|
@@ -46,6 +26,38 @@ function warn(message, ...args) {
|
|
|
46
26
|
function debug(message, ...args) {
|
|
47
27
|
current.debug(message, ...args);
|
|
48
28
|
}
|
|
49
|
-
|
|
50
29
|
//#endregion
|
|
51
|
-
|
|
30
|
+
//#region src/utils/error.ts
|
|
31
|
+
/**
|
|
32
|
+
* Creates an error with HTTP status code
|
|
33
|
+
*
|
|
34
|
+
* @param status - HTTP status code
|
|
35
|
+
* @param message - Error message
|
|
36
|
+
* @returns Error with status property
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* throw createError(404, 'Document not found');
|
|
40
|
+
* throw createError(400, 'Invalid input');
|
|
41
|
+
* throw createError(403, 'Access denied');
|
|
42
|
+
*/
|
|
43
|
+
function createError(status, message) {
|
|
44
|
+
const error = new Error(message);
|
|
45
|
+
error.status = status;
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detect and convert a MongoDB E11000 duplicate-key error into
|
|
50
|
+
* a 409 HttpError with an actionable message.
|
|
51
|
+
*
|
|
52
|
+
* Returns `null` when the error is not a duplicate-key error.
|
|
53
|
+
*/
|
|
54
|
+
function parseDuplicateKeyError(error) {
|
|
55
|
+
if (!error || typeof error !== "object") return null;
|
|
56
|
+
const mongoErr = error;
|
|
57
|
+
if (mongoErr.code !== 11e3) return null;
|
|
58
|
+
const fields = mongoErr.keyPattern ? Object.keys(mongoErr.keyPattern) : [];
|
|
59
|
+
const values = mongoErr.keyValue ? Object.entries(mongoErr.keyValue).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
|
|
60
|
+
return createError(409, fields.length ? `Duplicate value for ${fields.join(", ")}${values ? ` (${values})` : ""}` : "Duplicate key error");
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { warn as a, debug as i, parseDuplicateKeyError as n, configureLogger as r, createError as t };
|
|
@@ -1,102 +1,3 @@
|
|
|
1
|
-
//#region src/utils/field-selection.ts
|
|
2
|
-
/**
|
|
3
|
-
* Get allowed fields for a user based on their context
|
|
4
|
-
*
|
|
5
|
-
* @param user - User object from request.user (or null for public)
|
|
6
|
-
* @param preset - Field preset configuration
|
|
7
|
-
* @returns Array of allowed field names
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* const fields = getFieldsForUser(request.user, {
|
|
11
|
-
* public: ['id', 'name', 'price'],
|
|
12
|
-
* authenticated: ['description', 'features'],
|
|
13
|
-
* admin: ['createdAt', 'internalNotes']
|
|
14
|
-
* });
|
|
15
|
-
*/
|
|
16
|
-
function getFieldsForUser(user, preset) {
|
|
17
|
-
if (!preset) throw new Error("Field preset is required");
|
|
18
|
-
const fields = [...preset.public || []];
|
|
19
|
-
if (user) {
|
|
20
|
-
fields.push(...preset.authenticated || []);
|
|
21
|
-
const roles = Array.isArray(user.roles) ? user.roles : user.roles ? [user.roles] : [];
|
|
22
|
-
if (roles.includes("admin") || roles.includes("superadmin")) fields.push(...preset.admin || []);
|
|
23
|
-
}
|
|
24
|
-
return [...new Set(fields)];
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Get Mongoose projection string for query .select()
|
|
28
|
-
*
|
|
29
|
-
* @param user - User object from request.user
|
|
30
|
-
* @param preset - Field preset configuration
|
|
31
|
-
* @returns Space-separated field names for Mongoose .select()
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
35
|
-
* const plans = await GymPlan.find({ organizationId }).select(projection).lean();
|
|
36
|
-
*/
|
|
37
|
-
function getMongooseProjection(user, preset) {
|
|
38
|
-
return getFieldsForUser(user, preset).join(" ");
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Filter a single object to include only allowed fields
|
|
42
|
-
*/
|
|
43
|
-
function filterObject(obj, allowedFields) {
|
|
44
|
-
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
|
45
|
-
const filtered = {};
|
|
46
|
-
for (const field of allowedFields) if (field in obj) filtered[field] = obj[field];
|
|
47
|
-
return filtered;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Filter response data to include only allowed fields
|
|
51
|
-
*
|
|
52
|
-
* Use this for complex responses where Mongoose projections aren't applicable:
|
|
53
|
-
* - Aggregation pipeline results
|
|
54
|
-
* - Data from multiple sources
|
|
55
|
-
* - Custom computed fields
|
|
56
|
-
*
|
|
57
|
-
* For simple DB queries, prefer getMongooseProjection() (10x faster)
|
|
58
|
-
*
|
|
59
|
-
* @param data - Data to filter
|
|
60
|
-
* @param preset - Field preset configuration
|
|
61
|
-
* @param user - User object from request.user
|
|
62
|
-
* @returns Filtered data
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* const stats = await calculateComplexStats();
|
|
66
|
-
* const filtered = filterResponseData(stats, fieldPresets.dashboard, request.user);
|
|
67
|
-
* return reply.send(filtered);
|
|
68
|
-
*/
|
|
69
|
-
function filterResponseData(data, preset, user = null) {
|
|
70
|
-
const allowedFields = getFieldsForUser(user, preset);
|
|
71
|
-
if (Array.isArray(data)) return data.map((item) => filterObject(item, allowedFields));
|
|
72
|
-
return filterObject(data, allowedFields);
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Helper to create field presets (module-level)
|
|
76
|
-
*
|
|
77
|
-
* Each module should define its own field preset in its own directory.
|
|
78
|
-
* This keeps modules independent and self-contained.
|
|
79
|
-
*
|
|
80
|
-
* @param config - Field configuration
|
|
81
|
-
* @returns Field preset
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* // In modules/gym-plan/gym-plan.fields.ts
|
|
85
|
-
* export const gymPlanFieldPreset = createFieldPreset({
|
|
86
|
-
* public: ['id', 'name', 'price'],
|
|
87
|
-
* authenticated: ['features', 'description'],
|
|
88
|
-
* admin: ['createdAt', 'updatedAt', 'internalNotes']
|
|
89
|
-
* });
|
|
90
|
-
*/
|
|
91
|
-
function createFieldPreset(config) {
|
|
92
|
-
return {
|
|
93
|
-
public: config.public || [],
|
|
94
|
-
authenticated: config.authenticated || [],
|
|
95
|
-
admin: config.admin || []
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
//#endregion
|
|
100
1
|
//#region src/utils/cache-keys.ts
|
|
101
2
|
/**
|
|
102
3
|
* Simple hash function for query parameters
|
|
@@ -114,8 +15,8 @@ function hashString(str) {
|
|
|
114
15
|
function stableStringify(obj) {
|
|
115
16
|
if (obj === null || obj === void 0) return "";
|
|
116
17
|
if (typeof obj !== "object") return String(obj);
|
|
117
|
-
if (Array.isArray(obj)) return
|
|
118
|
-
return
|
|
18
|
+
if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(",")}]`;
|
|
19
|
+
return `{${Object.keys(obj).sort().map((key) => `${key}:${stableStringify(obj[key])}`).join(",")}}`;
|
|
119
20
|
}
|
|
120
21
|
/**
|
|
121
22
|
* Generate cache key for getById operations
|
|
@@ -143,9 +44,9 @@ function byIdKey(prefix, model, id, options) {
|
|
|
143
44
|
}
|
|
144
45
|
/**
|
|
145
46
|
* Generate cache key for single-document queries
|
|
146
|
-
*
|
|
47
|
+
*
|
|
147
48
|
* Format: {prefix}:one:{model}:{queryHash}
|
|
148
|
-
*
|
|
49
|
+
*
|
|
149
50
|
* @example
|
|
150
51
|
* byQueryKey('mk', 'User', { email: 'john@example.com' })
|
|
151
52
|
* // => 'mk:one:User:a1b2c3d4'
|
|
@@ -159,13 +60,13 @@ function byQueryKey(prefix, model, version, query, options) {
|
|
|
159
60
|
}
|
|
160
61
|
/**
|
|
161
62
|
* Generate cache key for paginated list queries
|
|
162
|
-
*
|
|
63
|
+
*
|
|
163
64
|
* Format: {prefix}:list:{model}:{version}:{queryHash}
|
|
164
|
-
*
|
|
65
|
+
*
|
|
165
66
|
* The version component enables efficient bulk invalidation:
|
|
166
67
|
* - On any mutation, bump the version
|
|
167
68
|
* - All list cache keys become invalid without scanning/deleting each
|
|
168
|
-
*
|
|
69
|
+
*
|
|
169
70
|
* @example
|
|
170
71
|
* listQueryKey('mk', 'User', 1, { filters: { status: 'active' }, page: 1, limit: 20 })
|
|
171
72
|
* // => 'mk:list:User:1:e5f6g7h8'
|
|
@@ -190,9 +91,9 @@ function listQueryKey(prefix, model, version, params) {
|
|
|
190
91
|
}
|
|
191
92
|
/**
|
|
192
93
|
* Generate cache key for collection version tag
|
|
193
|
-
*
|
|
94
|
+
*
|
|
194
95
|
* Format: {prefix}:ver:{model}
|
|
195
|
-
*
|
|
96
|
+
*
|
|
196
97
|
* Used to track mutation version for list invalidation
|
|
197
98
|
*/
|
|
198
99
|
function versionKey(prefix, model) {
|
|
@@ -200,9 +101,9 @@ function versionKey(prefix, model) {
|
|
|
200
101
|
}
|
|
201
102
|
/**
|
|
202
103
|
* Generate pattern for clearing all cache keys for a model
|
|
203
|
-
*
|
|
104
|
+
*
|
|
204
105
|
* Format: {prefix}:*:{model}:*
|
|
205
|
-
*
|
|
106
|
+
*
|
|
206
107
|
* @example
|
|
207
108
|
* modelPattern('mk', 'User')
|
|
208
109
|
* // => 'mk:*:User:*'
|
|
@@ -218,6 +119,103 @@ function modelPattern(prefix, model) {
|
|
|
218
119
|
function listPattern(prefix, model) {
|
|
219
120
|
return `${prefix}:list:${model}:*`;
|
|
220
121
|
}
|
|
221
|
-
|
|
222
122
|
//#endregion
|
|
223
|
-
|
|
123
|
+
//#region src/utils/field-selection.ts
|
|
124
|
+
/**
|
|
125
|
+
* Get allowed fields for a user based on their context
|
|
126
|
+
*
|
|
127
|
+
* @param user - User object from request.user (or null for public)
|
|
128
|
+
* @param preset - Field preset configuration
|
|
129
|
+
* @returns Array of allowed field names
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const fields = getFieldsForUser(request.user, {
|
|
133
|
+
* public: ['id', 'name', 'price'],
|
|
134
|
+
* authenticated: ['description', 'features'],
|
|
135
|
+
* admin: ['createdAt', 'internalNotes']
|
|
136
|
+
* });
|
|
137
|
+
*/
|
|
138
|
+
function getFieldsForUser(user, preset) {
|
|
139
|
+
if (!preset) throw new Error("Field preset is required");
|
|
140
|
+
const fields = [...preset.public || []];
|
|
141
|
+
if (user) {
|
|
142
|
+
fields.push(...preset.authenticated || []);
|
|
143
|
+
const roles = Array.isArray(user.roles) ? user.roles : user.roles ? [user.roles] : [];
|
|
144
|
+
if (roles.includes("admin") || roles.includes("superadmin")) fields.push(...preset.admin || []);
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(fields)];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get Mongoose projection string for query .select()
|
|
150
|
+
*
|
|
151
|
+
* @param user - User object from request.user
|
|
152
|
+
* @param preset - Field preset configuration
|
|
153
|
+
* @returns Space-separated field names for Mongoose .select()
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
|
|
157
|
+
* const plans = await GymPlan.find({ organizationId }).select(projection).lean();
|
|
158
|
+
*/
|
|
159
|
+
function getMongooseProjection(user, preset) {
|
|
160
|
+
return getFieldsForUser(user, preset).join(" ");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Filter a single object to include only allowed fields
|
|
164
|
+
*/
|
|
165
|
+
function filterObject(obj, allowedFields) {
|
|
166
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
|
167
|
+
const filtered = {};
|
|
168
|
+
for (const field of allowedFields) if (field in obj) filtered[field] = obj[field];
|
|
169
|
+
return filtered;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Filter response data to include only allowed fields
|
|
173
|
+
*
|
|
174
|
+
* Use this for complex responses where Mongoose projections aren't applicable:
|
|
175
|
+
* - Aggregation pipeline results
|
|
176
|
+
* - Data from multiple sources
|
|
177
|
+
* - Custom computed fields
|
|
178
|
+
*
|
|
179
|
+
* For simple DB queries, prefer getMongooseProjection() (10x faster)
|
|
180
|
+
*
|
|
181
|
+
* @param data - Data to filter
|
|
182
|
+
* @param preset - Field preset configuration
|
|
183
|
+
* @param user - User object from request.user
|
|
184
|
+
* @returns Filtered data
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* const stats = await calculateComplexStats();
|
|
188
|
+
* const filtered = filterResponseData(stats, fieldPresets.dashboard, request.user);
|
|
189
|
+
* return reply.send(filtered);
|
|
190
|
+
*/
|
|
191
|
+
function filterResponseData(data, preset, user = null) {
|
|
192
|
+
const allowedFields = getFieldsForUser(user, preset);
|
|
193
|
+
if (Array.isArray(data)) return data.map((item) => filterObject(item, allowedFields));
|
|
194
|
+
return filterObject(data, allowedFields);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Helper to create field presets (module-level)
|
|
198
|
+
*
|
|
199
|
+
* Each module should define its own field preset in its own directory.
|
|
200
|
+
* This keeps modules independent and self-contained.
|
|
201
|
+
*
|
|
202
|
+
* @param config - Field configuration
|
|
203
|
+
* @returns Field preset
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* // In modules/gym-plan/gym-plan.fields.ts
|
|
207
|
+
* export const gymPlanFieldPreset = createFieldPreset({
|
|
208
|
+
* public: ['id', 'name', 'price'],
|
|
209
|
+
* authenticated: ['features', 'description'],
|
|
210
|
+
* admin: ['createdAt', 'updatedAt', 'internalNotes']
|
|
211
|
+
* });
|
|
212
|
+
*/
|
|
213
|
+
function createFieldPreset(config) {
|
|
214
|
+
return {
|
|
215
|
+
public: config.public || [],
|
|
216
|
+
authenticated: config.authenticated || [],
|
|
217
|
+
admin: config.admin || []
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
export { byIdKey as a, listQueryKey as c, getMongooseProjection as i, modelPattern as l, filterResponseData as n, byQueryKey as o, getFieldsForUser as r, listPattern as s, createFieldPreset as t, versionKey as u };
|