@classytic/mongokit 3.2.0 → 3.2.2
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 +470 -193
- package/dist/actions/index.d.mts +9 -0
- package/dist/actions/index.mjs +15 -0
- package/dist/aggregate-BAi4Do-X.mjs +767 -0
- package/dist/aggregate-CCHI7F51.d.mts +269 -0
- package/dist/ai/index.d.mts +125 -0
- package/dist/ai/index.mjs +203 -0
- package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/create-BuO6xt0v.mjs +55 -0
- package/dist/custom-id.plugin-B_zIs6gE.mjs +1818 -0
- package/dist/custom-id.plugin-BzZI4gnE.d.mts +893 -0
- package/dist/index.d.mts +1012 -0
- package/dist/index.mjs +1906 -0
- package/dist/limits-DsNeCx4D.mjs +299 -0
- package/dist/logger-D8ily-PP.mjs +51 -0
- package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
- package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
- package/dist/pagination/PaginationEngine.d.mts +93 -0
- package/dist/pagination/PaginationEngine.mjs +196 -0
- package/dist/plugins/index.d.mts +3 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/types-D-gploPr.d.mts +1241 -0
- package/dist/utils/{index.d.ts → index.d.mts} +14 -21
- package/dist/utils/index.mjs +5 -0
- package/package.json +21 -21
- package/dist/actions/index.d.ts +0 -3
- package/dist/actions/index.js +0 -5
- package/dist/ai/index.d.ts +0 -175
- package/dist/ai/index.js +0 -206
- package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
- package/dist/chunks/chunk-44KXLGPO.js +0 -388
- package/dist/chunks/chunk-DEVXDBRL.js +0 -1226
- package/dist/chunks/chunk-I7CWNAJB.js +0 -46
- package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
- package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
- package/dist/chunks/chunk-URLJFIR7.js +0 -22
- package/dist/chunks/chunk-VWKIKZYF.js +0 -737
- package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
- package/dist/index-BDn5fSTE.d.ts +0 -516
- package/dist/index.d.ts +0 -1422
- package/dist/index.js +0 -1893
- package/dist/pagination/PaginationEngine.d.ts +0 -117
- package/dist/pagination/PaginationEngine.js +0 -3
- package/dist/plugins/index.d.ts +0 -922
- package/dist/plugins/index.js +0 -6
- package/dist/types-Jni1KgkP.d.ts +0 -780
- package/dist/utils/index.js +0 -5
|
@@ -0,0 +1,1818 @@
|
|
|
1
|
+
import { i as createError, n as debug, r as warn } from "./logger-D8ily-PP.mjs";
|
|
2
|
+
import { i as upsert } from "./create-BuO6xt0v.mjs";
|
|
3
|
+
import { a as modelPattern, i as listQueryKey, l as getFieldsForUser, n as byQueryKey, o as versionKey, t as byIdKey } from "./cache-keys-C8Z9B5sw.mjs";
|
|
4
|
+
import mongoose from "mongoose";
|
|
5
|
+
|
|
6
|
+
//#region src/plugins/field-filter.plugin.ts
|
|
7
|
+
/**
|
|
8
|
+
* Field Filter Plugin
|
|
9
|
+
* Automatically filters response fields based on user roles
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Field filter plugin that restricts fields based on user context
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const fieldPreset = {
|
|
16
|
+
* public: ['id', 'name'],
|
|
17
|
+
* authenticated: ['email'],
|
|
18
|
+
* admin: ['createdAt', 'internalNotes']
|
|
19
|
+
* };
|
|
20
|
+
*
|
|
21
|
+
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
22
|
+
*/
|
|
23
|
+
function fieldFilterPlugin(fieldPreset) {
|
|
24
|
+
return {
|
|
25
|
+
name: "fieldFilter",
|
|
26
|
+
apply(repo) {
|
|
27
|
+
const applyFieldFiltering = (context) => {
|
|
28
|
+
if (!fieldPreset) return;
|
|
29
|
+
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
30
|
+
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
31
|
+
else context.select = presetSelect;
|
|
32
|
+
};
|
|
33
|
+
repo.on("before:getAll", applyFieldFiltering);
|
|
34
|
+
repo.on("before:getById", applyFieldFiltering);
|
|
35
|
+
repo.on("before:getByQuery", applyFieldFiltering);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/plugins/timestamp.plugin.ts
|
|
42
|
+
/**
|
|
43
|
+
* Timestamp plugin that auto-injects timestamps
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const repo = new Repository(Model, [timestampPlugin()]);
|
|
47
|
+
*/
|
|
48
|
+
function timestampPlugin() {
|
|
49
|
+
return {
|
|
50
|
+
name: "timestamp",
|
|
51
|
+
apply(repo) {
|
|
52
|
+
repo.on("before:create", (context) => {
|
|
53
|
+
if (!context.data) return;
|
|
54
|
+
const now = /* @__PURE__ */ new Date();
|
|
55
|
+
if (!context.data.createdAt) context.data.createdAt = now;
|
|
56
|
+
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
57
|
+
});
|
|
58
|
+
repo.on("before:update", (context) => {
|
|
59
|
+
if (!context.data) return;
|
|
60
|
+
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/plugins/audit-log.plugin.ts
|
|
68
|
+
/**
|
|
69
|
+
* Audit log plugin that logs all repository operations
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* const repo = new Repository(Model, [auditLogPlugin(console)]);
|
|
73
|
+
*/
|
|
74
|
+
function auditLogPlugin(logger) {
|
|
75
|
+
return {
|
|
76
|
+
name: "auditLog",
|
|
77
|
+
apply(repo) {
|
|
78
|
+
repo.on("after:create", ({ context, result }) => {
|
|
79
|
+
logger?.info?.("Document created", {
|
|
80
|
+
model: context.model || repo.model,
|
|
81
|
+
id: result?._id,
|
|
82
|
+
userId: context.user?._id || context.user?.id,
|
|
83
|
+
organizationId: context.organizationId
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
repo.on("after:update", ({ context, result }) => {
|
|
87
|
+
logger?.info?.("Document updated", {
|
|
88
|
+
model: context.model || repo.model,
|
|
89
|
+
id: context.id || result?._id,
|
|
90
|
+
userId: context.user?._id || context.user?.id,
|
|
91
|
+
organizationId: context.organizationId
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
repo.on("after:delete", ({ context }) => {
|
|
95
|
+
logger?.info?.("Document deleted", {
|
|
96
|
+
model: context.model || repo.model,
|
|
97
|
+
id: context.id,
|
|
98
|
+
userId: context.user?._id || context.user?.id,
|
|
99
|
+
organizationId: context.organizationId
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
repo.on("error:create", ({ context, error }) => {
|
|
103
|
+
logger?.error?.("Create failed", {
|
|
104
|
+
model: context.model || repo.model,
|
|
105
|
+
error: error.message,
|
|
106
|
+
userId: context.user?._id || context.user?.id
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
repo.on("error:update", ({ context, error }) => {
|
|
110
|
+
logger?.error?.("Update failed", {
|
|
111
|
+
model: context.model || repo.model,
|
|
112
|
+
id: context.id,
|
|
113
|
+
error: error.message,
|
|
114
|
+
userId: context.user?._id || context.user?.id
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
repo.on("error:delete", ({ context, error }) => {
|
|
118
|
+
logger?.error?.("Delete failed", {
|
|
119
|
+
model: context.model || repo.model,
|
|
120
|
+
id: context.id,
|
|
121
|
+
error: error.message,
|
|
122
|
+
userId: context.user?._id || context.user?.id
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/plugins/soft-delete.plugin.ts
|
|
131
|
+
/**
|
|
132
|
+
* Build filter condition based on filter mode
|
|
133
|
+
*/
|
|
134
|
+
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
135
|
+
if (includeDeleted) return {};
|
|
136
|
+
if (filterMode === "exists") return { [deletedField]: { $exists: false } };
|
|
137
|
+
return { [deletedField]: null };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Build filter condition for finding deleted documents
|
|
141
|
+
*/
|
|
142
|
+
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
143
|
+
if (filterMode === "exists") return { [deletedField]: {
|
|
144
|
+
$exists: true,
|
|
145
|
+
$ne: null
|
|
146
|
+
} };
|
|
147
|
+
return { [deletedField]: { $ne: null } };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Soft delete plugin
|
|
151
|
+
*
|
|
152
|
+
* @example Basic usage
|
|
153
|
+
* ```typescript
|
|
154
|
+
* const repo = new Repository(Model, [
|
|
155
|
+
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
156
|
+
* ]);
|
|
157
|
+
*
|
|
158
|
+
* // Delete (soft)
|
|
159
|
+
* await repo.delete(id);
|
|
160
|
+
*
|
|
161
|
+
* // Restore
|
|
162
|
+
* await repo.restore(id);
|
|
163
|
+
*
|
|
164
|
+
* // Get deleted documents
|
|
165
|
+
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* @example With null filter mode (for schemas with default: null)
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // Schema: { deletedAt: { type: Date, default: null } }
|
|
171
|
+
* const repo = new Repository(Model, [
|
|
172
|
+
* softDeletePlugin({
|
|
173
|
+
* deletedField: 'deletedAt',
|
|
174
|
+
* filterMode: 'null', // default - works with default: null
|
|
175
|
+
* })
|
|
176
|
+
* ]);
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @example With TTL for auto-cleanup
|
|
180
|
+
* ```typescript
|
|
181
|
+
* const repo = new Repository(Model, [
|
|
182
|
+
* softDeletePlugin({
|
|
183
|
+
* deletedField: 'deletedAt',
|
|
184
|
+
* ttlDays: 30, // Auto-delete after 30 days
|
|
185
|
+
* })
|
|
186
|
+
* ]);
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
function softDeletePlugin(options = {}) {
|
|
190
|
+
const deletedField = options.deletedField || "deletedAt";
|
|
191
|
+
const deletedByField = options.deletedByField || "deletedBy";
|
|
192
|
+
const filterMode = options.filterMode || "null";
|
|
193
|
+
const addRestoreMethod = options.addRestoreMethod !== false;
|
|
194
|
+
const addGetDeletedMethod = options.addGetDeletedMethod !== false;
|
|
195
|
+
const ttlDays = options.ttlDays;
|
|
196
|
+
return {
|
|
197
|
+
name: "softDelete",
|
|
198
|
+
apply(repo) {
|
|
199
|
+
try {
|
|
200
|
+
const schemaPaths = repo.Model.schema.paths;
|
|
201
|
+
for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
|
|
202
|
+
if (pathName === "_id" || pathName === deletedField) continue;
|
|
203
|
+
if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
|
|
204
|
+
}
|
|
205
|
+
} catch {}
|
|
206
|
+
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
207
|
+
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
208
|
+
repo.Model.collection.createIndex({ [deletedField]: 1 }, {
|
|
209
|
+
expireAfterSeconds: ttlSeconds,
|
|
210
|
+
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
211
|
+
}).catch((err) => {
|
|
212
|
+
if (!err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
repo.on("before:delete", async (context) => {
|
|
216
|
+
if (options.soft !== false) {
|
|
217
|
+
const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
|
|
218
|
+
if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
|
|
219
|
+
await repo.Model.findByIdAndUpdate(context.id, updateData, { session: context.session });
|
|
220
|
+
context.softDeleted = true;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
repo.on("before:getAll", (context) => {
|
|
224
|
+
if (options.soft !== false) {
|
|
225
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
226
|
+
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
227
|
+
...context.filters || {},
|
|
228
|
+
...deleteFilter
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
repo.on("before:getById", (context) => {
|
|
233
|
+
if (options.soft !== false) {
|
|
234
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
235
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
236
|
+
...context.query || {},
|
|
237
|
+
...deleteFilter
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
repo.on("before:getByQuery", (context) => {
|
|
242
|
+
if (options.soft !== false) {
|
|
243
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
244
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
245
|
+
...context.query || {},
|
|
246
|
+
...deleteFilter
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
if (addRestoreMethod) {
|
|
251
|
+
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
252
|
+
const updateData = {
|
|
253
|
+
[deletedField]: null,
|
|
254
|
+
[deletedByField]: null
|
|
255
|
+
};
|
|
256
|
+
const result = await this.Model.findByIdAndUpdate(id, { $set: updateData }, {
|
|
257
|
+
returnDocument: "after",
|
|
258
|
+
session: restoreOptions.session
|
|
259
|
+
});
|
|
260
|
+
if (!result) {
|
|
261
|
+
const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
|
|
262
|
+
error.status = 404;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
await this.emitAsync("after:restore", {
|
|
266
|
+
id,
|
|
267
|
+
result
|
|
268
|
+
});
|
|
269
|
+
return result;
|
|
270
|
+
};
|
|
271
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
|
|
272
|
+
else repo.restore = restoreMethod.bind(repo);
|
|
273
|
+
}
|
|
274
|
+
if (addGetDeletedMethod) {
|
|
275
|
+
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
276
|
+
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
277
|
+
const combinedFilters = {
|
|
278
|
+
...params.filters || {},
|
|
279
|
+
...deletedFilter
|
|
280
|
+
};
|
|
281
|
+
const page = params.page || 1;
|
|
282
|
+
const limit = params.limit || 20;
|
|
283
|
+
const skip = (page - 1) * limit;
|
|
284
|
+
let sortSpec = { [deletedField]: -1 };
|
|
285
|
+
if (params.sort) if (typeof params.sort === "string") {
|
|
286
|
+
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
287
|
+
sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
|
|
288
|
+
} else sortSpec = params.sort;
|
|
289
|
+
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
290
|
+
if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
|
|
291
|
+
if (getDeletedOptions.select) {
|
|
292
|
+
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
293
|
+
query = query.select(selectValue);
|
|
294
|
+
}
|
|
295
|
+
if (getDeletedOptions.populate) {
|
|
296
|
+
const populateSpec = getDeletedOptions.populate;
|
|
297
|
+
if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
298
|
+
else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
|
|
299
|
+
else query = query.populate(populateSpec);
|
|
300
|
+
}
|
|
301
|
+
if (getDeletedOptions.lean !== false) query = query.lean();
|
|
302
|
+
const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
|
|
303
|
+
const pages = Math.ceil(total / limit);
|
|
304
|
+
return {
|
|
305
|
+
method: "offset",
|
|
306
|
+
docs,
|
|
307
|
+
page,
|
|
308
|
+
limit,
|
|
309
|
+
total,
|
|
310
|
+
pages,
|
|
311
|
+
hasNext: page < pages,
|
|
312
|
+
hasPrev: page > 1
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
|
|
316
|
+
else repo.getDeleted = getDeletedMethod.bind(repo);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/plugins/method-registry.plugin.ts
|
|
324
|
+
/**
|
|
325
|
+
* Method registry plugin that enables dynamic method registration
|
|
326
|
+
*/
|
|
327
|
+
function methodRegistryPlugin() {
|
|
328
|
+
return {
|
|
329
|
+
name: "method-registry",
|
|
330
|
+
apply(repo) {
|
|
331
|
+
const registeredMethods = [];
|
|
332
|
+
/**
|
|
333
|
+
* Register a new method on the repository instance
|
|
334
|
+
*/
|
|
335
|
+
repo.registerMethod = function(name, fn) {
|
|
336
|
+
if (repo[name]) throw new Error(`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`);
|
|
337
|
+
if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
|
|
338
|
+
if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
|
|
339
|
+
repo[name] = fn.bind(repo);
|
|
340
|
+
registeredMethods.push(name);
|
|
341
|
+
repo.emit("method:registered", {
|
|
342
|
+
name,
|
|
343
|
+
fn
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* Check if a method is registered
|
|
348
|
+
*/
|
|
349
|
+
repo.hasMethod = function(name) {
|
|
350
|
+
return typeof repo[name] === "function";
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Get list of all dynamically registered methods
|
|
354
|
+
*/
|
|
355
|
+
repo.getRegisteredMethods = function() {
|
|
356
|
+
return [...registeredMethods];
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/plugins/validation-chain.plugin.ts
|
|
364
|
+
/**
|
|
365
|
+
* Validation Chain Plugin
|
|
366
|
+
*
|
|
367
|
+
* Composable validation for repository operations with customizable rules.
|
|
368
|
+
*/
|
|
369
|
+
/**
|
|
370
|
+
* Validation chain plugin
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* const repo = new Repository(Model, [
|
|
374
|
+
* validationChainPlugin([
|
|
375
|
+
* requireField('email'),
|
|
376
|
+
* uniqueField('email', 'Email already exists'),
|
|
377
|
+
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
378
|
+
* ])
|
|
379
|
+
* ]);
|
|
380
|
+
*/
|
|
381
|
+
function validationChainPlugin(validators = [], options = {}) {
|
|
382
|
+
const { stopOnFirstError = true } = options;
|
|
383
|
+
validators.forEach((v, idx) => {
|
|
384
|
+
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
385
|
+
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
386
|
+
});
|
|
387
|
+
const validatorsByOperation = {
|
|
388
|
+
create: [],
|
|
389
|
+
update: [],
|
|
390
|
+
delete: [],
|
|
391
|
+
createMany: []
|
|
392
|
+
};
|
|
393
|
+
const allOperationsValidators = [];
|
|
394
|
+
validators.forEach((v) => {
|
|
395
|
+
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
396
|
+
else v.operations.forEach((op) => {
|
|
397
|
+
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
name: "validation-chain",
|
|
402
|
+
apply(repo) {
|
|
403
|
+
const getValidatorsForOperation = (operation) => {
|
|
404
|
+
const specific = validatorsByOperation[operation] || [];
|
|
405
|
+
return [...allOperationsValidators, ...specific];
|
|
406
|
+
};
|
|
407
|
+
const runValidators = async (operation, context) => {
|
|
408
|
+
const operationValidators = getValidatorsForOperation(operation);
|
|
409
|
+
const errors = [];
|
|
410
|
+
for (const validator of operationValidators) try {
|
|
411
|
+
await validator.validate(context, repo);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (stopOnFirstError) throw error;
|
|
414
|
+
errors.push({
|
|
415
|
+
validator: validator.name,
|
|
416
|
+
error: error.message || String(error)
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (errors.length > 0) {
|
|
420
|
+
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
421
|
+
err.validationErrors = errors;
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
repo.on("before:create", async (context) => runValidators("create", context));
|
|
426
|
+
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
427
|
+
repo.on("before:update", async (context) => runValidators("update", context));
|
|
428
|
+
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Block operation if condition is true
|
|
434
|
+
*
|
|
435
|
+
* @example
|
|
436
|
+
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
437
|
+
*/
|
|
438
|
+
function blockIf(name, operations, condition, errorMessage) {
|
|
439
|
+
return {
|
|
440
|
+
name,
|
|
441
|
+
operations,
|
|
442
|
+
validate: (context) => {
|
|
443
|
+
if (condition(context)) throw createError(403, errorMessage);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Require a field to be present
|
|
449
|
+
*/
|
|
450
|
+
function requireField(field, operations = ["create"]) {
|
|
451
|
+
return {
|
|
452
|
+
name: `require-${field}`,
|
|
453
|
+
operations,
|
|
454
|
+
validate: (context) => {
|
|
455
|
+
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Auto-inject a value if not present
|
|
461
|
+
*/
|
|
462
|
+
function autoInject(field, getter, operations = ["create"]) {
|
|
463
|
+
return {
|
|
464
|
+
name: `auto-inject-${field}`,
|
|
465
|
+
operations,
|
|
466
|
+
validate: (context) => {
|
|
467
|
+
if (context.data && !(field in context.data)) {
|
|
468
|
+
const value = getter(context);
|
|
469
|
+
if (value !== null && value !== void 0) context.data[field] = value;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Make a field immutable (cannot be updated)
|
|
476
|
+
*/
|
|
477
|
+
function immutableField(field) {
|
|
478
|
+
return {
|
|
479
|
+
name: `immutable-${field}`,
|
|
480
|
+
operations: ["update"],
|
|
481
|
+
validate: (context) => {
|
|
482
|
+
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Ensure field value is unique
|
|
488
|
+
*/
|
|
489
|
+
function uniqueField(field, errorMessage) {
|
|
490
|
+
return {
|
|
491
|
+
name: `unique-${field}`,
|
|
492
|
+
operations: ["create", "update"],
|
|
493
|
+
validate: async (context, repo) => {
|
|
494
|
+
if (!context.data || !context.data[field] || !repo) return;
|
|
495
|
+
const query = { [field]: context.data[field] };
|
|
496
|
+
const getByQuery = repo.getByQuery;
|
|
497
|
+
if (typeof getByQuery !== "function") return;
|
|
498
|
+
const existing = await getByQuery.call(repo, query, {
|
|
499
|
+
select: "_id",
|
|
500
|
+
lean: true,
|
|
501
|
+
throwOnNotFound: false
|
|
502
|
+
});
|
|
503
|
+
if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/plugins/mongo-operations.plugin.ts
|
|
510
|
+
/**
|
|
511
|
+
* MongoDB Operations Plugin
|
|
512
|
+
*
|
|
513
|
+
* Adds MongoDB-specific operations to repositories.
|
|
514
|
+
* Requires method-registry.plugin.js to be loaded first.
|
|
515
|
+
*/
|
|
516
|
+
/**
|
|
517
|
+
* MongoDB operations plugin
|
|
518
|
+
*
|
|
519
|
+
* Adds MongoDB-specific atomic operations to repositories:
|
|
520
|
+
* - upsert: Create or update document
|
|
521
|
+
* - increment/decrement: Atomic numeric operations
|
|
522
|
+
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
523
|
+
* - setField/unsetField/renameField: Field operations
|
|
524
|
+
* - multiplyField: Multiply numeric field
|
|
525
|
+
* - setMin/setMax: Conditional min/max updates
|
|
526
|
+
*
|
|
527
|
+
* @example Basic usage (no TypeScript autocomplete)
|
|
528
|
+
* ```typescript
|
|
529
|
+
* const repo = new Repository(ProductModel, [
|
|
530
|
+
* methodRegistryPlugin(),
|
|
531
|
+
* mongoOperationsPlugin(),
|
|
532
|
+
* ]);
|
|
533
|
+
*
|
|
534
|
+
* // Works at runtime but TypeScript doesn't know about these methods
|
|
535
|
+
* await (repo as any).increment(productId, 'views', 1);
|
|
536
|
+
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
537
|
+
* ```
|
|
538
|
+
*
|
|
539
|
+
* @example With TypeScript type safety (recommended)
|
|
540
|
+
* ```typescript
|
|
541
|
+
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
542
|
+
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
543
|
+
*
|
|
544
|
+
* class ProductRepo extends Repository<IProduct> {
|
|
545
|
+
* // Add your custom methods here
|
|
546
|
+
* }
|
|
547
|
+
*
|
|
548
|
+
* // Create with type assertion to get autocomplete for plugin methods
|
|
549
|
+
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
550
|
+
*
|
|
551
|
+
* const repo = new ProductRepo(ProductModel, [
|
|
552
|
+
* methodRegistryPlugin(),
|
|
553
|
+
* mongoOperationsPlugin(),
|
|
554
|
+
* ]) as ProductRepoWithPlugins;
|
|
555
|
+
*
|
|
556
|
+
* // Now TypeScript provides autocomplete and type checking!
|
|
557
|
+
* await repo.increment(productId, 'views', 1);
|
|
558
|
+
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
559
|
+
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
560
|
+
* ```
|
|
561
|
+
*/
|
|
562
|
+
function mongoOperationsPlugin() {
|
|
563
|
+
return {
|
|
564
|
+
name: "mongo-operations",
|
|
565
|
+
apply(repo) {
|
|
566
|
+
if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
|
|
567
|
+
/**
|
|
568
|
+
* Update existing document or insert new one
|
|
569
|
+
*/
|
|
570
|
+
repo.registerMethod("upsert", async function(query, data, options = {}) {
|
|
571
|
+
return upsert(this.Model, query, data, options);
|
|
572
|
+
});
|
|
573
|
+
const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
|
|
574
|
+
if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
|
|
575
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* Atomically increment numeric field
|
|
579
|
+
*/
|
|
580
|
+
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
581
|
+
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
582
|
+
});
|
|
583
|
+
/**
|
|
584
|
+
* Atomically decrement numeric field
|
|
585
|
+
*/
|
|
586
|
+
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
587
|
+
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
588
|
+
});
|
|
589
|
+
const applyOperator = function(id, field, value, operator, options) {
|
|
590
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Push value to array field
|
|
594
|
+
*/
|
|
595
|
+
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
596
|
+
return applyOperator.call(this, id, field, value, "$push", options);
|
|
597
|
+
});
|
|
598
|
+
/**
|
|
599
|
+
* Remove value from array field
|
|
600
|
+
*/
|
|
601
|
+
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
602
|
+
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
603
|
+
});
|
|
604
|
+
/**
|
|
605
|
+
* Add value to array only if not already present (unique)
|
|
606
|
+
*/
|
|
607
|
+
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
608
|
+
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
609
|
+
});
|
|
610
|
+
/**
|
|
611
|
+
* Set field value (alias for update with $set)
|
|
612
|
+
*/
|
|
613
|
+
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
614
|
+
return applyOperator.call(this, id, field, value, "$set", options);
|
|
615
|
+
});
|
|
616
|
+
/**
|
|
617
|
+
* Unset (remove) field from document
|
|
618
|
+
*/
|
|
619
|
+
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
620
|
+
const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
|
|
621
|
+
acc[field] = "";
|
|
622
|
+
return acc;
|
|
623
|
+
}, {});
|
|
624
|
+
return this.update(id, { $unset: unsetObj }, options);
|
|
625
|
+
});
|
|
626
|
+
/**
|
|
627
|
+
* Rename field in document
|
|
628
|
+
*/
|
|
629
|
+
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
630
|
+
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
631
|
+
});
|
|
632
|
+
/**
|
|
633
|
+
* Multiply numeric field by value
|
|
634
|
+
*/
|
|
635
|
+
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
636
|
+
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
637
|
+
});
|
|
638
|
+
/**
|
|
639
|
+
* Set field to minimum value (only if current value is greater)
|
|
640
|
+
*/
|
|
641
|
+
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
642
|
+
return applyOperator.call(this, id, field, value, "$min", options);
|
|
643
|
+
});
|
|
644
|
+
/**
|
|
645
|
+
* Set field to maximum value (only if current value is less)
|
|
646
|
+
*/
|
|
647
|
+
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
648
|
+
return applyOperator.call(this, id, field, value, "$max", options);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/plugins/batch-operations.plugin.ts
|
|
656
|
+
/**
|
|
657
|
+
* Batch operations plugin
|
|
658
|
+
*
|
|
659
|
+
* @example
|
|
660
|
+
* const repo = new Repository(Model, [
|
|
661
|
+
* methodRegistryPlugin(),
|
|
662
|
+
* batchOperationsPlugin(),
|
|
663
|
+
* ]);
|
|
664
|
+
*
|
|
665
|
+
* await repo.updateMany({ status: 'pending' }, { status: 'active' });
|
|
666
|
+
* await repo.deleteMany({ status: 'deleted' });
|
|
667
|
+
*/
|
|
668
|
+
function batchOperationsPlugin() {
|
|
669
|
+
return {
|
|
670
|
+
name: "batch-operations",
|
|
671
|
+
apply(repo) {
|
|
672
|
+
if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
|
|
673
|
+
/**
|
|
674
|
+
* Update multiple documents
|
|
675
|
+
*/
|
|
676
|
+
repo.registerMethod("updateMany", async function(query, data, options = {}) {
|
|
677
|
+
const context = await this._buildContext.call(this, "updateMany", {
|
|
678
|
+
query,
|
|
679
|
+
data,
|
|
680
|
+
options
|
|
681
|
+
});
|
|
682
|
+
try {
|
|
683
|
+
await this.emitAsync("before:updateMany", context);
|
|
684
|
+
if (Array.isArray(data) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
|
|
685
|
+
const result = await this.Model.updateMany(query, data, {
|
|
686
|
+
runValidators: true,
|
|
687
|
+
session: options.session,
|
|
688
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
689
|
+
}).exec();
|
|
690
|
+
await this.emitAsync("after:updateMany", {
|
|
691
|
+
context,
|
|
692
|
+
result
|
|
693
|
+
});
|
|
694
|
+
return result;
|
|
695
|
+
} catch (error) {
|
|
696
|
+
this.emit("error:updateMany", {
|
|
697
|
+
context,
|
|
698
|
+
error
|
|
699
|
+
});
|
|
700
|
+
throw this._handleError.call(this, error);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
/**
|
|
704
|
+
* Delete multiple documents
|
|
705
|
+
*/
|
|
706
|
+
repo.registerMethod("deleteMany", async function(query, options = {}) {
|
|
707
|
+
const context = await this._buildContext.call(this, "deleteMany", {
|
|
708
|
+
query,
|
|
709
|
+
options
|
|
710
|
+
});
|
|
711
|
+
try {
|
|
712
|
+
await this.emitAsync("before:deleteMany", context);
|
|
713
|
+
const result = await this.Model.deleteMany(query, { session: options.session }).exec();
|
|
714
|
+
await this.emitAsync("after:deleteMany", {
|
|
715
|
+
context,
|
|
716
|
+
result
|
|
717
|
+
});
|
|
718
|
+
return result;
|
|
719
|
+
} catch (error) {
|
|
720
|
+
this.emit("error:deleteMany", {
|
|
721
|
+
context,
|
|
722
|
+
error
|
|
723
|
+
});
|
|
724
|
+
throw this._handleError.call(this, error);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
733
|
+
/**
|
|
734
|
+
* Aggregate helpers plugin
|
|
735
|
+
*
|
|
736
|
+
* @example
|
|
737
|
+
* const repo = new Repository(Model, [
|
|
738
|
+
* methodRegistryPlugin(),
|
|
739
|
+
* aggregateHelpersPlugin(),
|
|
740
|
+
* ]);
|
|
741
|
+
*
|
|
742
|
+
* const groups = await repo.groupBy('category');
|
|
743
|
+
* const total = await repo.sum('amount', { status: 'completed' });
|
|
744
|
+
*/
|
|
745
|
+
function aggregateHelpersPlugin() {
|
|
746
|
+
return {
|
|
747
|
+
name: "aggregate-helpers",
|
|
748
|
+
apply(repo) {
|
|
749
|
+
if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
|
|
750
|
+
/**
|
|
751
|
+
* Group by field
|
|
752
|
+
*/
|
|
753
|
+
repo.registerMethod("groupBy", async function(field, options = {}) {
|
|
754
|
+
const pipeline = [{ $group: {
|
|
755
|
+
_id: `$${field}`,
|
|
756
|
+
count: { $sum: 1 }
|
|
757
|
+
} }, { $sort: { count: -1 } }];
|
|
758
|
+
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
759
|
+
return this.aggregate.call(this, pipeline, options);
|
|
760
|
+
});
|
|
761
|
+
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
762
|
+
const pipeline = [{ $match: query }, { $group: {
|
|
763
|
+
_id: null,
|
|
764
|
+
[resultKey]: { [operator]: `$${field}` }
|
|
765
|
+
} }];
|
|
766
|
+
return (await this.aggregate.call(this, pipeline, options))[0]?.[resultKey] || 0;
|
|
767
|
+
};
|
|
768
|
+
/**
|
|
769
|
+
* Sum field values
|
|
770
|
+
*/
|
|
771
|
+
repo.registerMethod("sum", async function(field, query = {}, options = {}) {
|
|
772
|
+
return aggregateOperation.call(this, field, "$sum", "total", query, options);
|
|
773
|
+
});
|
|
774
|
+
/**
|
|
775
|
+
* Average field values
|
|
776
|
+
*/
|
|
777
|
+
repo.registerMethod("average", async function(field, query = {}, options = {}) {
|
|
778
|
+
return aggregateOperation.call(this, field, "$avg", "avg", query, options);
|
|
779
|
+
});
|
|
780
|
+
/**
|
|
781
|
+
* Get minimum value
|
|
782
|
+
*/
|
|
783
|
+
repo.registerMethod("min", async function(field, query = {}, options = {}) {
|
|
784
|
+
return aggregateOperation.call(this, field, "$min", "min", query, options);
|
|
785
|
+
});
|
|
786
|
+
/**
|
|
787
|
+
* Get maximum value
|
|
788
|
+
*/
|
|
789
|
+
repo.registerMethod("max", async function(field, query = {}, options = {}) {
|
|
790
|
+
return aggregateOperation.call(this, field, "$max", "max", query, options);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/plugins/subdocument.plugin.ts
|
|
798
|
+
/**
|
|
799
|
+
* Subdocument plugin for managing nested arrays
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* const repo = new Repository(Model, [
|
|
803
|
+
* methodRegistryPlugin(),
|
|
804
|
+
* subdocumentPlugin(),
|
|
805
|
+
* ]);
|
|
806
|
+
*
|
|
807
|
+
* await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
|
|
808
|
+
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
809
|
+
*/
|
|
810
|
+
function subdocumentPlugin() {
|
|
811
|
+
return {
|
|
812
|
+
name: "subdocument",
|
|
813
|
+
apply(repo) {
|
|
814
|
+
if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
815
|
+
/**
|
|
816
|
+
* Add subdocument to array
|
|
817
|
+
*/
|
|
818
|
+
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
819
|
+
return this.update.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
|
|
820
|
+
});
|
|
821
|
+
/**
|
|
822
|
+
* Get subdocument from array
|
|
823
|
+
*/
|
|
824
|
+
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
825
|
+
return this._executeQuery.call(this, async (Model) => {
|
|
826
|
+
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
827
|
+
if (!parent) throw createError(404, "Parent not found");
|
|
828
|
+
const arrayField = parent[arrayPath];
|
|
829
|
+
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
830
|
+
const sub = arrayField.id(subId);
|
|
831
|
+
if (!sub) throw createError(404, "Subdocument not found");
|
|
832
|
+
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
/**
|
|
836
|
+
* Update subdocument in array
|
|
837
|
+
*/
|
|
838
|
+
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
839
|
+
return this._executeQuery.call(this, async (Model) => {
|
|
840
|
+
const query = {
|
|
841
|
+
_id: parentId,
|
|
842
|
+
[`${arrayPath}._id`]: subId
|
|
843
|
+
};
|
|
844
|
+
const update = { $set: { [`${arrayPath}.$`]: {
|
|
845
|
+
...updateData,
|
|
846
|
+
_id: subId
|
|
847
|
+
} } };
|
|
848
|
+
const result = await Model.findOneAndUpdate(query, update, {
|
|
849
|
+
returnDocument: "after",
|
|
850
|
+
runValidators: true,
|
|
851
|
+
session: options.session
|
|
852
|
+
}).exec();
|
|
853
|
+
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
854
|
+
return result;
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
/**
|
|
858
|
+
* Delete subdocument from array
|
|
859
|
+
*/
|
|
860
|
+
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
861
|
+
return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/plugins/cache.plugin.ts
|
|
869
|
+
/**
|
|
870
|
+
* Cache plugin factory
|
|
871
|
+
*
|
|
872
|
+
* @param options - Cache configuration
|
|
873
|
+
* @returns Plugin instance
|
|
874
|
+
*/
|
|
875
|
+
function cachePlugin(options) {
|
|
876
|
+
const config = {
|
|
877
|
+
adapter: options.adapter,
|
|
878
|
+
ttl: options.ttl ?? 60,
|
|
879
|
+
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
880
|
+
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
881
|
+
prefix: options.prefix ?? "mk",
|
|
882
|
+
debug: options.debug ?? false,
|
|
883
|
+
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
|
|
884
|
+
};
|
|
885
|
+
const stats = {
|
|
886
|
+
hits: 0,
|
|
887
|
+
misses: 0,
|
|
888
|
+
sets: 0,
|
|
889
|
+
invalidations: 0
|
|
890
|
+
};
|
|
891
|
+
const log = (msg, data) => {
|
|
892
|
+
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
893
|
+
};
|
|
894
|
+
return {
|
|
895
|
+
name: "cache",
|
|
896
|
+
apply(repo) {
|
|
897
|
+
const model = repo.model;
|
|
898
|
+
async function getVersion() {
|
|
899
|
+
try {
|
|
900
|
+
return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
|
|
901
|
+
} catch {
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Bump collection version in the adapter (invalidates all list caches).
|
|
907
|
+
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
908
|
+
*/
|
|
909
|
+
async function bumpVersion() {
|
|
910
|
+
const newVersion = Date.now();
|
|
911
|
+
try {
|
|
912
|
+
await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
|
|
913
|
+
stats.invalidations++;
|
|
914
|
+
log(`Bumped version for ${model} to:`, newVersion);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
log(`Failed to bump version for ${model}:`, e);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Invalidate a specific document by ID
|
|
921
|
+
*/
|
|
922
|
+
async function invalidateById(id) {
|
|
923
|
+
const key = byIdKey(config.prefix, model, id);
|
|
924
|
+
try {
|
|
925
|
+
await config.adapter.del(key);
|
|
926
|
+
stats.invalidations++;
|
|
927
|
+
log(`Invalidated byId cache:`, key);
|
|
928
|
+
} catch (e) {
|
|
929
|
+
log(`Failed to invalidate byId cache:`, e);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* before:getById - Check cache for document
|
|
934
|
+
*/
|
|
935
|
+
repo.on("before:getById", async (context) => {
|
|
936
|
+
if (context.skipCache) {
|
|
937
|
+
log(`Skipping cache for getById: ${context.id}`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const id = String(context.id);
|
|
941
|
+
const key = byIdKey(config.prefix, model, id);
|
|
942
|
+
try {
|
|
943
|
+
const cached = await config.adapter.get(key);
|
|
944
|
+
if (cached !== null) {
|
|
945
|
+
stats.hits++;
|
|
946
|
+
log(`Cache HIT for getById:`, key);
|
|
947
|
+
context._cacheHit = true;
|
|
948
|
+
context._cachedResult = cached;
|
|
949
|
+
} else {
|
|
950
|
+
stats.misses++;
|
|
951
|
+
log(`Cache MISS for getById:`, key);
|
|
952
|
+
}
|
|
953
|
+
} catch (e) {
|
|
954
|
+
log(`Cache error for getById:`, e);
|
|
955
|
+
stats.misses++;
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
/**
|
|
959
|
+
* before:getByQuery - Check cache for single-doc query
|
|
960
|
+
*/
|
|
961
|
+
repo.on("before:getByQuery", async (context) => {
|
|
962
|
+
if (context.skipCache) {
|
|
963
|
+
log(`Skipping cache for getByQuery`);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const query = context.query || {};
|
|
967
|
+
const key = byQueryKey(config.prefix, model, query, {
|
|
968
|
+
select: context.select,
|
|
969
|
+
populate: context.populate
|
|
970
|
+
});
|
|
971
|
+
try {
|
|
972
|
+
const cached = await config.adapter.get(key);
|
|
973
|
+
if (cached !== null) {
|
|
974
|
+
stats.hits++;
|
|
975
|
+
log(`Cache HIT for getByQuery:`, key);
|
|
976
|
+
context._cacheHit = true;
|
|
977
|
+
context._cachedResult = cached;
|
|
978
|
+
} else {
|
|
979
|
+
stats.misses++;
|
|
980
|
+
log(`Cache MISS for getByQuery:`, key);
|
|
981
|
+
}
|
|
982
|
+
} catch (e) {
|
|
983
|
+
log(`Cache error for getByQuery:`, e);
|
|
984
|
+
stats.misses++;
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
/**
|
|
988
|
+
* before:getAll - Check cache for list query
|
|
989
|
+
*/
|
|
990
|
+
repo.on("before:getAll", async (context) => {
|
|
991
|
+
if (context.skipCache) {
|
|
992
|
+
log(`Skipping cache for getAll`);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const limit = context.limit;
|
|
996
|
+
if (limit && limit > config.skipIfLargeLimit) {
|
|
997
|
+
log(`Skipping cache for large query (limit: ${limit})`);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const collectionVersion = await getVersion();
|
|
1001
|
+
const params = {
|
|
1002
|
+
filters: context.filters,
|
|
1003
|
+
sort: context.sort,
|
|
1004
|
+
page: context.page,
|
|
1005
|
+
limit,
|
|
1006
|
+
after: context.after,
|
|
1007
|
+
select: context.select,
|
|
1008
|
+
populate: context.populate,
|
|
1009
|
+
search: context.search
|
|
1010
|
+
};
|
|
1011
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
1012
|
+
try {
|
|
1013
|
+
const cached = await config.adapter.get(key);
|
|
1014
|
+
if (cached !== null) {
|
|
1015
|
+
stats.hits++;
|
|
1016
|
+
log(`Cache HIT for getAll:`, key);
|
|
1017
|
+
context._cacheHit = true;
|
|
1018
|
+
context._cachedResult = cached;
|
|
1019
|
+
} else {
|
|
1020
|
+
stats.misses++;
|
|
1021
|
+
log(`Cache MISS for getAll:`, key);
|
|
1022
|
+
}
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
log(`Cache error for getAll:`, e);
|
|
1025
|
+
stats.misses++;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
/**
|
|
1029
|
+
* after:getById - Cache the result
|
|
1030
|
+
*/
|
|
1031
|
+
repo.on("after:getById", async (payload) => {
|
|
1032
|
+
const { context, result } = payload;
|
|
1033
|
+
if (context._cacheHit) return;
|
|
1034
|
+
if (context.skipCache) return;
|
|
1035
|
+
if (result === null) return;
|
|
1036
|
+
const id = String(context.id);
|
|
1037
|
+
const key = byIdKey(config.prefix, model, id);
|
|
1038
|
+
const ttl = context.cacheTtl ?? config.byIdTtl;
|
|
1039
|
+
try {
|
|
1040
|
+
await config.adapter.set(key, result, ttl);
|
|
1041
|
+
stats.sets++;
|
|
1042
|
+
log(`Cached getById result:`, key);
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
log(`Failed to cache getById:`, e);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
/**
|
|
1048
|
+
* after:getByQuery - Cache the result
|
|
1049
|
+
*/
|
|
1050
|
+
repo.on("after:getByQuery", async (payload) => {
|
|
1051
|
+
const { context, result } = payload;
|
|
1052
|
+
if (context._cacheHit) return;
|
|
1053
|
+
if (context.skipCache) return;
|
|
1054
|
+
if (result === null) return;
|
|
1055
|
+
const query = context.query || {};
|
|
1056
|
+
const key = byQueryKey(config.prefix, model, query, {
|
|
1057
|
+
select: context.select,
|
|
1058
|
+
populate: context.populate
|
|
1059
|
+
});
|
|
1060
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
1061
|
+
try {
|
|
1062
|
+
await config.adapter.set(key, result, ttl);
|
|
1063
|
+
stats.sets++;
|
|
1064
|
+
log(`Cached getByQuery result:`, key);
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
log(`Failed to cache getByQuery:`, e);
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
/**
|
|
1070
|
+
* after:getAll - Cache the result
|
|
1071
|
+
*/
|
|
1072
|
+
repo.on("after:getAll", async (payload) => {
|
|
1073
|
+
const { context, result } = payload;
|
|
1074
|
+
if (context._cacheHit) return;
|
|
1075
|
+
if (context.skipCache) return;
|
|
1076
|
+
const limit = context.limit;
|
|
1077
|
+
if (limit && limit > config.skipIfLargeLimit) return;
|
|
1078
|
+
const collectionVersion = await getVersion();
|
|
1079
|
+
const params = {
|
|
1080
|
+
filters: context.filters,
|
|
1081
|
+
sort: context.sort,
|
|
1082
|
+
page: context.page,
|
|
1083
|
+
limit,
|
|
1084
|
+
after: context.after,
|
|
1085
|
+
select: context.select,
|
|
1086
|
+
populate: context.populate,
|
|
1087
|
+
search: context.search
|
|
1088
|
+
};
|
|
1089
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
1090
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
1091
|
+
try {
|
|
1092
|
+
await config.adapter.set(key, result, ttl);
|
|
1093
|
+
stats.sets++;
|
|
1094
|
+
log(`Cached getAll result:`, key);
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
log(`Failed to cache getAll:`, e);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
/**
|
|
1100
|
+
* after:create - Bump version to invalidate list caches
|
|
1101
|
+
*/
|
|
1102
|
+
repo.on("after:create", async () => {
|
|
1103
|
+
await bumpVersion();
|
|
1104
|
+
});
|
|
1105
|
+
/**
|
|
1106
|
+
* after:createMany - Bump version to invalidate list caches
|
|
1107
|
+
*/
|
|
1108
|
+
repo.on("after:createMany", async () => {
|
|
1109
|
+
await bumpVersion();
|
|
1110
|
+
});
|
|
1111
|
+
/**
|
|
1112
|
+
* after:update - Invalidate by ID and bump version
|
|
1113
|
+
*/
|
|
1114
|
+
repo.on("after:update", async (payload) => {
|
|
1115
|
+
const { context } = payload;
|
|
1116
|
+
const id = String(context.id);
|
|
1117
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
1118
|
+
});
|
|
1119
|
+
/**
|
|
1120
|
+
* after:updateMany - Bump version (can't track individual IDs efficiently)
|
|
1121
|
+
*/
|
|
1122
|
+
repo.on("after:updateMany", async () => {
|
|
1123
|
+
await bumpVersion();
|
|
1124
|
+
});
|
|
1125
|
+
/**
|
|
1126
|
+
* after:delete - Invalidate by ID and bump version
|
|
1127
|
+
*/
|
|
1128
|
+
repo.on("after:delete", async (payload) => {
|
|
1129
|
+
const { context } = payload;
|
|
1130
|
+
const id = String(context.id);
|
|
1131
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
1132
|
+
});
|
|
1133
|
+
/**
|
|
1134
|
+
* after:deleteMany - Bump version
|
|
1135
|
+
*/
|
|
1136
|
+
repo.on("after:deleteMany", async () => {
|
|
1137
|
+
await bumpVersion();
|
|
1138
|
+
});
|
|
1139
|
+
/**
|
|
1140
|
+
* Invalidate cache for a specific document
|
|
1141
|
+
* Use when document was updated outside this service
|
|
1142
|
+
*
|
|
1143
|
+
* @example
|
|
1144
|
+
* await userRepo.invalidateCache('507f1f77bcf86cd799439011');
|
|
1145
|
+
*/
|
|
1146
|
+
repo.invalidateCache = async (id) => {
|
|
1147
|
+
await invalidateById(id);
|
|
1148
|
+
log(`Manual invalidation for ID:`, id);
|
|
1149
|
+
};
|
|
1150
|
+
/**
|
|
1151
|
+
* Invalidate all list caches for this model
|
|
1152
|
+
* Use when bulk changes happened outside this service
|
|
1153
|
+
*
|
|
1154
|
+
* @example
|
|
1155
|
+
* await userRepo.invalidateListCache();
|
|
1156
|
+
*/
|
|
1157
|
+
repo.invalidateListCache = async () => {
|
|
1158
|
+
await bumpVersion();
|
|
1159
|
+
log(`Manual list cache invalidation for ${model}`);
|
|
1160
|
+
};
|
|
1161
|
+
/**
|
|
1162
|
+
* Invalidate ALL cache entries for this model
|
|
1163
|
+
* Nuclear option - use sparingly
|
|
1164
|
+
*
|
|
1165
|
+
* @example
|
|
1166
|
+
* await userRepo.invalidateAllCache();
|
|
1167
|
+
*/
|
|
1168
|
+
repo.invalidateAllCache = async () => {
|
|
1169
|
+
if (config.adapter.clear) try {
|
|
1170
|
+
await config.adapter.clear(modelPattern(config.prefix, model));
|
|
1171
|
+
stats.invalidations++;
|
|
1172
|
+
log(`Full cache invalidation for ${model}`);
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
log(`Failed full cache invalidation for ${model}:`, e);
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
await bumpVersion();
|
|
1178
|
+
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
/**
|
|
1182
|
+
* Get cache statistics for monitoring
|
|
1183
|
+
*
|
|
1184
|
+
* @example
|
|
1185
|
+
* const stats = userRepo.getCacheStats();
|
|
1186
|
+
* console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
|
|
1187
|
+
*/
|
|
1188
|
+
repo.getCacheStats = () => ({ ...stats });
|
|
1189
|
+
/**
|
|
1190
|
+
* Reset cache statistics
|
|
1191
|
+
*/
|
|
1192
|
+
repo.resetCacheStats = () => {
|
|
1193
|
+
stats.hits = 0;
|
|
1194
|
+
stats.misses = 0;
|
|
1195
|
+
stats.sets = 0;
|
|
1196
|
+
stats.invalidations = 0;
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
//#endregion
|
|
1203
|
+
//#region src/plugins/cascade.plugin.ts
|
|
1204
|
+
/**
|
|
1205
|
+
* Cascade Delete Plugin
|
|
1206
|
+
* Automatically deletes related documents when a parent document is deleted
|
|
1207
|
+
*
|
|
1208
|
+
* @example
|
|
1209
|
+
* ```typescript
|
|
1210
|
+
* import mongoose from 'mongoose';
|
|
1211
|
+
* import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
1212
|
+
*
|
|
1213
|
+
* const productRepo = new Repository(Product, [
|
|
1214
|
+
* methodRegistryPlugin(),
|
|
1215
|
+
* cascadePlugin({
|
|
1216
|
+
* relations: [
|
|
1217
|
+
* { model: 'StockEntry', foreignKey: 'product' },
|
|
1218
|
+
* { model: 'StockMovement', foreignKey: 'product' },
|
|
1219
|
+
* ]
|
|
1220
|
+
* })
|
|
1221
|
+
* ]);
|
|
1222
|
+
*
|
|
1223
|
+
* // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
|
|
1224
|
+
* await productRepo.delete(productId);
|
|
1225
|
+
* ```
|
|
1226
|
+
*/
|
|
1227
|
+
/**
|
|
1228
|
+
* Cascade delete plugin
|
|
1229
|
+
*
|
|
1230
|
+
* Deletes related documents after the parent document is deleted.
|
|
1231
|
+
* Works with both hard delete and soft delete scenarios.
|
|
1232
|
+
*
|
|
1233
|
+
* @param options - Cascade configuration
|
|
1234
|
+
* @returns Plugin
|
|
1235
|
+
*/
|
|
1236
|
+
function cascadePlugin(options) {
|
|
1237
|
+
const { relations, parallel = true, logger } = options;
|
|
1238
|
+
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
1239
|
+
return {
|
|
1240
|
+
name: "cascade",
|
|
1241
|
+
apply(repo) {
|
|
1242
|
+
repo.on("after:delete", async (payload) => {
|
|
1243
|
+
const { context } = payload;
|
|
1244
|
+
const deletedId = context.id;
|
|
1245
|
+
if (!deletedId) {
|
|
1246
|
+
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const isSoftDelete = context.softDeleted === true;
|
|
1250
|
+
const cascadeDelete = async (relation) => {
|
|
1251
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
1252
|
+
if (!RelatedModel) {
|
|
1253
|
+
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
1254
|
+
parentModel: context.model,
|
|
1255
|
+
parentId: String(deletedId)
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const query = { [relation.foreignKey]: deletedId };
|
|
1260
|
+
try {
|
|
1261
|
+
if (relation.softDelete ?? isSoftDelete) {
|
|
1262
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
1263
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
1264
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
1265
|
+
}, { session: context.session });
|
|
1266
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
|
|
1267
|
+
parentModel: context.model,
|
|
1268
|
+
parentId: String(deletedId),
|
|
1269
|
+
relatedModel: relation.model,
|
|
1270
|
+
foreignKey: relation.foreignKey,
|
|
1271
|
+
count: updateResult.modifiedCount
|
|
1272
|
+
});
|
|
1273
|
+
} else {
|
|
1274
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
1275
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
|
|
1276
|
+
parentModel: context.model,
|
|
1277
|
+
parentId: String(deletedId),
|
|
1278
|
+
relatedModel: relation.model,
|
|
1279
|
+
foreignKey: relation.foreignKey,
|
|
1280
|
+
count: deleteResult.deletedCount
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
|
|
1285
|
+
parentModel: context.model,
|
|
1286
|
+
parentId: String(deletedId),
|
|
1287
|
+
relatedModel: relation.model,
|
|
1288
|
+
foreignKey: relation.foreignKey,
|
|
1289
|
+
error: error.message
|
|
1290
|
+
});
|
|
1291
|
+
throw error;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
if (parallel) {
|
|
1295
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
|
|
1296
|
+
if (failures.length) {
|
|
1297
|
+
const err = failures[0].reason;
|
|
1298
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
1299
|
+
throw err;
|
|
1300
|
+
}
|
|
1301
|
+
} else for (const relation of relations) await cascadeDelete(relation);
|
|
1302
|
+
});
|
|
1303
|
+
repo.on("after:deleteMany", async (payload) => {
|
|
1304
|
+
const { context, result } = payload;
|
|
1305
|
+
const query = context.query;
|
|
1306
|
+
if (!query || Object.keys(query).length === 0) {
|
|
1307
|
+
logger?.warn?.("Cascade deleteMany skipped: empty query", { model: context.model });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
logger?.warn?.("Cascade deleteMany: use before:deleteMany hook for complete cascade support", { model: context.model });
|
|
1311
|
+
});
|
|
1312
|
+
repo.on("before:deleteMany", async (context) => {
|
|
1313
|
+
const query = context.query;
|
|
1314
|
+
if (!query || Object.keys(query).length === 0) return;
|
|
1315
|
+
context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
|
|
1316
|
+
});
|
|
1317
|
+
const originalAfterDeleteMany = repo._hooks.get("after:deleteMany") || [];
|
|
1318
|
+
repo._hooks.set("after:deleteMany", [...originalAfterDeleteMany, async (payload) => {
|
|
1319
|
+
const { context } = payload;
|
|
1320
|
+
const ids = context._cascadeIds;
|
|
1321
|
+
if (!ids || ids.length === 0) return;
|
|
1322
|
+
const isSoftDelete = context.softDeleted === true;
|
|
1323
|
+
const cascadeDeleteMany = async (relation) => {
|
|
1324
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
1325
|
+
if (!RelatedModel) {
|
|
1326
|
+
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const query = { [relation.foreignKey]: { $in: ids } };
|
|
1330
|
+
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
1331
|
+
try {
|
|
1332
|
+
if (shouldSoftDelete) {
|
|
1333
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
1334
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
1335
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
1336
|
+
}, { session: context.session });
|
|
1337
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
|
|
1338
|
+
parentModel: context.model,
|
|
1339
|
+
parentCount: ids.length,
|
|
1340
|
+
relatedModel: relation.model,
|
|
1341
|
+
foreignKey: relation.foreignKey,
|
|
1342
|
+
count: updateResult.modifiedCount
|
|
1343
|
+
});
|
|
1344
|
+
} else {
|
|
1345
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
1346
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
|
|
1347
|
+
parentModel: context.model,
|
|
1348
|
+
parentCount: ids.length,
|
|
1349
|
+
relatedModel: relation.model,
|
|
1350
|
+
foreignKey: relation.foreignKey,
|
|
1351
|
+
count: deleteResult.deletedCount
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
|
|
1356
|
+
parentModel: context.model,
|
|
1357
|
+
relatedModel: relation.model,
|
|
1358
|
+
foreignKey: relation.foreignKey,
|
|
1359
|
+
error: error.message
|
|
1360
|
+
});
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
if (parallel) {
|
|
1365
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
|
|
1366
|
+
if (failures.length) {
|
|
1367
|
+
const err = failures[0].reason;
|
|
1368
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
1369
|
+
throw err;
|
|
1370
|
+
}
|
|
1371
|
+
} else for (const relation of relations) await cascadeDeleteMany(relation);
|
|
1372
|
+
}]);
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/plugins/multi-tenant.plugin.ts
|
|
1379
|
+
function multiTenantPlugin(options = {}) {
|
|
1380
|
+
const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext } = options;
|
|
1381
|
+
const readOps = [
|
|
1382
|
+
"getById",
|
|
1383
|
+
"getByQuery",
|
|
1384
|
+
"getAll",
|
|
1385
|
+
"aggregatePaginate",
|
|
1386
|
+
"lookupPopulate"
|
|
1387
|
+
];
|
|
1388
|
+
const writeOps = [
|
|
1389
|
+
"create",
|
|
1390
|
+
"createMany",
|
|
1391
|
+
"update",
|
|
1392
|
+
"delete"
|
|
1393
|
+
];
|
|
1394
|
+
const allOps = [...readOps, ...writeOps];
|
|
1395
|
+
return {
|
|
1396
|
+
name: "multi-tenant",
|
|
1397
|
+
apply(repo) {
|
|
1398
|
+
for (const op of allOps) {
|
|
1399
|
+
if (skipOperations.includes(op)) continue;
|
|
1400
|
+
repo.on(`before:${op}`, (context) => {
|
|
1401
|
+
if (skipWhen?.(context, op)) return;
|
|
1402
|
+
let tenantId = context[contextKey];
|
|
1403
|
+
if (!tenantId && resolveContext) {
|
|
1404
|
+
tenantId = resolveContext();
|
|
1405
|
+
if (tenantId) context[contextKey] = tenantId;
|
|
1406
|
+
}
|
|
1407
|
+
if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
|
|
1408
|
+
if (!tenantId) return;
|
|
1409
|
+
if (readOps.includes(op)) if (op === "getAll" || op === "aggregatePaginate" || op === "lookupPopulate") context.filters = {
|
|
1410
|
+
...context.filters,
|
|
1411
|
+
[tenantField]: tenantId
|
|
1412
|
+
};
|
|
1413
|
+
else context.query = {
|
|
1414
|
+
...context.query,
|
|
1415
|
+
[tenantField]: tenantId
|
|
1416
|
+
};
|
|
1417
|
+
if (op === "create" && context.data) context.data[tenantField] = tenantId;
|
|
1418
|
+
if (op === "createMany" && context.dataArray) for (const doc of context.dataArray) doc[tenantField] = tenantId;
|
|
1419
|
+
if (op === "update" || op === "delete") context.query = {
|
|
1420
|
+
...context.query,
|
|
1421
|
+
[tenantField]: tenantId
|
|
1422
|
+
};
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
//#endregion
|
|
1430
|
+
//#region src/plugins/observability.plugin.ts
|
|
1431
|
+
const DEFAULT_OPS = [
|
|
1432
|
+
"create",
|
|
1433
|
+
"createMany",
|
|
1434
|
+
"update",
|
|
1435
|
+
"delete",
|
|
1436
|
+
"getById",
|
|
1437
|
+
"getByQuery",
|
|
1438
|
+
"getAll",
|
|
1439
|
+
"aggregatePaginate",
|
|
1440
|
+
"lookupPopulate"
|
|
1441
|
+
];
|
|
1442
|
+
const timers = /* @__PURE__ */ new WeakMap();
|
|
1443
|
+
function observabilityPlugin(options) {
|
|
1444
|
+
const { onMetric, slowThresholdMs } = options;
|
|
1445
|
+
const ops = options.operations ?? DEFAULT_OPS;
|
|
1446
|
+
return {
|
|
1447
|
+
name: "observability",
|
|
1448
|
+
apply(repo) {
|
|
1449
|
+
for (const op of ops) {
|
|
1450
|
+
repo.on(`before:${op}`, (context) => {
|
|
1451
|
+
timers.set(context, performance.now());
|
|
1452
|
+
});
|
|
1453
|
+
repo.on(`after:${op}`, ({ context }) => {
|
|
1454
|
+
const start = timers.get(context);
|
|
1455
|
+
if (start == null) return;
|
|
1456
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
1457
|
+
timers.delete(context);
|
|
1458
|
+
if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
|
|
1459
|
+
onMetric({
|
|
1460
|
+
operation: op,
|
|
1461
|
+
model: context.model || repo.model,
|
|
1462
|
+
durationMs,
|
|
1463
|
+
success: true,
|
|
1464
|
+
startedAt: new Date(Date.now() - durationMs),
|
|
1465
|
+
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
1466
|
+
organizationId: context.organizationId?.toString()
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
repo.on(`error:${op}`, ({ context, error }) => {
|
|
1470
|
+
const start = timers.get(context);
|
|
1471
|
+
if (start == null) return;
|
|
1472
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
1473
|
+
timers.delete(context);
|
|
1474
|
+
onMetric({
|
|
1475
|
+
operation: op,
|
|
1476
|
+
model: context.model || repo.model,
|
|
1477
|
+
durationMs,
|
|
1478
|
+
success: false,
|
|
1479
|
+
error: error.message,
|
|
1480
|
+
startedAt: new Date(Date.now() - durationMs),
|
|
1481
|
+
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
1482
|
+
organizationId: context.organizationId?.toString()
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
//#endregion
|
|
1491
|
+
//#region src/plugins/elastic.plugin.ts
|
|
1492
|
+
function elasticSearchPlugin(options) {
|
|
1493
|
+
return {
|
|
1494
|
+
name: "elastic-search",
|
|
1495
|
+
apply(repo) {
|
|
1496
|
+
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
1497
|
+
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
1498
|
+
const { client, index, idField = "_id" } = options;
|
|
1499
|
+
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
1500
|
+
const from = Math.max(searchOptions.from || 0, 0);
|
|
1501
|
+
const esResponse = await client.search({
|
|
1502
|
+
index,
|
|
1503
|
+
body: {
|
|
1504
|
+
query: searchQuery,
|
|
1505
|
+
size: limit,
|
|
1506
|
+
from
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
|
|
1510
|
+
if (hits.length === 0) return {
|
|
1511
|
+
docs: [],
|
|
1512
|
+
total: 0,
|
|
1513
|
+
limit,
|
|
1514
|
+
from
|
|
1515
|
+
};
|
|
1516
|
+
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
1517
|
+
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
1518
|
+
const docsOrder = /* @__PURE__ */ new Map();
|
|
1519
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1520
|
+
const ids = [];
|
|
1521
|
+
hits.forEach((hit, idx) => {
|
|
1522
|
+
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
1523
|
+
if (docId) {
|
|
1524
|
+
const strId = String(docId);
|
|
1525
|
+
docsOrder.set(strId, idx);
|
|
1526
|
+
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
1527
|
+
ids.push(strId);
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
if (ids.length === 0) return {
|
|
1531
|
+
docs: [],
|
|
1532
|
+
total,
|
|
1533
|
+
limit,
|
|
1534
|
+
from
|
|
1535
|
+
};
|
|
1536
|
+
const mongoQuery = this.Model.find({ _id: { $in: ids } });
|
|
1537
|
+
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
1538
|
+
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
1539
|
+
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
1540
|
+
return {
|
|
1541
|
+
docs: (await mongoQuery.exec()).sort((a, b) => {
|
|
1542
|
+
const aId = String(a._id);
|
|
1543
|
+
const bId = String(b._id);
|
|
1544
|
+
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
1545
|
+
}).map((doc) => {
|
|
1546
|
+
const strId = String(doc._id);
|
|
1547
|
+
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
1548
|
+
...doc,
|
|
1549
|
+
_score: scores.get(strId)
|
|
1550
|
+
};
|
|
1551
|
+
return doc;
|
|
1552
|
+
}),
|
|
1553
|
+
total,
|
|
1554
|
+
limit,
|
|
1555
|
+
from
|
|
1556
|
+
};
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
//#endregion
|
|
1563
|
+
//#region src/plugins/custom-id.plugin.ts
|
|
1564
|
+
/**
|
|
1565
|
+
* Custom ID Plugin
|
|
1566
|
+
*
|
|
1567
|
+
* Generates custom document IDs using pluggable generators.
|
|
1568
|
+
* Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
|
|
1569
|
+
* date-partitioned sequences, and fully custom generators.
|
|
1570
|
+
*
|
|
1571
|
+
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
1572
|
+
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
1573
|
+
*
|
|
1574
|
+
* @example Basic sequential counter
|
|
1575
|
+
* ```typescript
|
|
1576
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
1577
|
+
* customIdPlugin({
|
|
1578
|
+
* field: 'invoiceNumber',
|
|
1579
|
+
* generator: sequentialId({
|
|
1580
|
+
* prefix: 'INV',
|
|
1581
|
+
* model: InvoiceModel,
|
|
1582
|
+
* }),
|
|
1583
|
+
* }),
|
|
1584
|
+
* ]);
|
|
1585
|
+
*
|
|
1586
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
1587
|
+
* // inv.invoiceNumber → "INV-0001"
|
|
1588
|
+
* ```
|
|
1589
|
+
*
|
|
1590
|
+
* @example Date-partitioned counter (resets monthly)
|
|
1591
|
+
* ```typescript
|
|
1592
|
+
* const billRepo = new Repository(BillModel, [
|
|
1593
|
+
* customIdPlugin({
|
|
1594
|
+
* field: 'billNumber',
|
|
1595
|
+
* generator: dateSequentialId({
|
|
1596
|
+
* prefix: 'BILL',
|
|
1597
|
+
* model: BillModel,
|
|
1598
|
+
* partition: 'monthly',
|
|
1599
|
+
* separator: '-',
|
|
1600
|
+
* padding: 4,
|
|
1601
|
+
* }),
|
|
1602
|
+
* }),
|
|
1603
|
+
* ]);
|
|
1604
|
+
*
|
|
1605
|
+
* const bill = await billRepo.create({ total: 250 });
|
|
1606
|
+
* // bill.billNumber → "BILL-2026-02-0001"
|
|
1607
|
+
* ```
|
|
1608
|
+
*
|
|
1609
|
+
* @example Custom generator function
|
|
1610
|
+
* ```typescript
|
|
1611
|
+
* const orderRepo = new Repository(OrderModel, [
|
|
1612
|
+
* customIdPlugin({
|
|
1613
|
+
* field: 'orderRef',
|
|
1614
|
+
* generator: async (context) => {
|
|
1615
|
+
* const region = context.data?.region || 'US';
|
|
1616
|
+
* const seq = await getNextSequence('orders');
|
|
1617
|
+
* return `ORD-${region}-${seq}`;
|
|
1618
|
+
* },
|
|
1619
|
+
* }),
|
|
1620
|
+
* ]);
|
|
1621
|
+
* ```
|
|
1622
|
+
*/
|
|
1623
|
+
/** Schema for the internal counters collection */
|
|
1624
|
+
const counterSchema = new mongoose.Schema({
|
|
1625
|
+
_id: {
|
|
1626
|
+
type: String,
|
|
1627
|
+
required: true
|
|
1628
|
+
},
|
|
1629
|
+
seq: {
|
|
1630
|
+
type: Number,
|
|
1631
|
+
default: 0
|
|
1632
|
+
}
|
|
1633
|
+
}, {
|
|
1634
|
+
collection: "_mongokit_counters",
|
|
1635
|
+
versionKey: false
|
|
1636
|
+
});
|
|
1637
|
+
/**
|
|
1638
|
+
* Get or create the Counter model.
|
|
1639
|
+
* Lazy-init to avoid model registration errors if mongoose isn't connected yet.
|
|
1640
|
+
*/
|
|
1641
|
+
function getCounterModel() {
|
|
1642
|
+
if (mongoose.models._MongoKitCounter) return mongoose.models._MongoKitCounter;
|
|
1643
|
+
return mongoose.model("_MongoKitCounter", counterSchema);
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Atomically increment and return the next sequence value for a given key.
|
|
1647
|
+
* Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
|
|
1648
|
+
* heavy concurrency.
|
|
1649
|
+
*
|
|
1650
|
+
* @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
|
|
1651
|
+
* @param increment - Value to increment by (default: 1)
|
|
1652
|
+
* @returns The next sequence number (after increment)
|
|
1653
|
+
*
|
|
1654
|
+
* @example
|
|
1655
|
+
* const seq = await getNextSequence('invoices');
|
|
1656
|
+
* // First call → 1, second → 2, ...
|
|
1657
|
+
*
|
|
1658
|
+
* @example Batch increment for createMany
|
|
1659
|
+
* const startSeq = await getNextSequence('invoices', 5);
|
|
1660
|
+
* // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
|
|
1661
|
+
*/
|
|
1662
|
+
async function getNextSequence(counterKey, increment = 1) {
|
|
1663
|
+
return (await getCounterModel().findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
|
|
1664
|
+
upsert: true,
|
|
1665
|
+
returnDocument: "after"
|
|
1666
|
+
})).seq;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Generator: Simple sequential counter.
|
|
1670
|
+
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
1671
|
+
*
|
|
1672
|
+
* Uses atomic MongoDB counters — safe under concurrency.
|
|
1673
|
+
*
|
|
1674
|
+
* @example
|
|
1675
|
+
* ```typescript
|
|
1676
|
+
* customIdPlugin({
|
|
1677
|
+
* field: 'invoiceNumber',
|
|
1678
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
1679
|
+
* })
|
|
1680
|
+
* ```
|
|
1681
|
+
*/
|
|
1682
|
+
function sequentialId(options) {
|
|
1683
|
+
const { prefix, model, padding = 4, separator = "-", counterKey } = options;
|
|
1684
|
+
const key = counterKey || model.modelName;
|
|
1685
|
+
return async (_context) => {
|
|
1686
|
+
const seq = await getNextSequence(key);
|
|
1687
|
+
return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Generator: Date-partitioned sequential counter.
|
|
1692
|
+
* Counter resets per period — great for invoice/bill numbering.
|
|
1693
|
+
*
|
|
1694
|
+
* Produces IDs like:
|
|
1695
|
+
* - yearly: `BILL-2026-0001`
|
|
1696
|
+
* - monthly: `BILL-2026-02-0001`
|
|
1697
|
+
* - daily: `BILL-2026-02-20-0001`
|
|
1698
|
+
*
|
|
1699
|
+
* @example
|
|
1700
|
+
* ```typescript
|
|
1701
|
+
* customIdPlugin({
|
|
1702
|
+
* field: 'billNumber',
|
|
1703
|
+
* generator: dateSequentialId({
|
|
1704
|
+
* prefix: 'BILL',
|
|
1705
|
+
* model: BillModel,
|
|
1706
|
+
* partition: 'monthly',
|
|
1707
|
+
* }),
|
|
1708
|
+
* })
|
|
1709
|
+
* ```
|
|
1710
|
+
*/
|
|
1711
|
+
function dateSequentialId(options) {
|
|
1712
|
+
const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
|
|
1713
|
+
return async (_context) => {
|
|
1714
|
+
const now = /* @__PURE__ */ new Date();
|
|
1715
|
+
const year = String(now.getFullYear());
|
|
1716
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
1717
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
1718
|
+
let datePart;
|
|
1719
|
+
let counterKey;
|
|
1720
|
+
switch (partition) {
|
|
1721
|
+
case "yearly":
|
|
1722
|
+
datePart = year;
|
|
1723
|
+
counterKey = `${model.modelName}:${year}`;
|
|
1724
|
+
break;
|
|
1725
|
+
case "daily":
|
|
1726
|
+
datePart = `${year}${separator}${month}${separator}${day}`;
|
|
1727
|
+
counterKey = `${model.modelName}:${year}-${month}-${day}`;
|
|
1728
|
+
break;
|
|
1729
|
+
default:
|
|
1730
|
+
datePart = `${year}${separator}${month}`;
|
|
1731
|
+
counterKey = `${model.modelName}:${year}-${month}`;
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
const seq = await getNextSequence(counterKey);
|
|
1735
|
+
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Generator: Prefix + random alphanumeric suffix.
|
|
1740
|
+
* Does NOT require a database round-trip — purely in-memory.
|
|
1741
|
+
*
|
|
1742
|
+
* Produces IDs like: `USR_a7b3xk9m2p1q`
|
|
1743
|
+
*
|
|
1744
|
+
* Good for: user-facing IDs where ordering doesn't matter.
|
|
1745
|
+
* Not suitable for sequential numbering.
|
|
1746
|
+
*
|
|
1747
|
+
* @example
|
|
1748
|
+
* ```typescript
|
|
1749
|
+
* customIdPlugin({
|
|
1750
|
+
* field: 'publicId',
|
|
1751
|
+
* generator: prefixedId({ prefix: 'USR', length: 10 }),
|
|
1752
|
+
* })
|
|
1753
|
+
* ```
|
|
1754
|
+
*/
|
|
1755
|
+
function prefixedId(options) {
|
|
1756
|
+
const { prefix, separator = "_", length = 12 } = options;
|
|
1757
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1758
|
+
return (_context) => {
|
|
1759
|
+
let result = "";
|
|
1760
|
+
const bytes = new Uint8Array(length);
|
|
1761
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
1762
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1763
|
+
for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
|
|
1764
|
+
} else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
|
|
1765
|
+
return `${prefix}${separator}${result}`;
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Custom ID plugin — injects generated IDs into documents before creation.
|
|
1770
|
+
*
|
|
1771
|
+
* @param options - Configuration for ID generation
|
|
1772
|
+
* @returns Plugin instance
|
|
1773
|
+
*
|
|
1774
|
+
* @example
|
|
1775
|
+
* ```typescript
|
|
1776
|
+
* import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
|
|
1777
|
+
*
|
|
1778
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
1779
|
+
* customIdPlugin({
|
|
1780
|
+
* field: 'invoiceNumber',
|
|
1781
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
1782
|
+
* }),
|
|
1783
|
+
* ]);
|
|
1784
|
+
*
|
|
1785
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
1786
|
+
* console.log(inv.invoiceNumber); // "INV-0001"
|
|
1787
|
+
* ```
|
|
1788
|
+
*/
|
|
1789
|
+
function customIdPlugin(options) {
|
|
1790
|
+
const fieldName = options.field || "customId";
|
|
1791
|
+
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
1792
|
+
return {
|
|
1793
|
+
name: "custom-id",
|
|
1794
|
+
apply(repo) {
|
|
1795
|
+
repo.on("before:create", async (context) => {
|
|
1796
|
+
if (!context.data) return;
|
|
1797
|
+
if (generateOnlyIfEmpty && context.data[fieldName]) return;
|
|
1798
|
+
context.data[fieldName] = await options.generator(context);
|
|
1799
|
+
});
|
|
1800
|
+
repo.on("before:createMany", async (context) => {
|
|
1801
|
+
if (!context.dataArray) return;
|
|
1802
|
+
const docsNeedingIds = [];
|
|
1803
|
+
for (const doc of context.dataArray) {
|
|
1804
|
+
if (generateOnlyIfEmpty && doc[fieldName]) continue;
|
|
1805
|
+
docsNeedingIds.push(doc);
|
|
1806
|
+
}
|
|
1807
|
+
if (docsNeedingIds.length === 0) return;
|
|
1808
|
+
for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
|
|
1809
|
+
...context,
|
|
1810
|
+
data: doc
|
|
1811
|
+
});
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
//#endregion
|
|
1818
|
+
export { auditLogPlugin as C, softDeletePlugin as S, fieldFilterPlugin as T, immutableField as _, sequentialId as a, validationChainPlugin as b, multiTenantPlugin as c, subdocumentPlugin as d, aggregateHelpersPlugin as f, blockIf as g, autoInject as h, prefixedId as i, cascadePlugin as l, mongoOperationsPlugin as m, dateSequentialId as n, elasticSearchPlugin as o, batchOperationsPlugin as p, getNextSequence as r, observabilityPlugin as s, customIdPlugin as t, cachePlugin as u, requireField as v, timestampPlugin as w, methodRegistryPlugin as x, uniqueField as y };
|