@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.
Files changed (28) hide show
  1. package/README.md +137 -7
  2. package/dist/PaginationEngine-nY04eGUM.mjs +290 -0
  3. package/dist/actions/index.d.mts +2 -9
  4. package/dist/actions/index.mjs +3 -5
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/ai/index.mjs +3 -3
  7. package/dist/chunk-CfYAbeIz.mjs +13 -0
  8. package/dist/{limits-s1-d8rWb.mjs → cursor-CHToazHy.mjs} +122 -171
  9. package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
  10. package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-reyDRzXf.mjs} +110 -112
  11. package/dist/{aggregate-BkOG9qwr.d.mts → index-BuoZIZ15.d.mts} +132 -129
  12. package/dist/index.d.mts +549 -543
  13. package/dist/index.mjs +33 -101
  14. package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-B6Qyl8BK.mjs} +13 -12
  15. package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-RX9YfJLu.d.mts} +24 -17
  16. package/dist/pagination/PaginationEngine.d.mts +1 -1
  17. package/dist/pagination/PaginationEngine.mjs +2 -209
  18. package/dist/plugins/index.d.mts +1 -2
  19. package/dist/plugins/index.mjs +2 -3
  20. package/dist/sort-C-BJEWUZ.mjs +57 -0
  21. package/dist/{types-pVY0w1Pp.d.mts → types-COINbsdL.d.mts} +57 -27
  22. package/dist/{aggregate-BClp040M.mjs → update-DGKMmBgG.mjs} +575 -565
  23. package/dist/utils/index.d.mts +2 -2
  24. package/dist/utils/index.mjs +4 -5
  25. package/dist/{custom-id.plugin-BJ3FSnzt.d.mts → validation-chain.plugin-BNoaKDOm.d.mts} +832 -832
  26. package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-da3fOo8A.mjs} +2410 -2246
  27. package/package.json +11 -6
  28. 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
- * Validates and normalizes sort for keyset pagination
148
- * Auto-adds _id tie-breaker if needed
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
- * @param sort - Sort specification
152
- * @returns Validated and normalized sort
153
- * @throws Error if sort is invalid for keyset pagination
154
- */
155
- function validateKeysetSort(sort) {
156
- const keys = Object.keys(sort);
157
- if (keys.length === 1 && keys[0] !== "_id") {
158
- const field = keys[0];
159
- const direction = sort[field];
160
- return normalizeSort({
161
- [field]: direction,
162
- _id: direction
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
- throw new Error("Keyset pagination only supports single field + _id");
172
- }
173
- /**
174
- * Extracts primary sort field (first non-_id field)
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 { validatePage as a, validateKeysetSort as c, validateCursorSort as d, validateCursorVersion as f, validateLimit as i, decodeCursor as l, calculateTotalPages as n, buildKeysetFilter as o, shouldWarnDeepPagination as r, getPrimaryField as s, calculateSkip as t, encodeCursor as u };
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
- export { createError as i, debug as n, warn as r, configureLogger as t };
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 "[" + obj.map(stableStringify).join(",") + "]";
118
- return "{" + Object.keys(obj).sort().map((key) => `${key}:${stableStringify(obj[key])}`).join(",") + "}";
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
- export { modelPattern as a, filterResponseData as c, listQueryKey as i, getFieldsForUser as l, byQueryKey as n, versionKey as o, listPattern as r, createFieldPreset as s, byIdKey as t, getMongooseProjection as u };
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 };