@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,155 @@
1
+ /**
2
+ * Read Actions
3
+ * Pure functions for document retrieval
4
+ */
5
+
6
+ import createError from 'http-errors';
7
+
8
+ /**
9
+ * Get by ID
10
+ */
11
+ export async function getById(Model, id, options = {}) {
12
+ const query = Model.findById(id);
13
+
14
+ if (options.select) query.select(options.select);
15
+ if (options.populate) query.populate(parsePopulate(options.populate));
16
+ if (options.lean) query.lean();
17
+ if (options.session) query.session(options.session);
18
+
19
+ const document = await query.exec();
20
+ if (!document && options.throwOnNotFound !== false) {
21
+ throw createError(404, 'Document not found');
22
+ }
23
+
24
+ return document;
25
+ }
26
+
27
+ /**
28
+ * Get by query
29
+ */
30
+ export async function getByQuery(Model, query, options = {}) {
31
+ const mongoQuery = Model.findOne(query);
32
+
33
+ if (options.select) mongoQuery.select(options.select);
34
+ if (options.populate) mongoQuery.populate(parsePopulate(options.populate));
35
+ if (options.lean) mongoQuery.lean();
36
+ if (options.session) mongoQuery.session(options.session);
37
+
38
+ const document = await mongoQuery.exec();
39
+ if (!document && options.throwOnNotFound !== false) {
40
+ throw createError(404, 'Document not found');
41
+ }
42
+
43
+ return document;
44
+ }
45
+
46
+ /**
47
+ * Get all with pagination
48
+ */
49
+ export async function getAll(Model, queryParams, options = {}) {
50
+ const {
51
+ pagination = { page: 1, limit: 10 },
52
+ search,
53
+ sort = '-createdAt',
54
+ filters = {},
55
+ } = queryParams;
56
+
57
+ let query = {};
58
+
59
+ if (search) {
60
+ query.$text = { $search: search };
61
+ }
62
+
63
+ if (filters) {
64
+ query = { ...query, ...parseFilters(filters) };
65
+ }
66
+
67
+ const paginateOptions = {
68
+ page: parseInt(pagination.page, 10),
69
+ limit: parseInt(pagination.limit, 10),
70
+ sort: parseSort(sort),
71
+ populate: parsePopulate(options.populate),
72
+ select: options.select,
73
+ lean: options.lean !== false,
74
+ session: options.session,
75
+ };
76
+
77
+ return Model.paginate(query, paginateOptions);
78
+ }
79
+
80
+ /**
81
+ * Get or create
82
+ */
83
+ export async function getOrCreate(Model, query, createData, options = {}) {
84
+ return Model.findOneAndUpdate(
85
+ query,
86
+ { $setOnInsert: createData },
87
+ {
88
+ upsert: true,
89
+ new: true,
90
+ runValidators: true,
91
+ session: options.session,
92
+ }
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Count documents
98
+ */
99
+ export async function count(Model, query = {}, options = {}) {
100
+ return Model.countDocuments(query).session(options.session);
101
+ }
102
+
103
+ /**
104
+ * Check existence
105
+ */
106
+ export async function exists(Model, query, options = {}) {
107
+ return Model.exists(query).session(options.session);
108
+ }
109
+
110
+ // Utilities
111
+ function parsePopulate(populate) {
112
+ if (!populate) return [];
113
+ if (typeof populate === 'string') {
114
+ return populate.split(',').map(p => p.trim());
115
+ }
116
+ if (Array.isArray(populate)) {
117
+ return populate.map(p => typeof p === 'string' ? p.trim() : p);
118
+ }
119
+ return [populate];
120
+ }
121
+
122
+ function parseSort(sort) {
123
+ if (!sort) return { createdAt: -1 };
124
+ const sortOrder = sort.startsWith('-') ? -1 : 1;
125
+ const sortField = sort.startsWith('-') ? sort.substring(1) : sort;
126
+ return { [sortField]: sortOrder };
127
+ }
128
+
129
+ function parseFilters(filters) {
130
+ const parsed = {};
131
+ for (const [key, value] of Object.entries(filters)) {
132
+ parsed[key] = parseFilterValue(value);
133
+ }
134
+ return parsed;
135
+ }
136
+
137
+ function parseFilterValue(value) {
138
+ if (typeof value === 'string') return value;
139
+
140
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
141
+ const processed = {};
142
+ for (const [operator, operatorValue] of Object.entries(value)) {
143
+ if (operator === 'contains' || operator === 'like') {
144
+ processed.$regex = operatorValue;
145
+ processed.$options = 'i';
146
+ } else {
147
+ processed[operator.startsWith('$') ? operator : `$${operator}`] = operatorValue;
148
+ }
149
+ }
150
+ return processed;
151
+ }
152
+
153
+ return value;
154
+ }
155
+
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Update Actions
3
+ * Pure functions for document updates with optimizations
4
+ */
5
+
6
+ import createError from 'http-errors';
7
+
8
+ /**
9
+ * Update by ID
10
+ */
11
+ export async function update(Model, id, data, options = {}) {
12
+ const document = await Model.findByIdAndUpdate(id, data, {
13
+ new: true,
14
+ runValidators: true,
15
+ session: options.session,
16
+ })
17
+ .select(options.select)
18
+ .populate(parsePopulate(options.populate))
19
+ .lean(options.lean);
20
+
21
+ if (!document) {
22
+ throw createError(404, 'Document not found');
23
+ }
24
+
25
+ return document;
26
+ }
27
+
28
+ /**
29
+ * Update with query constraints (optimized)
30
+ * Returns null if constraints not met (not an error)
31
+ */
32
+ export async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
33
+ const query = { _id: id, ...constraints };
34
+
35
+ const document = await Model.findOneAndUpdate(query, data, {
36
+ new: true,
37
+ runValidators: true,
38
+ session: options.session,
39
+ })
40
+ .select(options.select)
41
+ .populate(parsePopulate(options.populate))
42
+ .lean(options.lean);
43
+
44
+ return document;
45
+ }
46
+
47
+ /**
48
+ * Update with validation (smart optimization)
49
+ * 1-query on success, 2-queries for detailed errors
50
+ */
51
+ export async function updateWithValidation(
52
+ Model,
53
+ id,
54
+ data,
55
+ validationOptions = {},
56
+ options = {}
57
+ ) {
58
+ const { buildConstraints, validateUpdate } = validationOptions;
59
+
60
+ // Try optimized update with constraints
61
+ if (buildConstraints) {
62
+ const constraints = buildConstraints(data);
63
+ const document = await updateWithConstraints(Model, id, data, constraints, options);
64
+
65
+ if (document) {
66
+ return { success: true, data: document };
67
+ }
68
+ }
69
+
70
+ // Fetch for validation
71
+ const existing = await Model.findById(id)
72
+ .select(options.select)
73
+ .lean();
74
+
75
+ if (!existing) {
76
+ return {
77
+ success: false,
78
+ error: {
79
+ code: 404,
80
+ message: 'Document not found',
81
+ },
82
+ };
83
+ }
84
+
85
+ // Run custom validation
86
+ if (validateUpdate) {
87
+ const validation = validateUpdate(existing, data);
88
+ if (!validation.valid) {
89
+ return {
90
+ success: false,
91
+ error: {
92
+ code: 403,
93
+ message: validation.message || 'Update not allowed',
94
+ violations: validation.violations,
95
+ },
96
+ };
97
+ }
98
+ }
99
+
100
+ // Validation passed - perform update
101
+ const updated = await update(Model, id, data, options);
102
+ return { success: true, data: updated };
103
+ }
104
+
105
+ /**
106
+ * Update many documents
107
+ */
108
+ export async function updateMany(Model, query, data, options = {}) {
109
+ const result = await Model.updateMany(query, data, {
110
+ runValidators: true,
111
+ session: options.session,
112
+ });
113
+
114
+ return {
115
+ matchedCount: result.matchedCount,
116
+ modifiedCount: result.modifiedCount,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Update by query
122
+ */
123
+ export async function updateByQuery(Model, query, data, options = {}) {
124
+ const document = await Model.findOneAndUpdate(query, data, {
125
+ new: true,
126
+ runValidators: true,
127
+ session: options.session,
128
+ })
129
+ .select(options.select)
130
+ .populate(parsePopulate(options.populate))
131
+ .lean(options.lean);
132
+
133
+ if (!document && options.throwOnNotFound !== false) {
134
+ throw createError(404, 'Document not found');
135
+ }
136
+
137
+ return document;
138
+ }
139
+
140
+ /**
141
+ * Increment field
142
+ */
143
+ export async function increment(Model, id, field, value = 1, options = {}) {
144
+ return update(Model, id, { $inc: { [field]: value } }, options);
145
+ }
146
+
147
+ /**
148
+ * Push to array
149
+ */
150
+ export async function pushToArray(Model, id, field, value, options = {}) {
151
+ return update(Model, id, { $push: { [field]: value } }, options);
152
+ }
153
+
154
+ /**
155
+ * Pull from array
156
+ */
157
+ export async function pullFromArray(Model, id, field, value, options = {}) {
158
+ return update(Model, id, { $pull: { [field]: value } }, options);
159
+ }
160
+
161
+ // Utilities
162
+ function parsePopulate(populate) {
163
+ if (!populate) return [];
164
+ if (typeof populate === 'string') {
165
+ return populate.split(',').map(p => p.trim());
166
+ }
167
+ if (Array.isArray(populate)) {
168
+ return populate.map(p => typeof p === 'string' ? p.trim() : p);
169
+ }
170
+ return [populate];
171
+ }
172
+
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Lifecycle Hooks
3
+ * Event system for repository actions
4
+ */
5
+
6
+ import { EventEmitter } from 'events';
7
+
8
+ export class RepositoryLifecycle extends EventEmitter {
9
+ constructor() {
10
+ super();
11
+ this.hooks = new Map();
12
+ }
13
+
14
+ /**
15
+ * Register hook
16
+ */
17
+ on(event, handler) {
18
+ if (!this.hooks.has(event)) {
19
+ this.hooks.set(event, []);
20
+ }
21
+ this.hooks.get(event).push(handler);
22
+ return super.on(event, handler);
23
+ }
24
+
25
+ /**
26
+ * Execute hooks before action
27
+ */
28
+ async runBeforeHooks(action, context) {
29
+ const event = `before:${action}`;
30
+ await this.emit(event, context);
31
+
32
+ const hooks = this.hooks.get(event) || [];
33
+ for (const hook of hooks) {
34
+ await hook(context);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Execute hooks after action
40
+ */
41
+ async runAfterHooks(action, context, result) {
42
+ const event = `after:${action}`;
43
+ await this.emit(event, context, result);
44
+
45
+ const hooks = this.hooks.get(event) || [];
46
+ for (const hook of hooks) {
47
+ await hook(context, result);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Execute hooks on error
53
+ */
54
+ async runErrorHooks(action, context, error) {
55
+ const event = `error:${action}`;
56
+ await this.emit(event, context, error);
57
+
58
+ const hooks = this.hooks.get(event) || [];
59
+ for (const hook of hooks) {
60
+ await hook(context, error);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Hook decorators for common patterns
67
+ */
68
+ export const hooks = {
69
+ /**
70
+ * Auto-timestamp before create/update
71
+ */
72
+ autoTimestamp: () => ({
73
+ 'before:create': (context) => {
74
+ context.data.createdAt = new Date();
75
+ context.data.updatedAt = new Date();
76
+ },
77
+ 'before:update': (context) => {
78
+ context.data.updatedAt = new Date();
79
+ },
80
+ }),
81
+
82
+ /**
83
+ * Auto-inject user context
84
+ */
85
+ autoUser: (userField = 'userId') => ({
86
+ 'before:create': (context) => {
87
+ if (context.user && !context.data[userField]) {
88
+ context.data[userField] = context.user._id || context.user.id;
89
+ }
90
+ },
91
+ }),
92
+
93
+ /**
94
+ * Auto-inject organization scope
95
+ */
96
+ autoOrganization: (orgField = 'organizationId') => ({
97
+ 'before:create': (context) => {
98
+ if (context.organizationId && !context.data[orgField]) {
99
+ context.data[orgField] = context.organizationId;
100
+ }
101
+ },
102
+ }),
103
+
104
+ /**
105
+ * Audit log
106
+ */
107
+ auditLog: (logger) => ({
108
+ 'after:create': (context, result) => {
109
+ logger.info('Document created', {
110
+ model: context.model,
111
+ id: result._id,
112
+ user: context.user?.id,
113
+ });
114
+ },
115
+ 'after:update': (context, result) => {
116
+ logger.info('Document updated', {
117
+ model: context.model,
118
+ id: result._id,
119
+ user: context.user?.id,
120
+ });
121
+ },
122
+ 'after:delete': (context, result) => {
123
+ logger.info('Document deleted', {
124
+ model: context.model,
125
+ user: context.user?.id,
126
+ });
127
+ },
128
+ }),
129
+
130
+ /**
131
+ * Cache invalidation
132
+ */
133
+ cacheInvalidation: (cache) => ({
134
+ 'after:create': async (context, result) => {
135
+ await cache.invalidate(`${context.model}:*`);
136
+ },
137
+ 'after:update': async (context, result) => {
138
+ await cache.invalidate(`${context.model}:${result._id}`);
139
+ await cache.invalidate(`${context.model}:*`);
140
+ },
141
+ 'after:delete': async (context) => {
142
+ await cache.invalidate(`${context.model}:*`);
143
+ },
144
+ }),
145
+ };
146
+
package/src/index.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Repository Pattern - Data Access Layer
3
+ *
4
+ * Event-driven, plugin-based abstraction for MongoDB operations
5
+ * Inspired by Meta & Stripe's repository patterns
6
+ *
7
+ * @module common/repositories
8
+ *
9
+ * Documentation:
10
+ * - README.md - Main documentation (concise overview)
11
+ * - QUICK_REFERENCE.md - One-page cheat sheet
12
+ * - EXAMPLES.md - Detailed examples and patterns
13
+ */
14
+
15
+ /**
16
+ * MongoKit - Event-driven repository pattern for MongoDB
17
+ *
18
+ * @module @classytic/mongokit
19
+ * @author Sadman Chowdhury (Github: @siam923)
20
+ * @license MIT
21
+ */
22
+
23
+ export { Repository } from './Repository.js';
24
+
25
+ // Plugins
26
+ export { fieldFilterPlugin } from './plugins/field-filter.plugin.js';
27
+ export { timestampPlugin } from './plugins/timestamp.plugin.js';
28
+ export { auditLogPlugin } from './plugins/audit-log.plugin.js';
29
+ export { softDeletePlugin } from './plugins/soft-delete.plugin.js';
30
+ export { methodRegistryPlugin } from './plugins/method-registry.plugin.js';
31
+ export {
32
+ validationChainPlugin,
33
+ blockIf,
34
+ requireField,
35
+ autoInject,
36
+ immutableField,
37
+ uniqueField,
38
+ } from './plugins/validation-chain.plugin.js';
39
+ export { mongoOperationsPlugin } from './plugins/mongo-operations.plugin.js';
40
+ export { batchOperationsPlugin } from './plugins/batch-operations.plugin.js';
41
+ export { aggregateHelpersPlugin } from './plugins/aggregate-helpers.plugin.js';
42
+ export { subdocumentPlugin } from './plugins/subdocument.plugin.js';
43
+
44
+ // Utilities
45
+ export {
46
+ getFieldsForUser,
47
+ getMongooseProjection,
48
+ filterResponseData,
49
+ createFieldPreset,
50
+ } from './utils/field-selection.js';
51
+
52
+ export * as actions from './actions/index.js';
53
+
54
+ import { Repository } from './Repository.js';
55
+
56
+ export const createRepository = (Model, plugins = []) => {
57
+ return new Repository(Model, plugins);
58
+ };
59
+
60
+ export default Repository;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Aggregate Helpers Plugin
3
+ * Adds common aggregation helper methods
4
+ */
5
+
6
+ export const aggregateHelpersPlugin = () => ({
7
+ name: 'aggregate-helpers',
8
+
9
+ apply(repo) {
10
+ if (!repo.registerMethod) {
11
+ throw new Error('aggregateHelpersPlugin requires methodRegistryPlugin');
12
+ }
13
+
14
+ /**
15
+ * Group by field
16
+ */
17
+ repo.registerMethod('groupBy', async function (field, options = {}) {
18
+ const pipeline = [
19
+ { $group: { _id: `$${field}`, count: { $sum: 1 } } },
20
+ { $sort: { count: -1 } }
21
+ ];
22
+
23
+ if (options.limit) {
24
+ pipeline.push({ $limit: options.limit });
25
+ }
26
+
27
+ return this.aggregate(pipeline, options);
28
+ });
29
+
30
+ // Helper: Generic aggregation operation
31
+ const aggregateOperation = async function (field, operator, resultKey, query = {}, options = {}) {
32
+ const pipeline = [
33
+ { $match: query },
34
+ { $group: { _id: null, [resultKey]: { [operator]: `$${field}` } } }
35
+ ];
36
+
37
+ const result = await this.aggregate(pipeline, options);
38
+ return result[0]?.[resultKey] || 0;
39
+ };
40
+
41
+ /**
42
+ * Sum field values
43
+ */
44
+ repo.registerMethod('sum', async function (field, query = {}, options = {}) {
45
+ return aggregateOperation.call(this, field, '$sum', 'total', query, options);
46
+ });
47
+
48
+ /**
49
+ * Average field values
50
+ */
51
+ repo.registerMethod('average', async function (field, query = {}, options = {}) {
52
+ return aggregateOperation.call(this, field, '$avg', 'avg', query, options);
53
+ });
54
+
55
+ /**
56
+ * Get minimum value
57
+ */
58
+ repo.registerMethod('min', async function (field, query = {}, options = {}) {
59
+ return aggregateOperation.call(this, field, '$min', 'min', query, options);
60
+ });
61
+
62
+ /**
63
+ * Get maximum value
64
+ */
65
+ repo.registerMethod('max', async function (field, query = {}, options = {}) {
66
+ return aggregateOperation.call(this, field, '$max', 'max', query, options);
67
+ });
68
+ }
69
+ });
70
+
71
+ export default aggregateHelpersPlugin;
@@ -0,0 +1,60 @@
1
+ export const auditLogPlugin = (logger) => ({
2
+ name: 'auditLog',
3
+
4
+ apply(repo) {
5
+ repo.on('after:create', ({ context, result }) => {
6
+ logger?.info?.('Document created', {
7
+ model: context.model || repo.model,
8
+ id: result._id,
9
+ userId: context.user?._id || context.user?.id,
10
+ organizationId: context.organizationId,
11
+ });
12
+ });
13
+
14
+ repo.on('after:update', ({ context, result }) => {
15
+ logger?.info?.('Document updated', {
16
+ model: context.model || repo.model,
17
+ id: context.id || result._id,
18
+ userId: context.user?._id || context.user?.id,
19
+ organizationId: context.organizationId,
20
+ });
21
+ });
22
+
23
+ repo.on('after:delete', ({ context, result }) => {
24
+ logger?.info?.('Document deleted', {
25
+ model: context.model || repo.model,
26
+ id: context.id,
27
+ userId: context.user?._id || context.user?.id,
28
+ organizationId: context.organizationId,
29
+ });
30
+ });
31
+
32
+ repo.on('error:create', ({ context, error }) => {
33
+ logger?.error?.('Create failed', {
34
+ model: context.model || repo.model,
35
+ error: error.message,
36
+ userId: context.user?._id || context.user?.id,
37
+ });
38
+ });
39
+
40
+ repo.on('error:update', ({ context, error }) => {
41
+ logger?.error?.('Update failed', {
42
+ model: context.model || repo.model,
43
+ id: context.id,
44
+ error: error.message,
45
+ userId: context.user?._id || context.user?.id,
46
+ });
47
+ });
48
+
49
+ repo.on('error:delete', ({ context, error }) => {
50
+ logger?.error?.('Delete failed', {
51
+ model: context.model || repo.model,
52
+ id: context.id,
53
+ error: error.message,
54
+ userId: context.user?._id || context.user?.id,
55
+ });
56
+ });
57
+ },
58
+ });
59
+
60
+ export default auditLogPlugin;