@classytic/mongokit 1.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.
@@ -0,0 +1,145 @@
1
+ import createError from 'http-errors';
2
+
3
+ export const validationChainPlugin = (validators = [], options = {}) => {
4
+ const { stopOnFirstError = true } = options;
5
+
6
+ validators.forEach((v, idx) => {
7
+ if (!v.name || typeof v.name !== 'string') {
8
+ throw new Error(`Validator at index ${idx} missing 'name' (string)`);
9
+ }
10
+ if (typeof v.validate !== 'function') {
11
+ throw new Error(`Validator '${v.name}' missing 'validate' function`);
12
+ }
13
+ });
14
+
15
+ const validatorsByOperation = { create: [], update: [], delete: [], createMany: [] };
16
+ const allOperationsValidators = [];
17
+
18
+ validators.forEach(v => {
19
+ if (!v.operations || v.operations.length === 0) {
20
+ allOperationsValidators.push(v);
21
+ } else {
22
+ v.operations.forEach(op => {
23
+ if (validatorsByOperation[op]) {
24
+ validatorsByOperation[op].push(v);
25
+ }
26
+ });
27
+ }
28
+ });
29
+
30
+ return {
31
+ name: 'validation-chain',
32
+
33
+ apply(repo) {
34
+ const getValidatorsForOperation = (operation) => {
35
+ const specific = validatorsByOperation[operation] || [];
36
+ return [...allOperationsValidators, ...specific];
37
+ };
38
+
39
+ const runValidators = async (operation, context) => {
40
+ const validators = getValidatorsForOperation(operation);
41
+ const errors = [];
42
+
43
+ for (const validator of validators) {
44
+ try {
45
+ await validator.validate(context, repo);
46
+ } catch (error) {
47
+ if (stopOnFirstError) {
48
+ throw error;
49
+ }
50
+ errors.push({
51
+ validator: validator.name,
52
+ error: error.message || String(error)
53
+ });
54
+ }
55
+ }
56
+
57
+ if (errors.length > 0) {
58
+ const error = createError(
59
+ 400,
60
+ `Validation failed: ${errors.map(e => `[${e.validator}] ${e.error}`).join('; ')}`
61
+ );
62
+ error.validationErrors = errors;
63
+ throw error;
64
+ }
65
+ };
66
+
67
+ repo.on('before:create', async (context) => await runValidators('create', context));
68
+ repo.on('before:createMany', async (context) => await runValidators('createMany', context));
69
+ repo.on('before:update', async (context) => await runValidators('update', context));
70
+ repo.on('before:delete', async (context) => await runValidators('delete', context));
71
+ }
72
+ };
73
+ };
74
+
75
+ /**
76
+ * Block operation if condition is true
77
+ * @param {string} name - Validator name
78
+ * @param {string[]} operations - Operations to block on
79
+ * @param {Function} condition - Condition function (context) => boolean
80
+ * @param {string} errorMessage - Error message to throw
81
+ * @example blockIf('block-library', ['delete'], ctx => ctx.data.managed, 'Cannot delete managed records')
82
+ */
83
+ export const blockIf = (name, operations, condition, errorMessage) => ({
84
+ name,
85
+ operations,
86
+ validate: (context) => {
87
+ if (condition(context)) {
88
+ throw createError(403, errorMessage);
89
+ }
90
+ }
91
+ });
92
+
93
+ export const requireField = (field, operations = ['create']) => ({
94
+ name: `require-${field}`,
95
+ operations,
96
+ validate: (context) => {
97
+ if (!context.data || context.data[field] === undefined || context.data[field] === null) {
98
+ throw createError(400, `Field '${field}' is required`);
99
+ }
100
+ }
101
+ });
102
+
103
+ export const autoInject = (field, getter, operations = ['create']) => ({
104
+ name: `auto-inject-${field}`,
105
+ operations,
106
+ validate: (context) => {
107
+ if (context.data && !(field in context.data)) {
108
+ const value = getter(context);
109
+ if (value !== null && value !== undefined) {
110
+ context.data[field] = value;
111
+ }
112
+ }
113
+ }
114
+ });
115
+
116
+ export const immutableField = (field) => ({
117
+ name: `immutable-${field}`,
118
+ operations: ['update'],
119
+ validate: (context) => {
120
+ if (context.data && field in context.data) {
121
+ throw createError(400, `Field '${field}' cannot be modified`);
122
+ }
123
+ }
124
+ });
125
+
126
+ export const uniqueField = (field, errorMessage) => ({
127
+ name: `unique-${field}`,
128
+ operations: ['create', 'update'],
129
+ validate: async (context, repo) => {
130
+ if (!context.data || !context.data[field]) return;
131
+
132
+ const query = { [field]: context.data[field] };
133
+ const existing = await repo.getByQuery(query, {
134
+ select: '_id',
135
+ lean: true,
136
+ throwOnNotFound: false
137
+ });
138
+
139
+ if (existing && existing._id.toString() !== context.id?.toString()) {
140
+ throw createError(409, errorMessage || `${field} already exists`);
141
+ }
142
+ }
143
+ });
144
+
145
+ export default validationChainPlugin;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Field Selection Utilities
3
+ *
4
+ * Provides explicit, performant field filtering using Mongoose projections.
5
+ *
6
+ * Philosophy:
7
+ * - Explicit is better than implicit
8
+ * - Filter at DB level (10x faster than in-memory)
9
+ * - Progressive disclosure (show more fields as trust increases)
10
+ *
11
+ * Usage:
12
+ * ```javascript
13
+ * // For Mongoose queries (PREFERRED - 90% of cases)
14
+ * const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
15
+ * const plans = await GymPlan.find().select(projection).lean();
16
+ *
17
+ * // For complex data (10% of cases - aggregations, multiple sources)
18
+ * const filtered = filterResponseData(complexData, fieldPresets.gymPlans, request.user);
19
+ * ```
20
+ */
21
+
22
+ /**
23
+ * Get allowed fields for a user based on their context
24
+ *
25
+ * @param {Object} user - User object from request.user (or null for public)
26
+ * @param {Object} preset - Field preset configuration
27
+ * @param {string[]} preset.public - Fields visible to everyone
28
+ * @param {string[]} preset.authenticated - Additional fields for authenticated users
29
+ * @param {string[]} preset.admin - Additional fields for admins
30
+ * @returns {string[]} Array of allowed field names
31
+ */
32
+ export const getFieldsForUser = (user, preset) => {
33
+ if (!preset) {
34
+ throw new Error('Field preset is required');
35
+ }
36
+
37
+ // Start with public fields
38
+ let fields = [...(preset.public || [])];
39
+
40
+ // Add authenticated fields if user is logged in
41
+ if (user) {
42
+ fields.push(...(preset.authenticated || []));
43
+
44
+ // Add admin fields if user is admin/superadmin
45
+ const roles = Array.isArray(user.roles) ? user.roles : (user.roles ? [user.roles] : []);
46
+ if (roles.includes('admin') || roles.includes('superadmin')) {
47
+ fields.push(...(preset.admin || []));
48
+ }
49
+ }
50
+
51
+ // Remove duplicates
52
+ return [...new Set(fields)];
53
+ };
54
+
55
+ /**
56
+ * Get Mongoose projection string for query .select()
57
+ *
58
+ * @param {Object} user - User object from request.user
59
+ * @param {Object} preset - Field preset configuration
60
+ * @returns {string} Space-separated field names for Mongoose .select()
61
+ *
62
+ * @example
63
+ * const projection = getMongooseProjection(request.user, fieldPresets.gymPlans);
64
+ * const plans = await GymPlan.find({ organizationId }).select(projection).lean();
65
+ */
66
+ export const getMongooseProjection = (user, preset) => {
67
+ const fields = getFieldsForUser(user, preset);
68
+ return fields.join(' ');
69
+ };
70
+
71
+ /**
72
+ * Filter response data to include only allowed fields
73
+ *
74
+ * Use this for complex responses where Mongoose projections aren't applicable:
75
+ * - Aggregation pipeline results
76
+ * - Data from multiple sources
77
+ * - Custom computed fields
78
+ *
79
+ * For simple DB queries, prefer getMongooseProjection() (10x faster)
80
+ *
81
+ * @param {Object|Array} data - Data to filter
82
+ * @param {Object} preset - Field preset configuration
83
+ * @param {Object} user - User object from request.user
84
+ * @returns {Object|Array} Filtered data
85
+ *
86
+ * @example
87
+ * const stats = await calculateComplexStats();
88
+ * const filtered = filterResponseData(stats, fieldPresets.dashboard, request.user);
89
+ * return reply.send(filtered);
90
+ */
91
+ export const filterResponseData = (data, preset, user = null) => {
92
+ const allowedFields = getFieldsForUser(user, preset);
93
+
94
+ // Handle arrays
95
+ if (Array.isArray(data)) {
96
+ return data.map(item => filterObject(item, allowedFields));
97
+ }
98
+
99
+ // Handle single object
100
+ return filterObject(data, allowedFields);
101
+ };
102
+
103
+ /**
104
+ * Filter a single object to include only allowed fields
105
+ *
106
+ * @private
107
+ * @param {Object} obj - Object to filter
108
+ * @param {string[]} allowedFields - Array of allowed field names
109
+ * @returns {Object} Filtered object
110
+ */
111
+ const filterObject = (obj, allowedFields) => {
112
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
113
+ return obj;
114
+ }
115
+
116
+ const filtered = {};
117
+
118
+ for (const field of allowedFields) {
119
+ if (field in obj) {
120
+ filtered[field] = obj[field];
121
+ }
122
+ }
123
+
124
+ return filtered;
125
+ };
126
+
127
+ /**
128
+ * Helper to create field presets (module-level)
129
+ *
130
+ * Each module should define its own field preset in its own directory.
131
+ * This keeps modules independent and self-contained.
132
+ *
133
+ * @param {Object} config - Field configuration
134
+ * @returns {Object} Field preset
135
+ *
136
+ * @example
137
+ * // In modules/gym-plan/gym-plan.fields.js
138
+ * import { createFieldPreset } from '#common/utils/field-selection.js';
139
+ *
140
+ * export const gymPlanFieldPreset = createFieldPreset({
141
+ * public: ['id', 'name', 'price'],
142
+ * authenticated: ['features', 'description'],
143
+ * admin: ['createdAt', 'updatedAt', 'internalNotes']
144
+ * });
145
+ *
146
+ * // Then in controller:
147
+ * import { gymPlanFieldPreset } from './gym-plan.fields.js';
148
+ * super(gymPlanRepository, { fieldPreset: gymPlanFieldPreset });
149
+ */
150
+ export const createFieldPreset = (config) => {
151
+ return {
152
+ public: config.public || [],
153
+ authenticated: config.authenticated || [],
154
+ admin: config.admin || [],
155
+ };
156
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Utility Functions for MongoKit
3
+ * Reusable helpers for field selection, filtering, and more
4
+ */
5
+
6
+ export {
7
+ getFieldsForUser,
8
+ getMongooseProjection,
9
+ filterResponseData,
10
+ createFieldPreset,
11
+ } from './field-selection.js';
12
+
@@ -0,0 +1,113 @@
1
+ import { Model, Document, FilterQuery, UpdateQuery, ClientSession } from 'mongoose';
2
+
3
+ export interface ActionOptions {
4
+ session?: ClientSession;
5
+ [key: string]: any;
6
+ }
7
+
8
+ // Create actions
9
+ export function create<T extends Document>(
10
+ Model: Model<T>,
11
+ data: Partial<T>,
12
+ options?: ActionOptions
13
+ ): Promise<T>;
14
+
15
+ export function createMany<T extends Document>(
16
+ Model: Model<T>,
17
+ dataArray: Partial<T>[],
18
+ options?: ActionOptions
19
+ ): Promise<T[]>;
20
+
21
+ export function createDefault<T extends Document>(
22
+ Model: Model<T>,
23
+ overrides?: Partial<T>,
24
+ options?: ActionOptions
25
+ ): Promise<T>;
26
+
27
+ export function upsert<T extends Document>(
28
+ Model: Model<T>,
29
+ query: FilterQuery<T>,
30
+ data: Partial<T>,
31
+ options?: ActionOptions
32
+ ): Promise<T>;
33
+
34
+ // Read actions
35
+ export function getById<T extends Document>(
36
+ Model: Model<T>,
37
+ id: string,
38
+ options?: ActionOptions
39
+ ): Promise<T | null>;
40
+
41
+ export function getByQuery<T extends Document>(
42
+ Model: Model<T>,
43
+ query: FilterQuery<T>,
44
+ options?: ActionOptions
45
+ ): Promise<T | null>;
46
+
47
+ export function getOrCreate<T extends Document>(
48
+ Model: Model<T>,
49
+ query: FilterQuery<T>,
50
+ createData: Partial<T>,
51
+ options?: ActionOptions
52
+ ): Promise<T>;
53
+
54
+ export function count<T extends Document>(
55
+ Model: Model<T>,
56
+ query?: FilterQuery<T>,
57
+ options?: ActionOptions
58
+ ): Promise<number>;
59
+
60
+ export function exists<T extends Document>(
61
+ Model: Model<T>,
62
+ query: FilterQuery<T>,
63
+ options?: ActionOptions
64
+ ): Promise<boolean>;
65
+
66
+ // Update actions
67
+ export function update<T extends Document>(
68
+ Model: Model<T>,
69
+ id: string,
70
+ data: UpdateQuery<T>,
71
+ options?: ActionOptions
72
+ ): Promise<T | null>;
73
+
74
+ export function updateMany<T extends Document>(
75
+ Model: Model<T>,
76
+ query: FilterQuery<T>,
77
+ data: UpdateQuery<T>,
78
+ options?: ActionOptions
79
+ ): Promise<any>;
80
+
81
+ // Delete actions
82
+ export function deleteById<T extends Document>(
83
+ Model: Model<T>,
84
+ id: string,
85
+ options?: ActionOptions
86
+ ): Promise<T | null>;
87
+
88
+ export function deleteMany<T extends Document>(
89
+ Model: Model<T>,
90
+ query: FilterQuery<T>,
91
+ options?: ActionOptions
92
+ ): Promise<any>;
93
+
94
+ // Aggregate actions
95
+ export function aggregate<T extends Document>(
96
+ Model: Model<T>,
97
+ pipeline: any[],
98
+ options?: ActionOptions
99
+ ): Promise<any[]>;
100
+
101
+ export function aggregatePaginate<T extends Document>(
102
+ Model: Model<T>,
103
+ pipeline: any[],
104
+ options?: ActionOptions
105
+ ): Promise<any>;
106
+
107
+ export function distinct<T extends Document>(
108
+ Model: Model<T>,
109
+ field: string,
110
+ query?: FilterQuery<T>,
111
+ options?: ActionOptions
112
+ ): Promise<any[]>;
113
+
@@ -0,0 +1,96 @@
1
+ import { Model, Document, ClientSession, PaginateOptions, PaginateResult, FilterQuery, UpdateQuery, AggregateOptions } from 'mongoose';
2
+
3
+ export interface RepositoryOptions {
4
+ session?: ClientSession;
5
+ populate?: string | string[] | any;
6
+ select?: string | any;
7
+ lean?: boolean;
8
+ throwOnNotFound?: boolean;
9
+ }
10
+
11
+ export interface QueryParams {
12
+ pagination?: {
13
+ page: number;
14
+ limit: number;
15
+ };
16
+ search?: string;
17
+ sort?: string;
18
+ filters?: Record<string, any>;
19
+ }
20
+
21
+ export interface RepositoryContext {
22
+ operation: string;
23
+ model: string;
24
+ data?: any;
25
+ dataArray?: any[];
26
+ id?: string;
27
+ query?: any;
28
+ queryParams?: QueryParams;
29
+ context?: any;
30
+ user?: any;
31
+ organizationId?: string;
32
+ [key: string]: any;
33
+ }
34
+
35
+ export type EventListener = (data: any) => void | Promise<void>;
36
+
37
+ export interface Plugin {
38
+ name: string;
39
+ apply(repository: Repository<any>): void;
40
+ }
41
+
42
+ export type PluginFactory = (options?: any) => Plugin;
43
+
44
+ export class Repository<T extends Document> {
45
+ Model: Model<T>;
46
+ model: string;
47
+ protected _hooks: Map<string, EventListener[]>;
48
+
49
+ constructor(Model: Model<T>, plugins?: (Plugin | PluginFactory)[]);
50
+
51
+ // Plugin system
52
+ use(plugin: Plugin | PluginFactory): this;
53
+ on(event: string, listener: EventListener): this;
54
+ emit(event: string, data: any): void;
55
+
56
+ // CRUD operations
57
+ create(data: Partial<T>, options?: RepositoryOptions): Promise<T>;
58
+ createMany(dataArray: Partial<T>[], options?: RepositoryOptions): Promise<T[]>;
59
+
60
+ getById(id: string, options?: RepositoryOptions): Promise<T | null>;
61
+ getByQuery(query: FilterQuery<T>, options?: RepositoryOptions): Promise<T | null>;
62
+ getAll(queryParams?: QueryParams, options?: RepositoryOptions): Promise<PaginateResult<T>>;
63
+ getOrCreate(query: FilterQuery<T>, createData: Partial<T>, options?: RepositoryOptions): Promise<T>;
64
+
65
+ count(query?: FilterQuery<T>, options?: RepositoryOptions): Promise<number>;
66
+ exists(query: FilterQuery<T>, options?: RepositoryOptions): Promise<boolean>;
67
+
68
+ update(id: string, data: UpdateQuery<T>, options?: RepositoryOptions): Promise<T | null>;
69
+ delete(id: string, options?: RepositoryOptions): Promise<T | null>;
70
+
71
+ // Aggregation
72
+ aggregate(pipeline: any[], options?: AggregateOptions): Promise<any[]>;
73
+ aggregatePaginate(pipeline: any[], options?: PaginateOptions): Promise<any>;
74
+ distinct(field: string, query?: FilterQuery<T>, options?: RepositoryOptions): Promise<any[]>;
75
+
76
+ // Transaction support
77
+ withTransaction<R>(callback: (session: ClientSession) => Promise<R>): Promise<R>;
78
+
79
+ // Internal methods
80
+ protected _executeQuery(buildQuery: (model: Model<T>) => Promise<any>): Promise<any>;
81
+ protected _buildContext(operation: string, options: any): Promise<RepositoryContext>;
82
+ protected _parseSort(sort: string | object): object;
83
+ protected _parsePopulate(populate: string | string[] | any): any[];
84
+ protected _handleError(error: any): Error;
85
+ }
86
+
87
+ export function createRepository<T extends Document>(
88
+ Model: Model<T>,
89
+ plugins?: (Plugin | PluginFactory)[]
90
+ ): Repository<T>;
91
+
92
+ // Plugin exports
93
+ export * from './plugins/index.js';
94
+ export * from './utils/index.js';
95
+ export * as actions from './actions/index.js';
96
+
@@ -0,0 +1,88 @@
1
+ import { Plugin, PluginFactory, Repository, RepositoryContext } from '../index.js';
2
+ import { Document } from 'mongoose';
3
+
4
+ // Field Filter Plugin
5
+ export interface FieldPreset {
6
+ public?: string[];
7
+ authenticated?: string[];
8
+ admin?: string[];
9
+ }
10
+
11
+ export function fieldFilterPlugin(fieldPreset: FieldPreset): Plugin;
12
+
13
+ // Soft Delete Plugin
14
+ export interface SoftDeleteOptions {
15
+ deletedField?: string;
16
+ deletedByField?: string;
17
+ }
18
+
19
+ export function softDeletePlugin(options?: SoftDeleteOptions): Plugin;
20
+
21
+ // Timestamp Plugin
22
+ export interface TimestampOptions {
23
+ createdAtField?: string;
24
+ updatedAtField?: string;
25
+ }
26
+
27
+ export function timestampPlugin(options?: TimestampOptions): Plugin;
28
+
29
+ // Audit Log Plugin
30
+ export interface Logger {
31
+ info(message: string, meta?: any): void;
32
+ error(message: string, meta?: any): void;
33
+ warn(message: string, meta?: any): void;
34
+ }
35
+
36
+ export function auditLogPlugin(logger: Logger): Plugin;
37
+
38
+ // Validation Chain Plugin
39
+ export interface Validator {
40
+ name: string;
41
+ operations?: string[];
42
+ validate(context: RepositoryContext, repo: Repository<any>): void | Promise<void>;
43
+ }
44
+
45
+ export interface ValidationChainOptions {
46
+ stopOnFirstError?: boolean;
47
+ }
48
+
49
+ export function validationChainPlugin(
50
+ validators: Validator[],
51
+ options?: ValidationChainOptions
52
+ ): Plugin;
53
+
54
+ // Validator helpers
55
+ export function blockIf(
56
+ name: string,
57
+ operations: string[],
58
+ condition: (context: RepositoryContext) => boolean,
59
+ errorMessage: string
60
+ ): Validator;
61
+
62
+ export function requireField(field: string, operations?: string[]): Validator;
63
+
64
+ export function autoInject(
65
+ field: string,
66
+ getter: (context: RepositoryContext) => any,
67
+ operations?: string[]
68
+ ): Validator;
69
+
70
+ export function immutableField(field: string): Validator;
71
+
72
+ export function uniqueField(field: string, errorMessage?: string): Validator;
73
+
74
+ // Method Registry Plugin
75
+ export function methodRegistryPlugin(): Plugin;
76
+
77
+ // Mongo Operations Plugin
78
+ export function mongoOperationsPlugin(): Plugin;
79
+
80
+ // Batch Operations Plugin
81
+ export function batchOperationsPlugin(): Plugin;
82
+
83
+ // Aggregate Helpers Plugin
84
+ export function aggregateHelpersPlugin(): Plugin;
85
+
86
+ // Subdocument Plugin
87
+ export function subdocumentPlugin(): Plugin;
88
+
@@ -0,0 +1,24 @@
1
+ import { FieldPreset } from '../plugins/index.js';
2
+
3
+ export interface User {
4
+ roles?: string | string[];
5
+ [key: string]: any;
6
+ }
7
+
8
+ // Field Selection utilities
9
+ export function getFieldsForUser(user: User | null, preset: FieldPreset): string[];
10
+
11
+ export function getMongooseProjection(user: User | null, preset: FieldPreset): string;
12
+
13
+ export function filterResponseData<T>(
14
+ data: T | T[],
15
+ preset: FieldPreset,
16
+ user?: User | null
17
+ ): T | T[];
18
+
19
+ export function createFieldPreset(config: {
20
+ public?: string[];
21
+ authenticated?: string[];
22
+ admin?: string[];
23
+ }): FieldPreset;
24
+