@classytic/mongokit 3.3.2 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -7
- package/dist/PaginationEngine-nY04eGUM.mjs +290 -0
- package/dist/actions/index.d.mts +2 -9
- package/dist/actions/index.mjs +3 -5
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +3 -3
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/{limits-s1-d8rWb.mjs → cursor-CHToazHy.mjs} +122 -171
- package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
- package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-reyDRzXf.mjs} +110 -112
- package/dist/{aggregate-BkOG9qwr.d.mts → index-BuoZIZ15.d.mts} +132 -129
- package/dist/index.d.mts +549 -543
- package/dist/index.mjs +33 -101
- package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-B6Qyl8BK.mjs} +13 -12
- package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-RX9YfJLu.d.mts} +24 -17
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +2 -209
- package/dist/plugins/index.d.mts +1 -2
- package/dist/plugins/index.mjs +2 -3
- package/dist/sort-C-BJEWUZ.mjs +57 -0
- package/dist/{types-pVY0w1Pp.d.mts → types-COINbsdL.d.mts} +57 -27
- package/dist/{aggregate-BClp040M.mjs → update-DGKMmBgG.mjs} +575 -565
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +4 -5
- package/dist/{custom-id.plugin-BJ3FSnzt.d.mts → validation-chain.plugin-BNoaKDOm.d.mts} +832 -832
- package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-da3fOo8A.mjs} +2410 -2246
- package/package.json +11 -6
- package/dist/chunk-DQk6qfdC.mjs +0 -18
|
@@ -1,101 +1,715 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
import { _ as
|
|
3
|
-
import { PaginationEngine } from "./
|
|
4
|
-
import { a as
|
|
1
|
+
import { a as warn, i as debug, n as parseDuplicateKeyError, t as createError } from "./error-Bpbi_NKo.mjs";
|
|
2
|
+
import { _ as LookupBuilder, a as getById, d as create, f as createMany, g as distinct, i as exists, l as deleteById, m as upsert, o as getByQuery, r as count, s as getOrCreate, t as update } from "./update-DGKMmBgG.mjs";
|
|
3
|
+
import { t as PaginationEngine } from "./PaginationEngine-nY04eGUM.mjs";
|
|
4
|
+
import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-reyDRzXf.mjs";
|
|
5
5
|
import mongoose from "mongoose";
|
|
6
|
-
|
|
7
|
-
//#region src/query/AggregationBuilder.ts
|
|
6
|
+
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Aggregate helpers plugin
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const repo = new Repository(Model, [
|
|
12
|
+
* methodRegistryPlugin(),
|
|
13
|
+
* aggregateHelpersPlugin(),
|
|
14
|
+
* ]);
|
|
15
|
+
*
|
|
16
|
+
* const groups = await repo.groupBy('category');
|
|
17
|
+
* const total = await repo.sum('amount', { status: 'completed' });
|
|
11
18
|
*/
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
function aggregateHelpersPlugin() {
|
|
20
|
+
return {
|
|
21
|
+
name: "aggregate-helpers",
|
|
22
|
+
apply(repo) {
|
|
23
|
+
if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
|
|
24
|
+
/**
|
|
25
|
+
* Group by field
|
|
26
|
+
*/
|
|
27
|
+
repo.registerMethod("groupBy", async function(field, options = {}) {
|
|
28
|
+
const pipeline = [{ $group: {
|
|
29
|
+
_id: `$${field}`,
|
|
30
|
+
count: { $sum: 1 }
|
|
31
|
+
} }, { $sort: { count: -1 } }];
|
|
32
|
+
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
33
|
+
return this.aggregate(pipeline, options);
|
|
34
|
+
});
|
|
35
|
+
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
36
|
+
const pipeline = [{ $match: query }, { $group: {
|
|
37
|
+
_id: null,
|
|
38
|
+
[resultKey]: { [operator]: `$${field}` }
|
|
39
|
+
} }];
|
|
40
|
+
return (await this.aggregate(pipeline, options))[0]?.[resultKey] || 0;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Sum field values
|
|
44
|
+
*/
|
|
45
|
+
repo.registerMethod("sum", async function(field, query = {}, options = {}) {
|
|
46
|
+
return aggregateOperation.call(this, field, "$sum", "total", query, options);
|
|
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
|
+
* Get minimum value
|
|
56
|
+
*/
|
|
57
|
+
repo.registerMethod("min", async function(field, query = {}, options = {}) {
|
|
58
|
+
return aggregateOperation.call(this, field, "$min", "min", query, options);
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Get maximum value
|
|
62
|
+
*/
|
|
63
|
+
repo.registerMethod("max", async function(field, query = {}, options = {}) {
|
|
64
|
+
return aggregateOperation.call(this, field, "$max", "max", query, options);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
};
|
|
18
68
|
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/plugins/audit-log.plugin.ts
|
|
19
71
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
72
|
+
* Audit log plugin that logs all repository operations
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const repo = new Repository(Model, [auditLogPlugin(console)]);
|
|
22
76
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
77
|
+
function auditLogPlugin(logger) {
|
|
78
|
+
return {
|
|
79
|
+
name: "auditLog",
|
|
80
|
+
apply(repo) {
|
|
81
|
+
repo.on("after:create", ({ context, result }) => {
|
|
82
|
+
logger?.info?.("Document created", {
|
|
83
|
+
model: context.model || repo.model,
|
|
84
|
+
id: result?._id,
|
|
85
|
+
userId: context.user?._id || context.user?.id,
|
|
86
|
+
organizationId: context.organizationId
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
repo.on("after:update", ({ context, result }) => {
|
|
90
|
+
logger?.info?.("Document updated", {
|
|
91
|
+
model: context.model || repo.model,
|
|
92
|
+
id: context.id || result?._id,
|
|
93
|
+
userId: context.user?._id || context.user?.id,
|
|
94
|
+
organizationId: context.organizationId
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
repo.on("after:delete", ({ context }) => {
|
|
98
|
+
logger?.info?.("Document deleted", {
|
|
99
|
+
model: context.model || repo.model,
|
|
100
|
+
id: context.id,
|
|
101
|
+
userId: context.user?._id || context.user?.id,
|
|
102
|
+
organizationId: context.organizationId
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
repo.on("error:create", ({ context, error }) => {
|
|
106
|
+
logger?.error?.("Create failed", {
|
|
107
|
+
model: context.model || repo.model,
|
|
108
|
+
error: error.message,
|
|
109
|
+
userId: context.user?._id || context.user?.id
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
repo.on("error:update", ({ context, error }) => {
|
|
113
|
+
logger?.error?.("Update failed", {
|
|
114
|
+
model: context.model || repo.model,
|
|
115
|
+
id: context.id,
|
|
116
|
+
error: error.message,
|
|
117
|
+
userId: context.user?._id || context.user?.id
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
repo.on("error:delete", ({ context, error }) => {
|
|
121
|
+
logger?.error?.("Delete failed", {
|
|
122
|
+
model: context.model || repo.model,
|
|
123
|
+
id: context.id,
|
|
124
|
+
error: error.message,
|
|
125
|
+
userId: context.user?._id || context.user?.id
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/plugins/audit-trail.plugin.ts
|
|
133
|
+
/**
|
|
134
|
+
* Audit Trail Plugin
|
|
135
|
+
*
|
|
136
|
+
* Persists operation audit entries to a MongoDB collection.
|
|
137
|
+
* Fire-and-forget: writes happen async and never block or fail the main operation.
|
|
138
|
+
*
|
|
139
|
+
* Features:
|
|
140
|
+
* - Tracks create, update, delete operations
|
|
141
|
+
* - Field-level change tracking (before/after diff on updates)
|
|
142
|
+
* - TTL auto-cleanup via MongoDB TTL index
|
|
143
|
+
* - Custom metadata per entry (IP, user-agent, etc.)
|
|
144
|
+
* - Shared `audit_trails` collection across all models
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* const repo = new Repository(Job, [
|
|
149
|
+
* auditTrailPlugin({
|
|
150
|
+
* operations: ['create', 'update', 'delete'],
|
|
151
|
+
* trackChanges: true,
|
|
152
|
+
* ttlDays: 90,
|
|
153
|
+
* metadata: (context) => ({
|
|
154
|
+
* ip: context.req?.ip,
|
|
155
|
+
* }),
|
|
156
|
+
* }),
|
|
157
|
+
* ]);
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
const modelCache = /* @__PURE__ */ new Map();
|
|
161
|
+
function getAuditModel(collectionName, ttlDays) {
|
|
162
|
+
const existing = modelCache.get(collectionName);
|
|
163
|
+
if (existing) return existing;
|
|
164
|
+
const schema = new mongoose.Schema({
|
|
165
|
+
model: {
|
|
166
|
+
type: String,
|
|
167
|
+
required: true,
|
|
168
|
+
index: true
|
|
169
|
+
},
|
|
170
|
+
operation: {
|
|
171
|
+
type: String,
|
|
172
|
+
required: true,
|
|
173
|
+
enum: [
|
|
174
|
+
"create",
|
|
175
|
+
"update",
|
|
176
|
+
"delete"
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
documentId: {
|
|
180
|
+
type: mongoose.Schema.Types.Mixed,
|
|
181
|
+
required: true,
|
|
182
|
+
index: true
|
|
183
|
+
},
|
|
184
|
+
userId: {
|
|
185
|
+
type: mongoose.Schema.Types.Mixed,
|
|
186
|
+
index: true
|
|
187
|
+
},
|
|
188
|
+
orgId: {
|
|
189
|
+
type: mongoose.Schema.Types.Mixed,
|
|
190
|
+
index: true
|
|
191
|
+
},
|
|
192
|
+
changes: { type: mongoose.Schema.Types.Mixed },
|
|
193
|
+
document: { type: mongoose.Schema.Types.Mixed },
|
|
194
|
+
metadata: { type: mongoose.Schema.Types.Mixed },
|
|
195
|
+
timestamp: {
|
|
196
|
+
type: Date,
|
|
197
|
+
default: Date.now,
|
|
198
|
+
index: true
|
|
199
|
+
}
|
|
200
|
+
}, {
|
|
201
|
+
collection: collectionName,
|
|
202
|
+
versionKey: false
|
|
203
|
+
});
|
|
204
|
+
schema.index({
|
|
205
|
+
model: 1,
|
|
206
|
+
documentId: 1,
|
|
207
|
+
timestamp: -1
|
|
208
|
+
});
|
|
209
|
+
schema.index({
|
|
210
|
+
orgId: 1,
|
|
211
|
+
userId: 1,
|
|
212
|
+
timestamp: -1
|
|
213
|
+
});
|
|
214
|
+
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
215
|
+
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
216
|
+
schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
|
|
37
217
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
218
|
+
const modelName = `AuditTrail_${collectionName}`;
|
|
219
|
+
const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
|
|
220
|
+
modelCache.set(collectionName, model);
|
|
221
|
+
return model;
|
|
222
|
+
}
|
|
223
|
+
/** Compute field-level diff between previous and updated document */
|
|
224
|
+
function computeChanges(prev, next, excludeFields) {
|
|
225
|
+
const changes = {};
|
|
226
|
+
const exclude = new Set(excludeFields);
|
|
227
|
+
for (const key of Object.keys(next)) {
|
|
228
|
+
if (exclude.has(key)) continue;
|
|
229
|
+
if (key === "_id" || key === "__v" || key === "updatedAt") continue;
|
|
230
|
+
const prevVal = prev[key];
|
|
231
|
+
const nextVal = next[key];
|
|
232
|
+
if (!deepEqual(prevVal, nextVal)) changes[key] = {
|
|
233
|
+
from: prevVal,
|
|
234
|
+
to: nextVal
|
|
45
235
|
};
|
|
46
236
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
237
|
+
return Object.keys(changes).length > 0 ? changes : void 0;
|
|
238
|
+
}
|
|
239
|
+
/** Simple deep equality check for audit diffing */
|
|
240
|
+
function deepEqual(a, b) {
|
|
241
|
+
if (a === b) return true;
|
|
242
|
+
if (a == null && b == null) return true;
|
|
243
|
+
if (a == null || b == null) return false;
|
|
244
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
245
|
+
const aStr = a.toString?.();
|
|
246
|
+
const bStr = b.toString?.();
|
|
247
|
+
if (aStr && bStr && aStr === bStr) return true;
|
|
248
|
+
}
|
|
249
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
250
|
+
try {
|
|
251
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Extract user ID from context */
|
|
257
|
+
function getUserId(context) {
|
|
258
|
+
return context.user?._id || context.user?.id;
|
|
259
|
+
}
|
|
260
|
+
/** Fire-and-forget: write audit entry, never throw */
|
|
261
|
+
function writeAudit(AuditModel, entry) {
|
|
262
|
+
Promise.resolve().then(() => {
|
|
263
|
+
AuditModel.create({
|
|
264
|
+
...entry,
|
|
265
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
266
|
+
}).catch((err) => {
|
|
267
|
+
warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const snapshots = /* @__PURE__ */ new WeakMap();
|
|
272
|
+
function auditTrailPlugin(options = {}) {
|
|
273
|
+
const { operations = [
|
|
274
|
+
"create",
|
|
275
|
+
"update",
|
|
276
|
+
"delete"
|
|
277
|
+
], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
|
|
278
|
+
const opsSet = new Set(operations);
|
|
279
|
+
return {
|
|
280
|
+
name: "auditTrail",
|
|
281
|
+
apply(repo) {
|
|
282
|
+
const AuditModel = getAuditModel(collectionName, ttlDays);
|
|
283
|
+
if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
|
|
284
|
+
const doc = toPlainObject(result);
|
|
285
|
+
writeAudit(AuditModel, {
|
|
286
|
+
model: context.model || repo.model,
|
|
287
|
+
operation: "create",
|
|
288
|
+
documentId: doc?._id,
|
|
289
|
+
userId: getUserId(context),
|
|
290
|
+
orgId: context.organizationId,
|
|
291
|
+
document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
|
|
292
|
+
metadata: metadata?.(context)
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
if (opsSet.has("update")) {
|
|
296
|
+
if (trackChanges) repo.on("before:update", async (context) => {
|
|
297
|
+
if (!context.id) return;
|
|
298
|
+
try {
|
|
299
|
+
const prev = await repo.Model.findById(context.id).lean();
|
|
300
|
+
if (prev) snapshots.set(context, prev);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
repo.on("after:update", ({ context, result }) => {
|
|
306
|
+
const doc = result;
|
|
307
|
+
let changes;
|
|
308
|
+
if (trackChanges) {
|
|
309
|
+
const prev = snapshots.get(context);
|
|
310
|
+
if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
|
|
311
|
+
snapshots.delete(context);
|
|
312
|
+
}
|
|
313
|
+
writeAudit(AuditModel, {
|
|
314
|
+
model: context.model || repo.model,
|
|
315
|
+
operation: "update",
|
|
316
|
+
documentId: context.id || doc?._id,
|
|
317
|
+
userId: getUserId(context),
|
|
318
|
+
orgId: context.organizationId,
|
|
319
|
+
changes,
|
|
320
|
+
metadata: metadata?.(context)
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
|
|
325
|
+
writeAudit(AuditModel, {
|
|
326
|
+
model: context.model || repo.model,
|
|
327
|
+
operation: "delete",
|
|
328
|
+
documentId: context.id,
|
|
329
|
+
userId: getUserId(context),
|
|
330
|
+
orgId: context.organizationId,
|
|
331
|
+
metadata: metadata?.(context)
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
if (typeof repo.registerMethod === "function")
|
|
335
|
+
/**
|
|
336
|
+
* Get audit trail for a specific document
|
|
337
|
+
*/
|
|
338
|
+
repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
|
|
339
|
+
const { page = 1, limit = 20, operation } = queryOptions;
|
|
340
|
+
const skip = (page - 1) * limit;
|
|
341
|
+
const filter = {
|
|
342
|
+
model: this.model,
|
|
343
|
+
documentId
|
|
344
|
+
};
|
|
345
|
+
if (operation) filter.operation = operation;
|
|
346
|
+
const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
|
|
347
|
+
return {
|
|
348
|
+
docs,
|
|
349
|
+
page,
|
|
350
|
+
limit,
|
|
351
|
+
total,
|
|
352
|
+
pages: Math.ceil(total / limit),
|
|
353
|
+
hasNext: page < Math.ceil(total / limit),
|
|
354
|
+
hasPrev: page > 1
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** Convert Mongoose document to plain object */
|
|
361
|
+
function toPlainObject(doc) {
|
|
362
|
+
if (!doc) return {};
|
|
363
|
+
if (typeof doc.toObject === "function") return doc.toObject();
|
|
364
|
+
return doc;
|
|
365
|
+
}
|
|
366
|
+
/** Remove excluded fields from a document snapshot */
|
|
367
|
+
function sanitizeDoc(doc, excludeFields) {
|
|
368
|
+
if (excludeFields.length === 0) return doc;
|
|
369
|
+
const result = { ...doc };
|
|
370
|
+
for (const field of excludeFields) delete result[field];
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Standalone audit trail query utility.
|
|
375
|
+
* Use this to query audits across all models — e.g., admin dashboards, audit APIs.
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```typescript
|
|
379
|
+
* import { AuditTrailQuery } from '@classytic/mongokit';
|
|
380
|
+
*
|
|
381
|
+
* const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
|
|
382
|
+
*
|
|
383
|
+
* // All audits for an org
|
|
384
|
+
* const orgAudits = await auditQuery.query({ orgId: '...' });
|
|
385
|
+
*
|
|
386
|
+
* // All updates by a user
|
|
387
|
+
* const userUpdates = await auditQuery.query({
|
|
388
|
+
* userId: '...',
|
|
389
|
+
* operation: 'update',
|
|
390
|
+
* });
|
|
391
|
+
*
|
|
392
|
+
* // All audits for a specific document
|
|
393
|
+
* const docHistory = await auditQuery.query({
|
|
394
|
+
* model: 'Job',
|
|
395
|
+
* documentId: '...',
|
|
396
|
+
* });
|
|
397
|
+
*
|
|
398
|
+
* // Date range
|
|
399
|
+
* const recent = await auditQuery.query({
|
|
400
|
+
* from: new Date('2025-01-01'),
|
|
401
|
+
* to: new Date(),
|
|
402
|
+
* page: 1,
|
|
403
|
+
* limit: 50,
|
|
404
|
+
* });
|
|
405
|
+
*
|
|
406
|
+
* // Direct model access for custom queries
|
|
407
|
+
* const model = auditQuery.getModel();
|
|
408
|
+
* const count = await model.countDocuments({ operation: 'delete' });
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
var AuditTrailQuery = class {
|
|
412
|
+
model;
|
|
413
|
+
constructor(collectionName = "audit_trails", ttlDays) {
|
|
414
|
+
this.model = getAuditModel(collectionName, ttlDays);
|
|
63
415
|
}
|
|
64
416
|
/**
|
|
65
|
-
*
|
|
417
|
+
* Get the underlying Mongoose model for custom queries
|
|
66
418
|
*/
|
|
67
|
-
|
|
68
|
-
this.
|
|
69
|
-
this._diskUse = false;
|
|
70
|
-
return this;
|
|
419
|
+
getModel() {
|
|
420
|
+
return this.model;
|
|
71
421
|
}
|
|
72
422
|
/**
|
|
73
|
-
*
|
|
423
|
+
* Query audit entries with filters and pagination
|
|
74
424
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
425
|
+
async query(options = {}) {
|
|
426
|
+
const { page = 1, limit = 20 } = options;
|
|
427
|
+
const skip = (page - 1) * limit;
|
|
428
|
+
const filter = {};
|
|
429
|
+
if (options.model) filter.model = options.model;
|
|
430
|
+
if (options.documentId) filter.documentId = options.documentId;
|
|
431
|
+
if (options.userId) filter.userId = options.userId;
|
|
432
|
+
if (options.orgId) filter.orgId = options.orgId;
|
|
433
|
+
if (options.operation) filter.operation = options.operation;
|
|
434
|
+
if (options.from || options.to) {
|
|
435
|
+
const dateFilter = {};
|
|
436
|
+
if (options.from) dateFilter.$gte = options.from;
|
|
437
|
+
if (options.to) dateFilter.$lte = options.to;
|
|
438
|
+
filter.timestamp = dateFilter;
|
|
439
|
+
}
|
|
440
|
+
const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
|
|
441
|
+
const pages = Math.ceil(total / limit);
|
|
442
|
+
return {
|
|
443
|
+
docs,
|
|
444
|
+
page,
|
|
445
|
+
limit,
|
|
446
|
+
total,
|
|
447
|
+
pages,
|
|
448
|
+
hasNext: page < pages,
|
|
449
|
+
hasPrev: page > 1
|
|
450
|
+
};
|
|
78
451
|
}
|
|
79
452
|
/**
|
|
80
|
-
*
|
|
453
|
+
* Get audit trail for a specific document
|
|
81
454
|
*/
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
455
|
+
async getDocumentTrail(model, documentId, options = {}) {
|
|
456
|
+
return this.query({
|
|
457
|
+
model,
|
|
458
|
+
documentId,
|
|
459
|
+
...options
|
|
460
|
+
});
|
|
85
461
|
}
|
|
86
462
|
/**
|
|
87
|
-
*
|
|
88
|
-
* IMPORTANT: Place $match as early as possible for performance
|
|
463
|
+
* Get all audits for a user
|
|
89
464
|
*/
|
|
90
|
-
|
|
91
|
-
this.
|
|
92
|
-
|
|
465
|
+
async getUserTrail(userId, options = {}) {
|
|
466
|
+
return this.query({
|
|
467
|
+
userId,
|
|
468
|
+
...options
|
|
469
|
+
});
|
|
93
470
|
}
|
|
94
471
|
/**
|
|
95
|
-
*
|
|
472
|
+
* Get all audits for an organization
|
|
96
473
|
*/
|
|
97
|
-
|
|
98
|
-
this.
|
|
474
|
+
async getOrgTrail(orgId, options = {}) {
|
|
475
|
+
return this.query({
|
|
476
|
+
orgId,
|
|
477
|
+
...options
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/plugins/batch-operations.plugin.ts
|
|
483
|
+
/**
|
|
484
|
+
* Batch operations plugin
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* const repo = new Repository(Model, [
|
|
488
|
+
* methodRegistryPlugin(),
|
|
489
|
+
* batchOperationsPlugin(),
|
|
490
|
+
* ]);
|
|
491
|
+
*
|
|
492
|
+
* await repo.updateMany({ status: 'pending' }, { status: 'active' });
|
|
493
|
+
* await repo.deleteMany({ status: 'deleted' });
|
|
494
|
+
*/
|
|
495
|
+
function batchOperationsPlugin() {
|
|
496
|
+
return {
|
|
497
|
+
name: "batch-operations",
|
|
498
|
+
apply(repo) {
|
|
499
|
+
if (!repo.registerMethod) throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
|
|
500
|
+
/**
|
|
501
|
+
* Update multiple documents
|
|
502
|
+
*/
|
|
503
|
+
repo.registerMethod("updateMany", async function(query, data, options = {}) {
|
|
504
|
+
const context = await this._buildContext("updateMany", {
|
|
505
|
+
query,
|
|
506
|
+
data,
|
|
507
|
+
...options
|
|
508
|
+
});
|
|
509
|
+
try {
|
|
510
|
+
const finalQuery = context.query || query;
|
|
511
|
+
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
|
|
512
|
+
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.");
|
|
513
|
+
const finalData = context.data || data;
|
|
514
|
+
const result = await this.Model.updateMany(finalQuery, finalData, {
|
|
515
|
+
runValidators: true,
|
|
516
|
+
session: options.session,
|
|
517
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
518
|
+
}).exec();
|
|
519
|
+
await this.emitAsync("after:updateMany", {
|
|
520
|
+
context,
|
|
521
|
+
result
|
|
522
|
+
});
|
|
523
|
+
return result;
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.emit("error:updateMany", {
|
|
526
|
+
context,
|
|
527
|
+
error
|
|
528
|
+
});
|
|
529
|
+
throw this._handleError(error);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
/**
|
|
533
|
+
* Execute heterogeneous bulk write operations in a single database call.
|
|
534
|
+
*
|
|
535
|
+
* Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
|
|
536
|
+
* operations mixed together for maximum efficiency.
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* await repo.bulkWrite([
|
|
540
|
+
* { insertOne: { document: { name: 'New Item', price: 10 } } },
|
|
541
|
+
* { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
|
|
542
|
+
* { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
|
|
543
|
+
* { deleteOne: { filter: { _id: id2 } } },
|
|
544
|
+
* ]);
|
|
545
|
+
*/
|
|
546
|
+
repo.registerMethod("bulkWrite", async function(operations, options = {}) {
|
|
547
|
+
const context = await this._buildContext("bulkWrite", {
|
|
548
|
+
operations,
|
|
549
|
+
...options
|
|
550
|
+
});
|
|
551
|
+
try {
|
|
552
|
+
const finalOps = context.operations || operations;
|
|
553
|
+
if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
|
|
554
|
+
const result = await this.Model.bulkWrite(finalOps, {
|
|
555
|
+
ordered: options.ordered ?? true,
|
|
556
|
+
session: options.session
|
|
557
|
+
});
|
|
558
|
+
const bulkResult = {
|
|
559
|
+
ok: result.ok,
|
|
560
|
+
insertedCount: result.insertedCount,
|
|
561
|
+
upsertedCount: result.upsertedCount,
|
|
562
|
+
matchedCount: result.matchedCount,
|
|
563
|
+
modifiedCount: result.modifiedCount,
|
|
564
|
+
deletedCount: result.deletedCount,
|
|
565
|
+
insertedIds: result.insertedIds,
|
|
566
|
+
upsertedIds: result.upsertedIds
|
|
567
|
+
};
|
|
568
|
+
await this.emitAsync("after:bulkWrite", {
|
|
569
|
+
context,
|
|
570
|
+
result: bulkResult
|
|
571
|
+
});
|
|
572
|
+
return bulkResult;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
this.emit("error:bulkWrite", {
|
|
575
|
+
context,
|
|
576
|
+
error
|
|
577
|
+
});
|
|
578
|
+
throw this._handleError(error);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* Delete multiple documents
|
|
583
|
+
*/
|
|
584
|
+
repo.registerMethod("deleteMany", async function(query, options = {}) {
|
|
585
|
+
const context = await this._buildContext("deleteMany", {
|
|
586
|
+
query,
|
|
587
|
+
...options
|
|
588
|
+
});
|
|
589
|
+
try {
|
|
590
|
+
if (context.softDeleted) {
|
|
591
|
+
const result = {
|
|
592
|
+
acknowledged: true,
|
|
593
|
+
deletedCount: 0
|
|
594
|
+
};
|
|
595
|
+
await this.emitAsync("after:deleteMany", {
|
|
596
|
+
context,
|
|
597
|
+
result
|
|
598
|
+
});
|
|
599
|
+
return result;
|
|
600
|
+
}
|
|
601
|
+
const finalQuery = context.query || query;
|
|
602
|
+
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
|
|
603
|
+
const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
|
|
604
|
+
await this.emitAsync("after:deleteMany", {
|
|
605
|
+
context,
|
|
606
|
+
result
|
|
607
|
+
});
|
|
608
|
+
return result;
|
|
609
|
+
} catch (error) {
|
|
610
|
+
this.emit("error:deleteMany", {
|
|
611
|
+
context,
|
|
612
|
+
error
|
|
613
|
+
});
|
|
614
|
+
throw this._handleError(error);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region src/query/AggregationBuilder.ts
|
|
622
|
+
/**
|
|
623
|
+
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
624
|
+
* Converts 'asc' -> 1, 'desc' -> -1
|
|
625
|
+
*/
|
|
626
|
+
function normalizeSortSpec(sortSpec) {
|
|
627
|
+
const normalized = {};
|
|
628
|
+
for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
|
|
629
|
+
else if (order === "desc") normalized[field] = -1;
|
|
630
|
+
else normalized[field] = order;
|
|
631
|
+
return normalized;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Fluent builder for MongoDB aggregation pipelines
|
|
635
|
+
* Optimized for complex queries at scale
|
|
636
|
+
*/
|
|
637
|
+
var AggregationBuilder = class AggregationBuilder {
|
|
638
|
+
pipeline = [];
|
|
639
|
+
_diskUse = false;
|
|
640
|
+
/**
|
|
641
|
+
* Get the current pipeline
|
|
642
|
+
*/
|
|
643
|
+
get() {
|
|
644
|
+
return [...this.pipeline];
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Build and return the final pipeline
|
|
648
|
+
*/
|
|
649
|
+
build() {
|
|
650
|
+
return this.get();
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Build pipeline with execution options (allowDiskUse, etc.)
|
|
654
|
+
*/
|
|
655
|
+
plan() {
|
|
656
|
+
return {
|
|
657
|
+
pipeline: this.get(),
|
|
658
|
+
allowDiskUse: this._diskUse
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Build and execute the pipeline against a model
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* ```typescript
|
|
666
|
+
* const results = await new AggregationBuilder()
|
|
667
|
+
* .match({ status: 'active' })
|
|
668
|
+
* .allowDiskUse()
|
|
669
|
+
* .exec(MyModel);
|
|
670
|
+
* ```
|
|
671
|
+
*/
|
|
672
|
+
async exec(model, session) {
|
|
673
|
+
const agg = model.aggregate(this.build());
|
|
674
|
+
if (this._diskUse) agg.allowDiskUse(true);
|
|
675
|
+
if (session) agg.session(session);
|
|
676
|
+
return agg.exec();
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Reset the pipeline
|
|
680
|
+
*/
|
|
681
|
+
reset() {
|
|
682
|
+
this.pipeline = [];
|
|
683
|
+
this._diskUse = false;
|
|
684
|
+
return this;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Add a raw pipeline stage
|
|
688
|
+
*/
|
|
689
|
+
addStage(stage) {
|
|
690
|
+
this.pipeline.push(stage);
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Add multiple raw pipeline stages
|
|
695
|
+
*/
|
|
696
|
+
addStages(stages) {
|
|
697
|
+
this.pipeline.push(...stages);
|
|
698
|
+
return this;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* $match - Filter documents
|
|
702
|
+
* IMPORTANT: Place $match as early as possible for performance
|
|
703
|
+
*/
|
|
704
|
+
match(query) {
|
|
705
|
+
this.pipeline.push({ $match: query });
|
|
706
|
+
return this;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* $project - Include/exclude fields or compute new fields
|
|
710
|
+
*/
|
|
711
|
+
project(projection) {
|
|
712
|
+
this.pipeline.push({ $project: projection });
|
|
99
713
|
return this;
|
|
100
714
|
}
|
|
101
715
|
/**
|
|
@@ -489,35 +1103,15 @@ var AggregationBuilder = class AggregationBuilder {
|
|
|
489
1103
|
return new AggregationBuilder().match(query);
|
|
490
1104
|
}
|
|
491
1105
|
};
|
|
492
|
-
|
|
493
1106
|
//#endregion
|
|
494
1107
|
//#region src/Repository.ts
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
* ```typescript
|
|
503
|
-
* const userRepo = new Repository(UserModel, [
|
|
504
|
-
* timestampPlugin(),
|
|
505
|
-
* softDeletePlugin(),
|
|
506
|
-
* ]);
|
|
507
|
-
*
|
|
508
|
-
* // Create
|
|
509
|
-
* const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
|
|
510
|
-
*
|
|
511
|
-
* // Read with pagination
|
|
512
|
-
* const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
|
|
513
|
-
*
|
|
514
|
-
* // Update
|
|
515
|
-
* const updated = await userRepo.update(user._id, { name: 'John Doe' });
|
|
516
|
-
*
|
|
517
|
-
* // Delete
|
|
518
|
-
* await userRepo.delete(user._id);
|
|
519
|
-
* ```
|
|
520
|
-
*/
|
|
1108
|
+
function ensureLookupProjectionIncludesCursorFields(projection, sort) {
|
|
1109
|
+
if (!projection || !sort) return projection;
|
|
1110
|
+
if (!Object.values(projection).some((value) => value === 1)) return projection;
|
|
1111
|
+
const nextProjection = { ...projection };
|
|
1112
|
+
for (const field of [...Object.keys(sort), "_id"]) nextProjection[field] = 1;
|
|
1113
|
+
return nextProjection;
|
|
1114
|
+
}
|
|
521
1115
|
/**
|
|
522
1116
|
* Plugin phase priorities (lower = runs first)
|
|
523
1117
|
* Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
|
|
@@ -546,7 +1140,9 @@ var Repository = class {
|
|
|
546
1140
|
this._hooks = /* @__PURE__ */ new Map();
|
|
547
1141
|
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
548
1142
|
this._hookMode = options.hooks ?? "async";
|
|
549
|
-
plugins.forEach((plugin) =>
|
|
1143
|
+
plugins.forEach((plugin) => {
|
|
1144
|
+
this.use(plugin);
|
|
1145
|
+
});
|
|
550
1146
|
}
|
|
551
1147
|
/**
|
|
552
1148
|
* Register a plugin
|
|
@@ -567,7 +1163,7 @@ var Repository = class {
|
|
|
567
1163
|
*/
|
|
568
1164
|
on(event, listener, options) {
|
|
569
1165
|
if (!this._hooks.has(event)) this._hooks.set(event, []);
|
|
570
|
-
const hooks = this._hooks.get(event);
|
|
1166
|
+
const hooks = this._hooks.get(event) ?? [];
|
|
571
1167
|
const priority = options?.priority ?? HOOK_PRIORITY.DEFAULT;
|
|
572
1168
|
hooks.push({
|
|
573
1169
|
listener,
|
|
@@ -807,7 +1403,7 @@ var Repository = class {
|
|
|
807
1403
|
let useKeyset = false;
|
|
808
1404
|
if (mode) useKeyset = mode === "keyset";
|
|
809
1405
|
else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
|
|
810
|
-
|
|
1406
|
+
const query = { ...filters };
|
|
811
1407
|
if (search) {
|
|
812
1408
|
if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
|
|
813
1409
|
if (this._hasTextIndex) query.$text = { $search: search };
|
|
@@ -824,20 +1420,49 @@ var Repository = class {
|
|
|
824
1420
|
session: options.session,
|
|
825
1421
|
hint: context.hint ?? params.hint,
|
|
826
1422
|
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
827
|
-
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
|
|
1423
|
+
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
|
|
1424
|
+
collation: context.collation ?? params.collation
|
|
828
1425
|
};
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1426
|
+
const lookups = context.lookups ?? params.lookups;
|
|
1427
|
+
if (lookups && lookups.length > 0) try {
|
|
1428
|
+
const lookupResult = await this.lookupPopulate({
|
|
1429
|
+
filters: query,
|
|
1430
|
+
lookups,
|
|
833
1431
|
sort: paginationOptions.sort,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1432
|
+
page: useKeyset ? void 0 : page || 1,
|
|
1433
|
+
after: useKeyset ? after : void 0,
|
|
1434
|
+
limit,
|
|
1435
|
+
select: paginationOptions.select,
|
|
1436
|
+
session: options.session,
|
|
1437
|
+
readPreference: paginationOptions.readPreference,
|
|
1438
|
+
collation: paginationOptions.collation,
|
|
839
1439
|
countStrategy: context.countStrategy ?? params.countStrategy
|
|
840
1440
|
});
|
|
1441
|
+
let result;
|
|
1442
|
+
if (lookupResult.next !== void 0) result = {
|
|
1443
|
+
method: "keyset",
|
|
1444
|
+
docs: lookupResult.data,
|
|
1445
|
+
limit: lookupResult.limit ?? limit,
|
|
1446
|
+
hasMore: lookupResult.hasMore ?? false,
|
|
1447
|
+
next: lookupResult.next ?? null
|
|
1448
|
+
};
|
|
1449
|
+
else {
|
|
1450
|
+
const total = lookupResult.total ?? 0;
|
|
1451
|
+
const resultLimit = lookupResult.limit ?? limit;
|
|
1452
|
+
const totalPages = Math.ceil(total / resultLimit);
|
|
1453
|
+
const currentPage = lookupResult.page ?? 1;
|
|
1454
|
+
const hasNext = lookupResult.hasMore !== void 0 ? lookupResult.hasMore : currentPage < totalPages;
|
|
1455
|
+
result = {
|
|
1456
|
+
method: "offset",
|
|
1457
|
+
docs: lookupResult.data,
|
|
1458
|
+
page: currentPage,
|
|
1459
|
+
limit: resultLimit,
|
|
1460
|
+
total,
|
|
1461
|
+
pages: totalPages,
|
|
1462
|
+
hasNext,
|
|
1463
|
+
hasPrev: currentPage > 1
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
841
1466
|
await this._emitHook("after:getAll", {
|
|
842
1467
|
context,
|
|
843
1468
|
result
|
|
@@ -850,8 +1475,32 @@ var Repository = class {
|
|
|
850
1475
|
});
|
|
851
1476
|
throw this._handleError(error);
|
|
852
1477
|
}
|
|
853
|
-
|
|
854
|
-
|
|
1478
|
+
try {
|
|
1479
|
+
let result;
|
|
1480
|
+
if (useKeyset) result = await this._pagination.stream({
|
|
1481
|
+
...paginationOptions,
|
|
1482
|
+
sort: paginationOptions.sort,
|
|
1483
|
+
after
|
|
1484
|
+
});
|
|
1485
|
+
else result = await this._pagination.paginate({
|
|
1486
|
+
...paginationOptions,
|
|
1487
|
+
page: page || 1,
|
|
1488
|
+
countStrategy: context.countStrategy ?? params.countStrategy
|
|
1489
|
+
});
|
|
1490
|
+
await this._emitHook("after:getAll", {
|
|
1491
|
+
context,
|
|
1492
|
+
result
|
|
1493
|
+
});
|
|
1494
|
+
return result;
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
await this._emitErrorHook("error:getAll", {
|
|
1497
|
+
context,
|
|
1498
|
+
error
|
|
1499
|
+
});
|
|
1500
|
+
throw this._handleError(error);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
855
1504
|
* Get or create document
|
|
856
1505
|
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
857
1506
|
*/
|
|
@@ -1116,42 +1765,120 @@ var Repository = class {
|
|
|
1116
1765
|
async lookupPopulate(options) {
|
|
1117
1766
|
const context = await this._buildContext("lookupPopulate", options);
|
|
1118
1767
|
try {
|
|
1119
|
-
const
|
|
1768
|
+
const MAX_LOOKUPS = 10;
|
|
1769
|
+
const lookups = context.lookups ?? options.lookups;
|
|
1770
|
+
if (lookups.length > MAX_LOOKUPS) throw createError(400, `Too many lookups (${lookups.length}). Maximum is ${MAX_LOOKUPS}.`);
|
|
1120
1771
|
const filters = context.filters ?? options.filters;
|
|
1121
|
-
if (filters && Object.keys(filters).length > 0) builder.match(filters);
|
|
1122
|
-
builder.multiLookup(options.lookups);
|
|
1123
1772
|
const sort = context.sort ?? options.sort;
|
|
1124
|
-
if (sort) builder.sort(this._parseSort(sort));
|
|
1125
|
-
const page = context.page ?? options.page ?? 1;
|
|
1126
1773
|
const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1774
|
+
const readPref = context.readPreference ?? options.readPreference;
|
|
1775
|
+
const session = context.session ?? options.session;
|
|
1776
|
+
const collation = context.collation ?? options.collation;
|
|
1777
|
+
const after = context.after ?? options.after;
|
|
1778
|
+
const pageFromContext = context.page ?? options.page;
|
|
1779
|
+
const isKeyset = !!after || !pageFromContext && !!sort;
|
|
1780
|
+
const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
|
|
1133
1781
|
const selectSpec = context.select ?? options.select;
|
|
1782
|
+
let projection;
|
|
1134
1783
|
if (selectSpec) {
|
|
1135
|
-
let projection;
|
|
1136
1784
|
if (typeof selectSpec === "string") {
|
|
1137
1785
|
projection = {};
|
|
1138
|
-
const
|
|
1139
|
-
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
1786
|
+
for (const field of selectSpec.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
1140
1787
|
else projection[field] = 1;
|
|
1141
1788
|
} else if (Array.isArray(selectSpec)) {
|
|
1142
1789
|
projection = {};
|
|
1143
1790
|
for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
1144
1791
|
else projection[field] = 1;
|
|
1145
|
-
} else projection = selectSpec;
|
|
1146
|
-
|
|
1792
|
+
} else projection = { ...selectSpec };
|
|
1793
|
+
if (Object.values(projection).some((v) => v === 1)) for (const lookup of lookups) {
|
|
1794
|
+
const asField = lookup.as || lookup.from;
|
|
1795
|
+
if (!(asField in projection)) projection[asField] = 1;
|
|
1796
|
+
}
|
|
1147
1797
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1798
|
+
const appendLookupStages = (pipeline) => {
|
|
1799
|
+
pipeline.push(...LookupBuilder.multiple(lookups));
|
|
1800
|
+
for (const lookup of lookups) if (lookup.single) {
|
|
1801
|
+
const asField = lookup.as || lookup.from;
|
|
1802
|
+
pipeline.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
|
|
1803
|
+
}
|
|
1804
|
+
const finalProjection = ensureLookupProjectionIncludesCursorFields(projection, isKeyset && sort ? this._parseSort(sort) : void 0);
|
|
1805
|
+
if (finalProjection) pipeline.push({ $project: finalProjection });
|
|
1806
|
+
};
|
|
1807
|
+
if (isKeyset && sort) {
|
|
1808
|
+
const parsedSort = this._parseSort(sort);
|
|
1809
|
+
const { validateKeysetSort } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
|
|
1810
|
+
const { encodeCursor, resolveCursorFilter } = await import("./cursor-CHToazHy.mjs").then((n) => n.t);
|
|
1811
|
+
const { getPrimaryField } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
|
|
1812
|
+
const normalizedSort = validateKeysetSort(parsedSort);
|
|
1813
|
+
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
1814
|
+
const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }) : { ...filters || {} };
|
|
1815
|
+
if (projection) {
|
|
1816
|
+
if (Object.values(projection).some((v) => v === 1)) {
|
|
1817
|
+
for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
const pipeline = [];
|
|
1821
|
+
if (Object.keys(matchFilters).length > 0) pipeline.push({ $match: matchFilters });
|
|
1822
|
+
pipeline.push({ $sort: normalizedSort });
|
|
1823
|
+
pipeline.push({ $limit: limit + 1 });
|
|
1824
|
+
appendLookupStages(pipeline);
|
|
1825
|
+
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
1826
|
+
if (collation) aggregation.collation(collation);
|
|
1827
|
+
if (readPref) aggregation.read(readPref);
|
|
1828
|
+
const docs = await aggregation;
|
|
1829
|
+
const hasMore = docs.length > limit;
|
|
1830
|
+
if (hasMore) docs.pop();
|
|
1831
|
+
const primaryField = getPrimaryField(normalizedSort);
|
|
1832
|
+
const nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null;
|
|
1833
|
+
await this._emitHook("after:lookupPopulate", {
|
|
1834
|
+
context,
|
|
1835
|
+
result: docs
|
|
1836
|
+
});
|
|
1837
|
+
return {
|
|
1838
|
+
data: docs,
|
|
1839
|
+
limit,
|
|
1840
|
+
next: nextCursor,
|
|
1841
|
+
hasMore
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
const page = pageFromContext ?? 1;
|
|
1845
|
+
const skip = (page - 1) * limit;
|
|
1846
|
+
if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
|
|
1847
|
+
const dataPipeline = [];
|
|
1848
|
+
if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
|
|
1849
|
+
if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
|
|
1850
|
+
if (countStrategy === "none") {
|
|
1851
|
+
dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
|
|
1852
|
+
appendLookupStages(dataPipeline);
|
|
1853
|
+
const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
|
|
1854
|
+
if (collation) aggregation.collation(collation);
|
|
1855
|
+
if (readPref) aggregation.read(readPref);
|
|
1856
|
+
const docs = await aggregation;
|
|
1857
|
+
const hasNext = docs.length > limit;
|
|
1858
|
+
if (hasNext) docs.pop();
|
|
1859
|
+
await this._emitHook("after:lookupPopulate", {
|
|
1860
|
+
context,
|
|
1861
|
+
result: docs
|
|
1862
|
+
});
|
|
1863
|
+
return {
|
|
1864
|
+
data: docs,
|
|
1865
|
+
total: 0,
|
|
1866
|
+
page,
|
|
1867
|
+
limit,
|
|
1868
|
+
hasMore: hasNext
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
dataPipeline.push({ $skip: skip }, { $limit: limit });
|
|
1872
|
+
appendLookupStages(dataPipeline);
|
|
1873
|
+
const countPipeline = [];
|
|
1874
|
+
if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
|
|
1875
|
+
countPipeline.push({ $count: "total" });
|
|
1876
|
+
const pipeline = [{ $facet: {
|
|
1877
|
+
metadata: countPipeline,
|
|
1878
|
+
data: dataPipeline
|
|
1879
|
+
} }];
|
|
1880
|
+
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
1881
|
+
if (collation) aggregation.collation(collation);
|
|
1155
1882
|
if (readPref) aggregation.read(readPref);
|
|
1156
1883
|
const result = (await aggregation)[0] || {
|
|
1157
1884
|
metadata: [],
|
|
@@ -1340,1614 +2067,1205 @@ var Repository = class {
|
|
|
1340
2067
|
_handleError(error) {
|
|
1341
2068
|
if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
|
|
1342
2069
|
if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
2070
|
+
const duplicateErr = parseDuplicateKeyError(error);
|
|
2071
|
+
if (duplicateErr) return duplicateErr;
|
|
1343
2072
|
if (error.status && error.message) return error;
|
|
1344
2073
|
return createError(500, error.message || "Internal Server Error");
|
|
1345
2074
|
}
|
|
1346
2075
|
};
|
|
1347
|
-
|
|
1348
|
-
//#endregion
|
|
1349
|
-
//#region src/plugins/field-filter.plugin.ts
|
|
1350
|
-
/**
|
|
1351
|
-
* Field Filter Plugin
|
|
1352
|
-
* Automatically filters response fields based on user roles
|
|
1353
|
-
*/
|
|
1354
|
-
/**
|
|
1355
|
-
* Field filter plugin that restricts fields based on user context
|
|
1356
|
-
*
|
|
1357
|
-
* @example
|
|
1358
|
-
* const fieldPreset = {
|
|
1359
|
-
* public: ['id', 'name'],
|
|
1360
|
-
* authenticated: ['email'],
|
|
1361
|
-
* admin: ['createdAt', 'internalNotes']
|
|
1362
|
-
* };
|
|
1363
|
-
*
|
|
1364
|
-
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
1365
|
-
*/
|
|
1366
|
-
function fieldFilterPlugin(fieldPreset) {
|
|
1367
|
-
return {
|
|
1368
|
-
name: "fieldFilter",
|
|
1369
|
-
apply(repo) {
|
|
1370
|
-
const applyFieldFiltering = (context) => {
|
|
1371
|
-
if (!fieldPreset) return;
|
|
1372
|
-
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
1373
|
-
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
1374
|
-
else context.select = presetSelect;
|
|
1375
|
-
};
|
|
1376
|
-
repo.on("before:getAll", applyFieldFiltering);
|
|
1377
|
-
repo.on("before:getById", applyFieldFiltering);
|
|
1378
|
-
repo.on("before:getByQuery", applyFieldFiltering);
|
|
1379
|
-
}
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
//#endregion
|
|
1384
|
-
//#region src/plugins/timestamp.plugin.ts
|
|
1385
|
-
/**
|
|
1386
|
-
* Timestamp plugin that auto-injects timestamps
|
|
1387
|
-
*
|
|
1388
|
-
* @example
|
|
1389
|
-
* const repo = new Repository(Model, [timestampPlugin()]);
|
|
1390
|
-
*/
|
|
1391
|
-
function timestampPlugin() {
|
|
1392
|
-
return {
|
|
1393
|
-
name: "timestamp",
|
|
1394
|
-
apply(repo) {
|
|
1395
|
-
repo.on("before:create", (context) => {
|
|
1396
|
-
if (!context.data) return;
|
|
1397
|
-
const now = /* @__PURE__ */ new Date();
|
|
1398
|
-
if (!context.data.createdAt) context.data.createdAt = now;
|
|
1399
|
-
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
1400
|
-
});
|
|
1401
|
-
repo.on("before:update", (context) => {
|
|
1402
|
-
if (!context.data) return;
|
|
1403
|
-
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
};
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
//#endregion
|
|
1410
|
-
//#region src/plugins/audit-log.plugin.ts
|
|
1411
|
-
/**
|
|
1412
|
-
* Audit log plugin that logs all repository operations
|
|
1413
|
-
*
|
|
1414
|
-
* @example
|
|
1415
|
-
* const repo = new Repository(Model, [auditLogPlugin(console)]);
|
|
1416
|
-
*/
|
|
1417
|
-
function auditLogPlugin(logger) {
|
|
1418
|
-
return {
|
|
1419
|
-
name: "auditLog",
|
|
1420
|
-
apply(repo) {
|
|
1421
|
-
repo.on("after:create", ({ context, result }) => {
|
|
1422
|
-
logger?.info?.("Document created", {
|
|
1423
|
-
model: context.model || repo.model,
|
|
1424
|
-
id: result?._id,
|
|
1425
|
-
userId: context.user?._id || context.user?.id,
|
|
1426
|
-
organizationId: context.organizationId
|
|
1427
|
-
});
|
|
1428
|
-
});
|
|
1429
|
-
repo.on("after:update", ({ context, result }) => {
|
|
1430
|
-
logger?.info?.("Document updated", {
|
|
1431
|
-
model: context.model || repo.model,
|
|
1432
|
-
id: context.id || result?._id,
|
|
1433
|
-
userId: context.user?._id || context.user?.id,
|
|
1434
|
-
organizationId: context.organizationId
|
|
1435
|
-
});
|
|
1436
|
-
});
|
|
1437
|
-
repo.on("after:delete", ({ context }) => {
|
|
1438
|
-
logger?.info?.("Document deleted", {
|
|
1439
|
-
model: context.model || repo.model,
|
|
1440
|
-
id: context.id,
|
|
1441
|
-
userId: context.user?._id || context.user?.id,
|
|
1442
|
-
organizationId: context.organizationId
|
|
1443
|
-
});
|
|
1444
|
-
});
|
|
1445
|
-
repo.on("error:create", ({ context, error }) => {
|
|
1446
|
-
logger?.error?.("Create failed", {
|
|
1447
|
-
model: context.model || repo.model,
|
|
1448
|
-
error: error.message,
|
|
1449
|
-
userId: context.user?._id || context.user?.id
|
|
1450
|
-
});
|
|
1451
|
-
});
|
|
1452
|
-
repo.on("error:update", ({ context, error }) => {
|
|
1453
|
-
logger?.error?.("Update failed", {
|
|
1454
|
-
model: context.model || repo.model,
|
|
1455
|
-
id: context.id,
|
|
1456
|
-
error: error.message,
|
|
1457
|
-
userId: context.user?._id || context.user?.id
|
|
1458
|
-
});
|
|
1459
|
-
});
|
|
1460
|
-
repo.on("error:delete", ({ context, error }) => {
|
|
1461
|
-
logger?.error?.("Delete failed", {
|
|
1462
|
-
model: context.model || repo.model,
|
|
1463
|
-
id: context.id,
|
|
1464
|
-
error: error.message,
|
|
1465
|
-
userId: context.user?._id || context.user?.id
|
|
1466
|
-
});
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
};
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
2076
|
//#endregion
|
|
1473
|
-
//#region src/plugins/
|
|
1474
|
-
/**
|
|
1475
|
-
* Build filter condition based on filter mode
|
|
1476
|
-
*/
|
|
1477
|
-
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
1478
|
-
if (includeDeleted) return {};
|
|
1479
|
-
if (filterMode === "exists") return { [deletedField]: { $exists: false } };
|
|
1480
|
-
return { [deletedField]: null };
|
|
1481
|
-
}
|
|
1482
|
-
/**
|
|
1483
|
-
* Build filter condition for finding deleted documents
|
|
1484
|
-
*/
|
|
1485
|
-
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
1486
|
-
if (filterMode === "exists") return { [deletedField]: {
|
|
1487
|
-
$exists: true,
|
|
1488
|
-
$ne: null
|
|
1489
|
-
} };
|
|
1490
|
-
return { [deletedField]: { $ne: null } };
|
|
1491
|
-
}
|
|
2077
|
+
//#region src/plugins/cache.plugin.ts
|
|
1492
2078
|
/**
|
|
1493
|
-
*
|
|
2079
|
+
* Cache Plugin
|
|
1494
2080
|
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
* const repo = new Repository(Model, [
|
|
1498
|
-
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
1499
|
-
* ]);
|
|
2081
|
+
* Optional caching layer for MongoKit with automatic invalidation.
|
|
2082
|
+
* Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
|
|
1500
2083
|
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
2084
|
+
* Features:
|
|
2085
|
+
* - Cache-aside (read-through) pattern with configurable TTLs
|
|
2086
|
+
* - Automatic invalidation on create/update/delete
|
|
2087
|
+
* - Collection version tags for efficient list cache invalidation
|
|
2088
|
+
* - Manual invalidation methods for microservice scenarios
|
|
2089
|
+
* - Skip cache per-operation with `skipCache: true`
|
|
1503
2090
|
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
2091
|
+
* @example
|
|
2092
|
+
* ```typescript
|
|
2093
|
+
* import { Repository, cachePlugin } from '@classytic/mongokit';
|
|
2094
|
+
* import Redis from 'ioredis';
|
|
1506
2095
|
*
|
|
1507
|
-
*
|
|
1508
|
-
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
1509
|
-
* ```
|
|
2096
|
+
* const redis = new Redis();
|
|
1510
2097
|
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
2098
|
+
* const userRepo = new Repository(UserModel, [
|
|
2099
|
+
* cachePlugin({
|
|
2100
|
+
* adapter: {
|
|
2101
|
+
* async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
|
|
2102
|
+
* async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
|
|
2103
|
+
* async del(key) { await redis.del(key); },
|
|
2104
|
+
* async clear(pattern) {
|
|
2105
|
+
* const keys = await redis.keys(pattern || '*');
|
|
2106
|
+
* if (keys.length) await redis.del(...keys);
|
|
2107
|
+
* }
|
|
2108
|
+
* },
|
|
2109
|
+
* ttl: 60, // 1 minute default
|
|
1518
2110
|
* })
|
|
1519
2111
|
* ]);
|
|
1520
|
-
* ```
|
|
1521
2112
|
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
2113
|
+
* // Reads check cache first
|
|
2114
|
+
* const user = await userRepo.getById(id); // cached
|
|
2115
|
+
*
|
|
2116
|
+
* // Skip cache for fresh data
|
|
2117
|
+
* const fresh = await userRepo.getById(id, { skipCache: true });
|
|
2118
|
+
*
|
|
2119
|
+
* // Mutations auto-invalidate
|
|
2120
|
+
* await userRepo.update(id, { name: 'New Name' }); // invalidates cache
|
|
2121
|
+
*
|
|
2122
|
+
* // Manual invalidation for microservice sync
|
|
2123
|
+
* await userRepo.invalidateCache(id); // invalidate single doc
|
|
2124
|
+
* await userRepo.invalidateAllCache(); // invalidate all for this model
|
|
1530
2125
|
* ```
|
|
1531
2126
|
*/
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
2127
|
+
/**
|
|
2128
|
+
* Cache plugin factory
|
|
2129
|
+
*
|
|
2130
|
+
* @param options - Cache configuration
|
|
2131
|
+
* @returns Plugin instance
|
|
2132
|
+
*/
|
|
2133
|
+
function cachePlugin(options) {
|
|
2134
|
+
const config = {
|
|
2135
|
+
adapter: options.adapter,
|
|
2136
|
+
ttl: options.ttl ?? 60,
|
|
2137
|
+
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
2138
|
+
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
2139
|
+
prefix: options.prefix ?? "mk",
|
|
2140
|
+
debug: options.debug ?? false,
|
|
2141
|
+
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
|
|
2142
|
+
};
|
|
2143
|
+
const stats = {
|
|
2144
|
+
hits: 0,
|
|
2145
|
+
misses: 0,
|
|
2146
|
+
sets: 0,
|
|
2147
|
+
invalidations: 0,
|
|
2148
|
+
errors: 0
|
|
2149
|
+
};
|
|
2150
|
+
const log = (msg, data) => {
|
|
2151
|
+
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
2152
|
+
};
|
|
1539
2153
|
return {
|
|
1540
|
-
name: "
|
|
2154
|
+
name: "cache",
|
|
1541
2155
|
apply(repo) {
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
2156
|
+
const model = repo.model;
|
|
2157
|
+
const byIdKeyRegistry = /* @__PURE__ */ new Map();
|
|
2158
|
+
function trackByIdKey(docId, cacheKey) {
|
|
2159
|
+
let keys = byIdKeyRegistry.get(docId);
|
|
2160
|
+
if (!keys) {
|
|
2161
|
+
keys = /* @__PURE__ */ new Set();
|
|
2162
|
+
byIdKeyRegistry.set(docId, keys);
|
|
1547
2163
|
}
|
|
1548
|
-
|
|
1549
|
-
warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1550
|
-
}
|
|
1551
|
-
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
1552
|
-
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
1553
|
-
repo.Model.collection.createIndex({ [deletedField]: 1 }, {
|
|
1554
|
-
expireAfterSeconds: ttlSeconds,
|
|
1555
|
-
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
1556
|
-
}).catch((err) => {
|
|
1557
|
-
if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
1558
|
-
});
|
|
2164
|
+
keys.add(cacheKey);
|
|
1559
2165
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
...context.query || {}
|
|
1567
|
-
};
|
|
1568
|
-
if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
|
|
1569
|
-
const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
|
|
1570
|
-
error.status = 404;
|
|
1571
|
-
throw error;
|
|
1572
|
-
}
|
|
1573
|
-
context.softDeleted = true;
|
|
2166
|
+
async function getVersion() {
|
|
2167
|
+
try {
|
|
2168
|
+
return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
|
|
2169
|
+
} catch (e) {
|
|
2170
|
+
log(`Cache error in getVersion for ${model}:`, e);
|
|
2171
|
+
return 0;
|
|
1574
2172
|
}
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Bump collection version in the adapter (invalidates all list caches).
|
|
2176
|
+
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
2177
|
+
*/
|
|
2178
|
+
async function bumpVersion() {
|
|
2179
|
+
const newVersion = Date.now();
|
|
2180
|
+
try {
|
|
2181
|
+
await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
|
|
2182
|
+
stats.invalidations++;
|
|
2183
|
+
log(`Bumped version for ${model} to:`, newVersion);
|
|
2184
|
+
} catch (e) {
|
|
2185
|
+
log(`Failed to bump version for ${model}:`, e);
|
|
1583
2186
|
}
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Invalidate a specific document by ID (all shape variants).
|
|
2190
|
+
* Deletes every tracked shape-variant key individually via del(),
|
|
2191
|
+
* so adapters without pattern-based clear() still get full invalidation.
|
|
2192
|
+
*/
|
|
2193
|
+
async function invalidateById(id) {
|
|
2194
|
+
try {
|
|
2195
|
+
const baseKey = byIdKey(config.prefix, model, id);
|
|
2196
|
+
await config.adapter.del(baseKey);
|
|
2197
|
+
const trackedKeys = byIdKeyRegistry.get(id);
|
|
2198
|
+
if (trackedKeys) {
|
|
2199
|
+
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
|
|
2200
|
+
byIdKeyRegistry.delete(id);
|
|
2201
|
+
}
|
|
2202
|
+
stats.invalidations++;
|
|
2203
|
+
log(`Invalidated byId cache for:`, id);
|
|
2204
|
+
} catch (e) {
|
|
2205
|
+
log(`Failed to invalidate byId cache:`, e);
|
|
1592
2206
|
}
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
};
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* before:getById - Check cache for document
|
|
2210
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2211
|
+
*/
|
|
2212
|
+
repo.on("before:getById", async (context) => {
|
|
2213
|
+
if (context.skipCache) {
|
|
2214
|
+
log(`Skipping cache for getById: ${context.id}`);
|
|
2215
|
+
return;
|
|
1601
2216
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2217
|
+
const id = String(context.id);
|
|
2218
|
+
const key = byIdKey(config.prefix, model, id, {
|
|
2219
|
+
select: context.select,
|
|
2220
|
+
populate: context.populate,
|
|
2221
|
+
lean: context.lean
|
|
2222
|
+
});
|
|
2223
|
+
try {
|
|
2224
|
+
const cached = await config.adapter.get(key);
|
|
2225
|
+
if (cached !== null) {
|
|
2226
|
+
stats.hits++;
|
|
2227
|
+
log(`Cache HIT for getById:`, key);
|
|
2228
|
+
context._cacheHit = true;
|
|
2229
|
+
context._cachedResult = cached;
|
|
2230
|
+
} else {
|
|
2231
|
+
stats.misses++;
|
|
2232
|
+
log(`Cache MISS for getById:`, key);
|
|
2233
|
+
}
|
|
2234
|
+
} catch (e) {
|
|
2235
|
+
log(`Cache error for getById:`, e);
|
|
2236
|
+
stats.errors++;
|
|
1610
2237
|
}
|
|
1611
|
-
}, { priority: HOOK_PRIORITY.
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
2238
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2239
|
+
/**
|
|
2240
|
+
* before:getByQuery - Check cache for single-doc query
|
|
2241
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2242
|
+
*/
|
|
2243
|
+
repo.on("before:getByQuery", async (context) => {
|
|
2244
|
+
if (context.skipCache) {
|
|
2245
|
+
log(`Skipping cache for getByQuery`);
|
|
2246
|
+
return;
|
|
1619
2247
|
}
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2248
|
+
const collectionVersion = await getVersion();
|
|
2249
|
+
const query = context.query || {};
|
|
2250
|
+
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2251
|
+
select: context.select,
|
|
2252
|
+
populate: context.populate
|
|
2253
|
+
});
|
|
2254
|
+
try {
|
|
2255
|
+
const cached = await config.adapter.get(key);
|
|
2256
|
+
if (cached !== null) {
|
|
2257
|
+
stats.hits++;
|
|
2258
|
+
log(`Cache HIT for getByQuery:`, key);
|
|
2259
|
+
context._cacheHit = true;
|
|
2260
|
+
context._cachedResult = cached;
|
|
2261
|
+
} else {
|
|
2262
|
+
stats.misses++;
|
|
2263
|
+
log(`Cache MISS for getByQuery:`, key);
|
|
2264
|
+
}
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
log(`Cache error for getByQuery:`, e);
|
|
2267
|
+
stats.errors++;
|
|
1628
2268
|
}
|
|
1629
|
-
}, { priority: HOOK_PRIORITY.
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
2269
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2270
|
+
/**
|
|
2271
|
+
* before:getAll - Check cache for list query
|
|
2272
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2273
|
+
*/
|
|
2274
|
+
repo.on("before:getAll", async (context) => {
|
|
2275
|
+
if (context.skipCache) {
|
|
2276
|
+
log(`Skipping cache for getAll`);
|
|
2277
|
+
return;
|
|
1637
2278
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
1643
|
-
...context.query || {},
|
|
1644
|
-
...deleteFilter
|
|
1645
|
-
};
|
|
2279
|
+
const limit = context.limit;
|
|
2280
|
+
if (limit && limit > config.skipIfLargeLimit) {
|
|
2281
|
+
log(`Skipping cache for large query (limit: ${limit})`);
|
|
2282
|
+
return;
|
|
1646
2283
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2284
|
+
const collectionVersion = await getVersion();
|
|
2285
|
+
const params = {
|
|
2286
|
+
filters: context.filters,
|
|
2287
|
+
sort: context.sort,
|
|
2288
|
+
page: context.page,
|
|
2289
|
+
limit,
|
|
2290
|
+
after: context.after,
|
|
2291
|
+
select: context.select,
|
|
2292
|
+
populate: context.populate,
|
|
2293
|
+
search: context.search,
|
|
2294
|
+
mode: context.mode,
|
|
2295
|
+
lean: context.lean,
|
|
2296
|
+
readPreference: context.readPreference,
|
|
2297
|
+
hint: context.hint,
|
|
2298
|
+
maxTimeMS: context.maxTimeMS,
|
|
2299
|
+
countStrategy: context.countStrategy
|
|
2300
|
+
};
|
|
2301
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2302
|
+
try {
|
|
2303
|
+
const cached = await config.adapter.get(key);
|
|
2304
|
+
if (cached !== null) {
|
|
2305
|
+
stats.hits++;
|
|
2306
|
+
log(`Cache HIT for getAll:`, key);
|
|
2307
|
+
context._cacheHit = true;
|
|
2308
|
+
context._cachedResult = cached;
|
|
2309
|
+
} else {
|
|
2310
|
+
stats.misses++;
|
|
2311
|
+
log(`Cache MISS for getAll:`, key);
|
|
2312
|
+
}
|
|
2313
|
+
} catch (e) {
|
|
2314
|
+
log(`Cache error for getAll:`, e);
|
|
2315
|
+
stats.errors++;
|
|
1655
2316
|
}
|
|
1656
|
-
}, { priority: HOOK_PRIORITY.
|
|
1657
|
-
if (addRestoreMethod) {
|
|
1658
|
-
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
1659
|
-
const context = await this._buildContext.call(this, "restore", {
|
|
1660
|
-
id,
|
|
1661
|
-
...restoreOptions
|
|
1662
|
-
});
|
|
1663
|
-
const updateData = {
|
|
1664
|
-
[deletedField]: null,
|
|
1665
|
-
[deletedByField]: null
|
|
1666
|
-
};
|
|
1667
|
-
const restoreQuery = {
|
|
1668
|
-
_id: id,
|
|
1669
|
-
...context.query || {}
|
|
1670
|
-
};
|
|
1671
|
-
const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
|
|
1672
|
-
returnDocument: "after",
|
|
1673
|
-
session: restoreOptions.session
|
|
1674
|
-
});
|
|
1675
|
-
if (!result) {
|
|
1676
|
-
const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
|
|
1677
|
-
error.status = 404;
|
|
1678
|
-
throw error;
|
|
1679
|
-
}
|
|
1680
|
-
await this.emitAsync("after:restore", {
|
|
1681
|
-
id,
|
|
1682
|
-
result,
|
|
1683
|
-
context
|
|
1684
|
-
});
|
|
1685
|
-
return result;
|
|
1686
|
-
};
|
|
1687
|
-
if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
|
|
1688
|
-
else repo.restore = restoreMethod.bind(repo);
|
|
1689
|
-
}
|
|
1690
|
-
if (addGetDeletedMethod) {
|
|
1691
|
-
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
1692
|
-
const context = await this._buildContext.call(this, "getDeleted", {
|
|
1693
|
-
...params,
|
|
1694
|
-
...getDeletedOptions
|
|
1695
|
-
});
|
|
1696
|
-
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
1697
|
-
const combinedFilters = {
|
|
1698
|
-
...params.filters || {},
|
|
1699
|
-
...deletedFilter,
|
|
1700
|
-
...context.filters || {},
|
|
1701
|
-
...context.query || {}
|
|
1702
|
-
};
|
|
1703
|
-
const page = params.page || 1;
|
|
1704
|
-
const limit = params.limit || 20;
|
|
1705
|
-
const skip = (page - 1) * limit;
|
|
1706
|
-
let sortSpec = { [deletedField]: -1 };
|
|
1707
|
-
if (params.sort) if (typeof params.sort === "string") {
|
|
1708
|
-
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
1709
|
-
sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
|
|
1710
|
-
} else sortSpec = params.sort;
|
|
1711
|
-
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
1712
|
-
if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
|
|
1713
|
-
if (getDeletedOptions.select) {
|
|
1714
|
-
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
1715
|
-
query = query.select(selectValue);
|
|
1716
|
-
}
|
|
1717
|
-
if (getDeletedOptions.populate) {
|
|
1718
|
-
const populateSpec = getDeletedOptions.populate;
|
|
1719
|
-
if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
1720
|
-
else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
|
|
1721
|
-
else query = query.populate(populateSpec);
|
|
1722
|
-
}
|
|
1723
|
-
if (getDeletedOptions.lean !== false) query = query.lean();
|
|
1724
|
-
const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
|
|
1725
|
-
const pages = Math.ceil(total / limit);
|
|
1726
|
-
return {
|
|
1727
|
-
method: "offset",
|
|
1728
|
-
docs,
|
|
1729
|
-
page,
|
|
1730
|
-
limit,
|
|
1731
|
-
total,
|
|
1732
|
-
pages,
|
|
1733
|
-
hasNext: page < pages,
|
|
1734
|
-
hasPrev: page > 1
|
|
1735
|
-
};
|
|
1736
|
-
};
|
|
1737
|
-
if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
|
|
1738
|
-
else repo.getDeleted = getDeletedMethod.bind(repo);
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
};
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
//#endregion
|
|
1745
|
-
//#region src/plugins/method-registry.plugin.ts
|
|
1746
|
-
/**
|
|
1747
|
-
* Method registry plugin that enables dynamic method registration
|
|
1748
|
-
*/
|
|
1749
|
-
function methodRegistryPlugin() {
|
|
1750
|
-
return {
|
|
1751
|
-
name: "method-registry",
|
|
1752
|
-
apply(repo) {
|
|
1753
|
-
const registeredMethods = [];
|
|
2317
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
1754
2318
|
/**
|
|
1755
|
-
*
|
|
2319
|
+
* after:getById - Cache the result
|
|
1756
2320
|
*/
|
|
1757
|
-
repo.
|
|
1758
|
-
|
|
1759
|
-
if (
|
|
1760
|
-
if (
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2321
|
+
repo.on("after:getById", async (payload) => {
|
|
2322
|
+
const { context, result } = payload;
|
|
2323
|
+
if (context._cacheHit) return;
|
|
2324
|
+
if (context.skipCache) return;
|
|
2325
|
+
if (result === null) return;
|
|
2326
|
+
const id = String(context.id);
|
|
2327
|
+
const key = byIdKey(config.prefix, model, id, {
|
|
2328
|
+
select: context.select,
|
|
2329
|
+
populate: context.populate,
|
|
2330
|
+
lean: context.lean
|
|
1766
2331
|
});
|
|
1767
|
-
|
|
2332
|
+
const ttl = context.cacheTtl ?? config.byIdTtl;
|
|
2333
|
+
try {
|
|
2334
|
+
await config.adapter.set(key, result, ttl);
|
|
2335
|
+
trackByIdKey(id, key);
|
|
2336
|
+
stats.sets++;
|
|
2337
|
+
log(`Cached getById result:`, key);
|
|
2338
|
+
} catch (e) {
|
|
2339
|
+
log(`Failed to cache getById:`, e);
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
1768
2342
|
/**
|
|
1769
|
-
*
|
|
2343
|
+
* after:getByQuery - Cache the result
|
|
2344
|
+
*/
|
|
2345
|
+
repo.on("after:getByQuery", async (payload) => {
|
|
2346
|
+
const { context, result } = payload;
|
|
2347
|
+
if (context._cacheHit) return;
|
|
2348
|
+
if (context.skipCache) return;
|
|
2349
|
+
if (result === null) return;
|
|
2350
|
+
const collectionVersion = await getVersion();
|
|
2351
|
+
const query = context.query || {};
|
|
2352
|
+
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2353
|
+
select: context.select,
|
|
2354
|
+
populate: context.populate
|
|
2355
|
+
});
|
|
2356
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2357
|
+
try {
|
|
2358
|
+
await config.adapter.set(key, result, ttl);
|
|
2359
|
+
stats.sets++;
|
|
2360
|
+
log(`Cached getByQuery result:`, key);
|
|
2361
|
+
} catch (e) {
|
|
2362
|
+
log(`Failed to cache getByQuery:`, e);
|
|
2363
|
+
}
|
|
2364
|
+
});
|
|
2365
|
+
/**
|
|
2366
|
+
* after:getAll - Cache the result
|
|
2367
|
+
*/
|
|
2368
|
+
repo.on("after:getAll", async (payload) => {
|
|
2369
|
+
const { context, result } = payload;
|
|
2370
|
+
if (context._cacheHit) return;
|
|
2371
|
+
if (context.skipCache) return;
|
|
2372
|
+
const limit = context.limit;
|
|
2373
|
+
if (limit && limit > config.skipIfLargeLimit) return;
|
|
2374
|
+
const collectionVersion = await getVersion();
|
|
2375
|
+
const params = {
|
|
2376
|
+
filters: context.filters,
|
|
2377
|
+
sort: context.sort,
|
|
2378
|
+
page: context.page,
|
|
2379
|
+
limit,
|
|
2380
|
+
after: context.after,
|
|
2381
|
+
select: context.select,
|
|
2382
|
+
populate: context.populate,
|
|
2383
|
+
search: context.search,
|
|
2384
|
+
mode: context.mode,
|
|
2385
|
+
lean: context.lean,
|
|
2386
|
+
readPreference: context.readPreference,
|
|
2387
|
+
hint: context.hint,
|
|
2388
|
+
maxTimeMS: context.maxTimeMS,
|
|
2389
|
+
countStrategy: context.countStrategy
|
|
2390
|
+
};
|
|
2391
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2392
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2393
|
+
try {
|
|
2394
|
+
await config.adapter.set(key, result, ttl);
|
|
2395
|
+
stats.sets++;
|
|
2396
|
+
log(`Cached getAll result:`, key);
|
|
2397
|
+
} catch (e) {
|
|
2398
|
+
log(`Failed to cache getAll:`, e);
|
|
2399
|
+
}
|
|
2400
|
+
});
|
|
2401
|
+
/**
|
|
2402
|
+
* after:create - Bump version to invalidate list caches
|
|
2403
|
+
*/
|
|
2404
|
+
repo.on("after:create", async () => {
|
|
2405
|
+
await bumpVersion();
|
|
2406
|
+
});
|
|
2407
|
+
/**
|
|
2408
|
+
* after:createMany - Bump version to invalidate list caches
|
|
2409
|
+
*/
|
|
2410
|
+
repo.on("after:createMany", async () => {
|
|
2411
|
+
await bumpVersion();
|
|
2412
|
+
});
|
|
2413
|
+
/**
|
|
2414
|
+
* after:update - Invalidate by ID and bump version
|
|
2415
|
+
*/
|
|
2416
|
+
repo.on("after:update", async (payload) => {
|
|
2417
|
+
const { context } = payload;
|
|
2418
|
+
const id = String(context.id);
|
|
2419
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
2420
|
+
});
|
|
2421
|
+
/**
|
|
2422
|
+
* after:updateMany - Bump version (can't track individual IDs efficiently)
|
|
2423
|
+
*/
|
|
2424
|
+
repo.on("after:updateMany", async () => {
|
|
2425
|
+
await bumpVersion();
|
|
2426
|
+
});
|
|
2427
|
+
/**
|
|
2428
|
+
* after:delete - Invalidate by ID and bump version
|
|
2429
|
+
*/
|
|
2430
|
+
repo.on("after:delete", async (payload) => {
|
|
2431
|
+
const { context } = payload;
|
|
2432
|
+
const id = String(context.id);
|
|
2433
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
2434
|
+
});
|
|
2435
|
+
/**
|
|
2436
|
+
* after:deleteMany - Bump version
|
|
2437
|
+
*/
|
|
2438
|
+
repo.on("after:deleteMany", async () => {
|
|
2439
|
+
await bumpVersion();
|
|
2440
|
+
});
|
|
2441
|
+
/**
|
|
2442
|
+
* after:bulkWrite - Bump version (bulk ops may insert/update/delete)
|
|
1770
2443
|
*/
|
|
1771
|
-
repo.
|
|
1772
|
-
|
|
2444
|
+
repo.on("after:bulkWrite", async () => {
|
|
2445
|
+
await bumpVersion();
|
|
2446
|
+
});
|
|
2447
|
+
/**
|
|
2448
|
+
* Invalidate cache for a specific document
|
|
2449
|
+
* Use when document was updated outside this service
|
|
2450
|
+
*
|
|
2451
|
+
* @example
|
|
2452
|
+
* await userRepo.invalidateCache('507f1f77bcf86cd799439011');
|
|
2453
|
+
*/
|
|
2454
|
+
repo.invalidateCache = async (id) => {
|
|
2455
|
+
await invalidateById(id);
|
|
2456
|
+
log(`Manual invalidation for ID:`, id);
|
|
1773
2457
|
};
|
|
1774
2458
|
/**
|
|
1775
|
-
*
|
|
2459
|
+
* Invalidate all list caches for this model
|
|
2460
|
+
* Use when bulk changes happened outside this service
|
|
2461
|
+
*
|
|
2462
|
+
* @example
|
|
2463
|
+
* await userRepo.invalidateListCache();
|
|
1776
2464
|
*/
|
|
1777
|
-
repo.
|
|
1778
|
-
|
|
2465
|
+
repo.invalidateListCache = async () => {
|
|
2466
|
+
await bumpVersion();
|
|
2467
|
+
log(`Manual list cache invalidation for ${model}`);
|
|
1779
2468
|
};
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
* @example
|
|
1795
|
-
* const repo = new Repository(Model, [
|
|
1796
|
-
* validationChainPlugin([
|
|
1797
|
-
* requireField('email'),
|
|
1798
|
-
* uniqueField('email', 'Email already exists'),
|
|
1799
|
-
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
1800
|
-
* ])
|
|
1801
|
-
* ]);
|
|
1802
|
-
*/
|
|
1803
|
-
function validationChainPlugin(validators = [], options = {}) {
|
|
1804
|
-
const { stopOnFirstError = true } = options;
|
|
1805
|
-
validators.forEach((v, idx) => {
|
|
1806
|
-
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
1807
|
-
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
1808
|
-
});
|
|
1809
|
-
const validatorsByOperation = {
|
|
1810
|
-
create: [],
|
|
1811
|
-
update: [],
|
|
1812
|
-
delete: [],
|
|
1813
|
-
createMany: []
|
|
1814
|
-
};
|
|
1815
|
-
const allOperationsValidators = [];
|
|
1816
|
-
validators.forEach((v) => {
|
|
1817
|
-
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
1818
|
-
else v.operations.forEach((op) => {
|
|
1819
|
-
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
1820
|
-
});
|
|
1821
|
-
});
|
|
1822
|
-
return {
|
|
1823
|
-
name: "validation-chain",
|
|
1824
|
-
apply(repo) {
|
|
1825
|
-
const getValidatorsForOperation = (operation) => {
|
|
1826
|
-
const specific = validatorsByOperation[operation] || [];
|
|
1827
|
-
return [...allOperationsValidators, ...specific];
|
|
1828
|
-
};
|
|
1829
|
-
const runValidators = async (operation, context) => {
|
|
1830
|
-
const operationValidators = getValidatorsForOperation(operation);
|
|
1831
|
-
const errors = [];
|
|
1832
|
-
for (const validator of operationValidators) try {
|
|
1833
|
-
await validator.validate(context, repo);
|
|
1834
|
-
} catch (error) {
|
|
1835
|
-
if (stopOnFirstError) throw error;
|
|
1836
|
-
errors.push({
|
|
1837
|
-
validator: validator.name,
|
|
1838
|
-
error: error.message || String(error)
|
|
1839
|
-
});
|
|
2469
|
+
/**
|
|
2470
|
+
* Invalidate ALL cache entries for this model
|
|
2471
|
+
* Nuclear option - use sparingly
|
|
2472
|
+
*
|
|
2473
|
+
* @example
|
|
2474
|
+
* await userRepo.invalidateAllCache();
|
|
2475
|
+
*/
|
|
2476
|
+
repo.invalidateAllCache = async () => {
|
|
2477
|
+
if (config.adapter.clear) try {
|
|
2478
|
+
await config.adapter.clear(modelPattern(config.prefix, model));
|
|
2479
|
+
stats.invalidations++;
|
|
2480
|
+
log(`Full cache invalidation for ${model}`);
|
|
2481
|
+
} catch (e) {
|
|
2482
|
+
log(`Failed full cache invalidation for ${model}:`, e);
|
|
1840
2483
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
throw err;
|
|
2484
|
+
else {
|
|
2485
|
+
await bumpVersion();
|
|
2486
|
+
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
1845
2487
|
}
|
|
1846
2488
|
};
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
*
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
if (condition(context)) throw createError(403, errorMessage);
|
|
1866
|
-
}
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
/**
|
|
1870
|
-
* Require a field to be present
|
|
1871
|
-
*/
|
|
1872
|
-
function requireField(field, operations = ["create"]) {
|
|
1873
|
-
return {
|
|
1874
|
-
name: `require-${field}`,
|
|
1875
|
-
operations,
|
|
1876
|
-
validate: (context) => {
|
|
1877
|
-
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
1878
|
-
}
|
|
1879
|
-
};
|
|
1880
|
-
}
|
|
1881
|
-
/**
|
|
1882
|
-
* Auto-inject a value if not present
|
|
1883
|
-
*/
|
|
1884
|
-
function autoInject(field, getter, operations = ["create"]) {
|
|
1885
|
-
return {
|
|
1886
|
-
name: `auto-inject-${field}`,
|
|
1887
|
-
operations,
|
|
1888
|
-
validate: (context) => {
|
|
1889
|
-
if (context.data && !(field in context.data)) {
|
|
1890
|
-
const value = getter(context);
|
|
1891
|
-
if (value !== null && value !== void 0) context.data[field] = value;
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
};
|
|
1895
|
-
}
|
|
1896
|
-
/**
|
|
1897
|
-
* Make a field immutable (cannot be updated)
|
|
1898
|
-
*/
|
|
1899
|
-
function immutableField(field) {
|
|
1900
|
-
return {
|
|
1901
|
-
name: `immutable-${field}`,
|
|
1902
|
-
operations: ["update"],
|
|
1903
|
-
validate: (context) => {
|
|
1904
|
-
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
1905
|
-
}
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
1908
|
-
/**
|
|
1909
|
-
* Ensure field value is unique
|
|
1910
|
-
*/
|
|
1911
|
-
function uniqueField(field, errorMessage) {
|
|
1912
|
-
return {
|
|
1913
|
-
name: `unique-${field}`,
|
|
1914
|
-
operations: ["create", "update"],
|
|
1915
|
-
validate: async (context, repo) => {
|
|
1916
|
-
if (!context.data || !context.data[field]) return;
|
|
1917
|
-
if (!repo) {
|
|
1918
|
-
warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
|
|
1919
|
-
return;
|
|
1920
|
-
}
|
|
1921
|
-
const query = { [field]: context.data[field] };
|
|
1922
|
-
const getByQuery = repo.getByQuery;
|
|
1923
|
-
if (typeof getByQuery !== "function") {
|
|
1924
|
-
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
1925
|
-
return;
|
|
1926
|
-
}
|
|
1927
|
-
const existing = await getByQuery.call(repo, query, {
|
|
1928
|
-
select: "_id",
|
|
1929
|
-
lean: true,
|
|
1930
|
-
throwOnNotFound: false
|
|
1931
|
-
});
|
|
1932
|
-
if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
2489
|
+
/**
|
|
2490
|
+
* Get cache statistics for monitoring
|
|
2491
|
+
*
|
|
2492
|
+
* @example
|
|
2493
|
+
* const stats = userRepo.getCacheStats();
|
|
2494
|
+
* console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
|
|
2495
|
+
*/
|
|
2496
|
+
repo.getCacheStats = () => ({ ...stats });
|
|
2497
|
+
/**
|
|
2498
|
+
* Reset cache statistics
|
|
2499
|
+
*/
|
|
2500
|
+
repo.resetCacheStats = () => {
|
|
2501
|
+
stats.hits = 0;
|
|
2502
|
+
stats.misses = 0;
|
|
2503
|
+
stats.sets = 0;
|
|
2504
|
+
stats.invalidations = 0;
|
|
2505
|
+
stats.errors = 0;
|
|
2506
|
+
};
|
|
1933
2507
|
}
|
|
1934
2508
|
};
|
|
1935
2509
|
}
|
|
1936
|
-
|
|
1937
2510
|
//#endregion
|
|
1938
|
-
//#region src/plugins/
|
|
1939
|
-
/**
|
|
1940
|
-
* MongoDB Operations Plugin
|
|
1941
|
-
*
|
|
1942
|
-
* Adds MongoDB-specific operations to repositories.
|
|
1943
|
-
* Requires method-registry.plugin.js to be loaded first.
|
|
1944
|
-
*/
|
|
2511
|
+
//#region src/plugins/cascade.plugin.ts
|
|
1945
2512
|
/**
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
* Adds MongoDB-specific atomic operations to repositories:
|
|
1949
|
-
* - upsert: Create or update document
|
|
1950
|
-
* - increment/decrement: Atomic numeric operations
|
|
1951
|
-
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
1952
|
-
* - setField/unsetField/renameField: Field operations
|
|
1953
|
-
* - multiplyField: Multiply numeric field
|
|
1954
|
-
* - setMin/setMax: Conditional min/max updates
|
|
2513
|
+
* Cascade Delete Plugin
|
|
2514
|
+
* Automatically deletes related documents when a parent document is deleted
|
|
1955
2515
|
*
|
|
1956
|
-
* @example
|
|
2516
|
+
* @example
|
|
1957
2517
|
* ```typescript
|
|
1958
|
-
*
|
|
2518
|
+
* import mongoose from 'mongoose';
|
|
2519
|
+
* import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
2520
|
+
*
|
|
2521
|
+
* const productRepo = new Repository(Product, [
|
|
1959
2522
|
* methodRegistryPlugin(),
|
|
1960
|
-
*
|
|
2523
|
+
* cascadePlugin({
|
|
2524
|
+
* relations: [
|
|
2525
|
+
* { model: 'StockEntry', foreignKey: 'product' },
|
|
2526
|
+
* { model: 'StockMovement', foreignKey: 'product' },
|
|
2527
|
+
* ]
|
|
2528
|
+
* })
|
|
1961
2529
|
* ]);
|
|
1962
2530
|
*
|
|
1963
|
-
* //
|
|
1964
|
-
* await
|
|
1965
|
-
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
2531
|
+
* // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
|
|
2532
|
+
* await productRepo.delete(productId);
|
|
1966
2533
|
* ```
|
|
2534
|
+
*/
|
|
2535
|
+
/**
|
|
2536
|
+
* Cascade delete plugin
|
|
1967
2537
|
*
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
1970
|
-
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
1971
|
-
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
1972
|
-
*
|
|
1973
|
-
* class ProductRepo extends Repository<IProduct> {
|
|
1974
|
-
* // Add your custom methods here
|
|
1975
|
-
* }
|
|
1976
|
-
*
|
|
1977
|
-
* // Create with type assertion to get autocomplete for plugin methods
|
|
1978
|
-
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
1979
|
-
*
|
|
1980
|
-
* const repo = new ProductRepo(ProductModel, [
|
|
1981
|
-
* methodRegistryPlugin(),
|
|
1982
|
-
* mongoOperationsPlugin(),
|
|
1983
|
-
* ]) as ProductRepoWithPlugins;
|
|
2538
|
+
* Deletes related documents after the parent document is deleted.
|
|
2539
|
+
* Works with both hard delete and soft delete scenarios.
|
|
1984
2540
|
*
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
1987
|
-
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
1988
|
-
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
1989
|
-
* ```
|
|
2541
|
+
* @param options - Cascade configuration
|
|
2542
|
+
* @returns Plugin
|
|
1990
2543
|
*/
|
|
1991
|
-
function
|
|
2544
|
+
function cascadePlugin(options) {
|
|
2545
|
+
const { relations, parallel = true, logger } = options;
|
|
2546
|
+
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
1992
2547
|
return {
|
|
1993
|
-
name: "
|
|
2548
|
+
name: "cascade",
|
|
1994
2549
|
apply(repo) {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
/**
|
|
2056
|
-
* Rename field in document
|
|
2057
|
-
*/
|
|
2058
|
-
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
2059
|
-
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
2060
|
-
});
|
|
2061
|
-
/**
|
|
2062
|
-
* Multiply numeric field by value
|
|
2063
|
-
*/
|
|
2064
|
-
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
2065
|
-
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
2066
|
-
});
|
|
2067
|
-
/**
|
|
2068
|
-
* Set field to minimum value (only if current value is greater)
|
|
2069
|
-
*/
|
|
2070
|
-
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
2071
|
-
return applyOperator.call(this, id, field, value, "$min", options);
|
|
2550
|
+
repo.on("after:delete", async (payload) => {
|
|
2551
|
+
const { context } = payload;
|
|
2552
|
+
const deletedId = context.id;
|
|
2553
|
+
if (!deletedId) {
|
|
2554
|
+
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const isSoftDelete = context.softDeleted === true;
|
|
2558
|
+
const cascadeDelete = async (relation) => {
|
|
2559
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2560
|
+
if (!RelatedModel) {
|
|
2561
|
+
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2562
|
+
parentModel: context.model,
|
|
2563
|
+
parentId: String(deletedId)
|
|
2564
|
+
});
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const query = { [relation.foreignKey]: deletedId };
|
|
2568
|
+
try {
|
|
2569
|
+
if (relation.softDelete ?? isSoftDelete) {
|
|
2570
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
2571
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
2572
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2573
|
+
}, { session: context.session });
|
|
2574
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
|
|
2575
|
+
parentModel: context.model,
|
|
2576
|
+
parentId: String(deletedId),
|
|
2577
|
+
relatedModel: relation.model,
|
|
2578
|
+
foreignKey: relation.foreignKey,
|
|
2579
|
+
count: updateResult.modifiedCount
|
|
2580
|
+
});
|
|
2581
|
+
} else {
|
|
2582
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2583
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
|
|
2584
|
+
parentModel: context.model,
|
|
2585
|
+
parentId: String(deletedId),
|
|
2586
|
+
relatedModel: relation.model,
|
|
2587
|
+
foreignKey: relation.foreignKey,
|
|
2588
|
+
count: deleteResult.deletedCount
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
} catch (error) {
|
|
2592
|
+
logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
|
|
2593
|
+
parentModel: context.model,
|
|
2594
|
+
parentId: String(deletedId),
|
|
2595
|
+
relatedModel: relation.model,
|
|
2596
|
+
foreignKey: relation.foreignKey,
|
|
2597
|
+
error: error.message
|
|
2598
|
+
});
|
|
2599
|
+
throw error;
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
if (parallel) {
|
|
2603
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
|
|
2604
|
+
if (failures.length) {
|
|
2605
|
+
const err = failures[0].reason;
|
|
2606
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2607
|
+
throw err;
|
|
2608
|
+
}
|
|
2609
|
+
} else for (const relation of relations) await cascadeDelete(relation);
|
|
2072
2610
|
});
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
return applyOperator.call(this, id, field, value, "$max", options);
|
|
2611
|
+
repo.on("before:deleteMany", async (context) => {
|
|
2612
|
+
const query = context.query;
|
|
2613
|
+
if (!query || Object.keys(query).length === 0) return;
|
|
2614
|
+
context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
|
|
2078
2615
|
});
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2616
|
+
repo.on("after:deleteMany", async (payload) => {
|
|
2617
|
+
const { context } = payload;
|
|
2618
|
+
const ids = context._cascadeIds;
|
|
2619
|
+
if (!ids || ids.length === 0) return;
|
|
2620
|
+
const isSoftDelete = context.softDeleted === true;
|
|
2621
|
+
const cascadeDeleteMany = async (relation) => {
|
|
2622
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2623
|
+
if (!RelatedModel) {
|
|
2624
|
+
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
const query = { [relation.foreignKey]: { $in: ids } };
|
|
2628
|
+
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
2629
|
+
try {
|
|
2630
|
+
if (shouldSoftDelete) {
|
|
2631
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
2632
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
2633
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2634
|
+
}, { session: context.session });
|
|
2635
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
|
|
2636
|
+
parentModel: context.model,
|
|
2637
|
+
parentCount: ids.length,
|
|
2638
|
+
relatedModel: relation.model,
|
|
2639
|
+
foreignKey: relation.foreignKey,
|
|
2640
|
+
count: updateResult.modifiedCount
|
|
2641
|
+
});
|
|
2642
|
+
} else {
|
|
2643
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2644
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
|
|
2645
|
+
parentModel: context.model,
|
|
2646
|
+
parentCount: ids.length,
|
|
2647
|
+
relatedModel: relation.model,
|
|
2648
|
+
foreignKey: relation.foreignKey,
|
|
2649
|
+
count: deleteResult.deletedCount
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
|
|
2654
|
+
parentModel: context.model,
|
|
2655
|
+
relatedModel: relation.model,
|
|
2656
|
+
foreignKey: relation.foreignKey,
|
|
2657
|
+
error: error.message
|
|
2658
|
+
});
|
|
2659
|
+
throw error;
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
if (parallel) {
|
|
2663
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
|
|
2664
|
+
if (failures.length) {
|
|
2665
|
+
const err = failures[0].reason;
|
|
2666
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2667
|
+
throw err;
|
|
2668
|
+
}
|
|
2669
|
+
} else for (const relation of relations) await cascadeDeleteMany(relation);
|
|
2131
2670
|
});
|
|
2132
2671
|
}
|
|
2133
2672
|
};
|
|
2134
2673
|
}
|
|
2135
|
-
|
|
2136
2674
|
//#endregion
|
|
2137
|
-
//#region src/plugins/
|
|
2675
|
+
//#region src/plugins/custom-id.plugin.ts
|
|
2138
2676
|
/**
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
repo.registerMethod("updateMany", async function(query, data, options = {}) {
|
|
2159
|
-
const context = await this._buildContext.call(this, "updateMany", {
|
|
2160
|
-
query,
|
|
2161
|
-
data,
|
|
2162
|
-
...options
|
|
2163
|
-
});
|
|
2164
|
-
try {
|
|
2165
|
-
const finalQuery = context.query || query;
|
|
2166
|
-
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
|
|
2167
|
-
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.");
|
|
2168
|
-
const result = await this.Model.updateMany(finalQuery, data, {
|
|
2169
|
-
runValidators: true,
|
|
2170
|
-
session: options.session,
|
|
2171
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
2172
|
-
}).exec();
|
|
2173
|
-
await this.emitAsync("after:updateMany", {
|
|
2174
|
-
context,
|
|
2175
|
-
result
|
|
2176
|
-
});
|
|
2177
|
-
return result;
|
|
2178
|
-
} catch (error) {
|
|
2179
|
-
this.emit("error:updateMany", {
|
|
2180
|
-
context,
|
|
2181
|
-
error
|
|
2182
|
-
});
|
|
2183
|
-
throw this._handleError.call(this, error);
|
|
2184
|
-
}
|
|
2185
|
-
});
|
|
2186
|
-
/**
|
|
2187
|
-
* Execute heterogeneous bulk write operations in a single database call.
|
|
2188
|
-
*
|
|
2189
|
-
* Supports insertOne, updateOne, updateMany, deleteOne, deleteMany, and replaceOne
|
|
2190
|
-
* operations mixed together for maximum efficiency.
|
|
2191
|
-
*
|
|
2192
|
-
* @example
|
|
2193
|
-
* await repo.bulkWrite([
|
|
2194
|
-
* { insertOne: { document: { name: 'New Item', price: 10 } } },
|
|
2195
|
-
* { updateOne: { filter: { _id: id1 }, update: { $inc: { views: 1 } } } },
|
|
2196
|
-
* { updateMany: { filter: { status: 'draft' }, update: { $set: { status: 'published' } } } },
|
|
2197
|
-
* { deleteOne: { filter: { _id: id2 } } },
|
|
2198
|
-
* ]);
|
|
2199
|
-
*/
|
|
2200
|
-
repo.registerMethod("bulkWrite", async function(operations, options = {}) {
|
|
2201
|
-
const context = await this._buildContext.call(this, "bulkWrite", {
|
|
2202
|
-
operations,
|
|
2203
|
-
...options
|
|
2204
|
-
});
|
|
2205
|
-
try {
|
|
2206
|
-
const finalOps = context.operations || operations;
|
|
2207
|
-
if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
|
|
2208
|
-
const result = await this.Model.bulkWrite(finalOps, {
|
|
2209
|
-
ordered: options.ordered ?? true,
|
|
2210
|
-
session: options.session
|
|
2211
|
-
});
|
|
2212
|
-
const bulkResult = {
|
|
2213
|
-
ok: result.ok,
|
|
2214
|
-
insertedCount: result.insertedCount,
|
|
2215
|
-
upsertedCount: result.upsertedCount,
|
|
2216
|
-
matchedCount: result.matchedCount,
|
|
2217
|
-
modifiedCount: result.modifiedCount,
|
|
2218
|
-
deletedCount: result.deletedCount,
|
|
2219
|
-
insertedIds: result.insertedIds,
|
|
2220
|
-
upsertedIds: result.upsertedIds
|
|
2221
|
-
};
|
|
2222
|
-
await this.emitAsync("after:bulkWrite", {
|
|
2223
|
-
context,
|
|
2224
|
-
result: bulkResult
|
|
2225
|
-
});
|
|
2226
|
-
return bulkResult;
|
|
2227
|
-
} catch (error) {
|
|
2228
|
-
this.emit("error:bulkWrite", {
|
|
2229
|
-
context,
|
|
2230
|
-
error
|
|
2231
|
-
});
|
|
2232
|
-
throw this._handleError.call(this, error);
|
|
2233
|
-
}
|
|
2234
|
-
});
|
|
2235
|
-
/**
|
|
2236
|
-
* Delete multiple documents
|
|
2237
|
-
*/
|
|
2238
|
-
repo.registerMethod("deleteMany", async function(query, options = {}) {
|
|
2239
|
-
const context = await this._buildContext.call(this, "deleteMany", {
|
|
2240
|
-
query,
|
|
2241
|
-
...options
|
|
2242
|
-
});
|
|
2243
|
-
try {
|
|
2244
|
-
const finalQuery = context.query || query;
|
|
2245
|
-
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
|
|
2246
|
-
const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
|
|
2247
|
-
await this.emitAsync("after:deleteMany", {
|
|
2248
|
-
context,
|
|
2249
|
-
result
|
|
2250
|
-
});
|
|
2251
|
-
return result;
|
|
2252
|
-
} catch (error) {
|
|
2253
|
-
this.emit("error:deleteMany", {
|
|
2254
|
-
context,
|
|
2255
|
-
error
|
|
2256
|
-
});
|
|
2257
|
-
throw this._handleError.call(this, error);
|
|
2258
|
-
}
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
};
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
//#endregion
|
|
2265
|
-
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
2266
|
-
/**
|
|
2267
|
-
* Aggregate helpers plugin
|
|
2268
|
-
*
|
|
2269
|
-
* @example
|
|
2270
|
-
* const repo = new Repository(Model, [
|
|
2271
|
-
* methodRegistryPlugin(),
|
|
2272
|
-
* aggregateHelpersPlugin(),
|
|
2677
|
+
* Custom ID Plugin
|
|
2678
|
+
*
|
|
2679
|
+
* Generates custom document IDs using pluggable generators.
|
|
2680
|
+
* Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
|
|
2681
|
+
* date-partitioned sequences, and fully custom generators.
|
|
2682
|
+
*
|
|
2683
|
+
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
2684
|
+
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
2685
|
+
*
|
|
2686
|
+
* @example Basic sequential counter
|
|
2687
|
+
* ```typescript
|
|
2688
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
2689
|
+
* customIdPlugin({
|
|
2690
|
+
* field: 'invoiceNumber',
|
|
2691
|
+
* generator: sequentialId({
|
|
2692
|
+
* prefix: 'INV',
|
|
2693
|
+
* model: InvoiceModel,
|
|
2694
|
+
* }),
|
|
2695
|
+
* }),
|
|
2273
2696
|
* ]);
|
|
2274
|
-
*
|
|
2275
|
-
* const
|
|
2276
|
-
*
|
|
2697
|
+
*
|
|
2698
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
2699
|
+
* // inv.invoiceNumber → "INV-0001"
|
|
2700
|
+
* ```
|
|
2701
|
+
*
|
|
2702
|
+
* @example Date-partitioned counter (resets monthly)
|
|
2703
|
+
* ```typescript
|
|
2704
|
+
* const billRepo = new Repository(BillModel, [
|
|
2705
|
+
* customIdPlugin({
|
|
2706
|
+
* field: 'billNumber',
|
|
2707
|
+
* generator: dateSequentialId({
|
|
2708
|
+
* prefix: 'BILL',
|
|
2709
|
+
* model: BillModel,
|
|
2710
|
+
* partition: 'monthly',
|
|
2711
|
+
* separator: '-',
|
|
2712
|
+
* padding: 4,
|
|
2713
|
+
* }),
|
|
2714
|
+
* }),
|
|
2715
|
+
* ]);
|
|
2716
|
+
*
|
|
2717
|
+
* const bill = await billRepo.create({ total: 250 });
|
|
2718
|
+
* // bill.billNumber → "BILL-2026-02-0001"
|
|
2719
|
+
* ```
|
|
2720
|
+
*
|
|
2721
|
+
* @example Custom generator function
|
|
2722
|
+
* ```typescript
|
|
2723
|
+
* const orderRepo = new Repository(OrderModel, [
|
|
2724
|
+
* customIdPlugin({
|
|
2725
|
+
* field: 'orderRef',
|
|
2726
|
+
* generator: async (context) => {
|
|
2727
|
+
* const region = context.data?.region || 'US';
|
|
2728
|
+
* const seq = await getNextSequence('orders');
|
|
2729
|
+
* return `ORD-${region}-${seq}`;
|
|
2730
|
+
* },
|
|
2731
|
+
* }),
|
|
2732
|
+
* ]);
|
|
2733
|
+
* ```
|
|
2277
2734
|
*/
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
/**
|
|
2302
|
-
* Sum field values
|
|
2303
|
-
*/
|
|
2304
|
-
repo.registerMethod("sum", async function(field, query = {}, options = {}) {
|
|
2305
|
-
return aggregateOperation.call(this, field, "$sum", "total", query, options);
|
|
2306
|
-
});
|
|
2307
|
-
/**
|
|
2308
|
-
* Average field values
|
|
2309
|
-
*/
|
|
2310
|
-
repo.registerMethod("average", async function(field, query = {}, options = {}) {
|
|
2311
|
-
return aggregateOperation.call(this, field, "$avg", "avg", query, options);
|
|
2312
|
-
});
|
|
2313
|
-
/**
|
|
2314
|
-
* Get minimum value
|
|
2315
|
-
*/
|
|
2316
|
-
repo.registerMethod("min", async function(field, query = {}, options = {}) {
|
|
2317
|
-
return aggregateOperation.call(this, field, "$min", "min", query, options);
|
|
2318
|
-
});
|
|
2319
|
-
/**
|
|
2320
|
-
* Get maximum value
|
|
2321
|
-
*/
|
|
2322
|
-
repo.registerMethod("max", async function(field, query = {}, options = {}) {
|
|
2323
|
-
return aggregateOperation.call(this, field, "$max", "max", query, options);
|
|
2324
|
-
});
|
|
2325
|
-
}
|
|
2326
|
-
};
|
|
2735
|
+
/** Schema for the internal counters collection */
|
|
2736
|
+
const counterSchema = new mongoose.Schema({
|
|
2737
|
+
_id: {
|
|
2738
|
+
type: String,
|
|
2739
|
+
required: true
|
|
2740
|
+
},
|
|
2741
|
+
seq: {
|
|
2742
|
+
type: Number,
|
|
2743
|
+
default: 0
|
|
2744
|
+
}
|
|
2745
|
+
}, {
|
|
2746
|
+
collection: "_mongokit_counters",
|
|
2747
|
+
versionKey: false
|
|
2748
|
+
});
|
|
2749
|
+
/**
|
|
2750
|
+
* Get or create the Counter model on the given connection.
|
|
2751
|
+
* Falls back to the default mongoose connection if none is provided.
|
|
2752
|
+
* Lazy-init to avoid model registration errors if mongoose isn't connected yet.
|
|
2753
|
+
*/
|
|
2754
|
+
function getCounterModel(connection) {
|
|
2755
|
+
const conn = connection ?? mongoose.connection;
|
|
2756
|
+
if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
|
|
2757
|
+
return conn.model("_MongoKitCounter", counterSchema);
|
|
2327
2758
|
}
|
|
2328
|
-
|
|
2329
|
-
//#endregion
|
|
2330
|
-
//#region src/plugins/subdocument.plugin.ts
|
|
2331
2759
|
/**
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2760
|
+
* Atomically increment and return the next sequence value for a given key.
|
|
2761
|
+
* Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
|
|
2762
|
+
* heavy concurrency.
|
|
2763
|
+
*
|
|
2764
|
+
* @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
|
|
2765
|
+
* @param increment - Value to increment by (default: 1)
|
|
2766
|
+
* @returns The next sequence number (after increment)
|
|
2767
|
+
*
|
|
2334
2768
|
* @example
|
|
2335
|
-
* const
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
2341
|
-
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
2769
|
+
* const seq = await getNextSequence('invoices');
|
|
2770
|
+
* // First call → 1, second → 2, ...
|
|
2771
|
+
*
|
|
2772
|
+
* @example Batch increment for createMany
|
|
2773
|
+
* const startSeq = await getNextSequence('invoices', 5);
|
|
2774
|
+
* // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
|
|
2342
2775
|
*/
|
|
2343
|
-
function
|
|
2776
|
+
async function getNextSequence(counterKey, increment = 1, connection) {
|
|
2777
|
+
const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
|
|
2778
|
+
upsert: true,
|
|
2779
|
+
returnDocument: "after"
|
|
2780
|
+
});
|
|
2781
|
+
if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
|
|
2782
|
+
return result.seq;
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* Generator: Simple sequential counter.
|
|
2786
|
+
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
2787
|
+
*
|
|
2788
|
+
* Uses atomic MongoDB counters — safe under concurrency.
|
|
2789
|
+
*
|
|
2790
|
+
* @example
|
|
2791
|
+
* ```typescript
|
|
2792
|
+
* customIdPlugin({
|
|
2793
|
+
* field: 'invoiceNumber',
|
|
2794
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
2795
|
+
* })
|
|
2796
|
+
* ```
|
|
2797
|
+
*/
|
|
2798
|
+
function sequentialId(options) {
|
|
2799
|
+
const { prefix, model, padding = 4, separator = "-", counterKey } = options;
|
|
2800
|
+
const key = counterKey || model.modelName;
|
|
2801
|
+
return async (context) => {
|
|
2802
|
+
const seq = await getNextSequence(key, 1, context._counterConnection);
|
|
2803
|
+
return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* Generator: Date-partitioned sequential counter.
|
|
2808
|
+
* Counter resets per period — great for invoice/bill numbering.
|
|
2809
|
+
*
|
|
2810
|
+
* Produces IDs like:
|
|
2811
|
+
* - yearly: `BILL-2026-0001`
|
|
2812
|
+
* - monthly: `BILL-2026-02-0001`
|
|
2813
|
+
* - daily: `BILL-2026-02-20-0001`
|
|
2814
|
+
*
|
|
2815
|
+
* @example
|
|
2816
|
+
* ```typescript
|
|
2817
|
+
* customIdPlugin({
|
|
2818
|
+
* field: 'billNumber',
|
|
2819
|
+
* generator: dateSequentialId({
|
|
2820
|
+
* prefix: 'BILL',
|
|
2821
|
+
* model: BillModel,
|
|
2822
|
+
* partition: 'monthly',
|
|
2823
|
+
* }),
|
|
2824
|
+
* })
|
|
2825
|
+
* ```
|
|
2826
|
+
*/
|
|
2827
|
+
function dateSequentialId(options) {
|
|
2828
|
+
const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
|
|
2829
|
+
return async (context) => {
|
|
2830
|
+
const now = /* @__PURE__ */ new Date();
|
|
2831
|
+
const year = String(now.getFullYear());
|
|
2832
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2833
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2834
|
+
let datePart;
|
|
2835
|
+
let counterKey;
|
|
2836
|
+
switch (partition) {
|
|
2837
|
+
case "yearly":
|
|
2838
|
+
datePart = year;
|
|
2839
|
+
counterKey = `${model.modelName}:${year}`;
|
|
2840
|
+
break;
|
|
2841
|
+
case "daily":
|
|
2842
|
+
datePart = `${year}${separator}${month}${separator}${day}`;
|
|
2843
|
+
counterKey = `${model.modelName}:${year}-${month}-${day}`;
|
|
2844
|
+
break;
|
|
2845
|
+
default:
|
|
2846
|
+
datePart = `${year}${separator}${month}`;
|
|
2847
|
+
counterKey = `${model.modelName}:${year}-${month}`;
|
|
2848
|
+
break;
|
|
2849
|
+
}
|
|
2850
|
+
const seq = await getNextSequence(counterKey, 1, context._counterConnection);
|
|
2851
|
+
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
/**
|
|
2855
|
+
* Generator: Prefix + random alphanumeric suffix.
|
|
2856
|
+
* Does NOT require a database round-trip — purely in-memory.
|
|
2857
|
+
*
|
|
2858
|
+
* Produces IDs like: `USR_a7b3xk9m2p1q`
|
|
2859
|
+
*
|
|
2860
|
+
* Good for: user-facing IDs where ordering doesn't matter.
|
|
2861
|
+
* Not suitable for sequential numbering.
|
|
2862
|
+
*
|
|
2863
|
+
* @example
|
|
2864
|
+
* ```typescript
|
|
2865
|
+
* customIdPlugin({
|
|
2866
|
+
* field: 'publicId',
|
|
2867
|
+
* generator: prefixedId({ prefix: 'USR', length: 10 }),
|
|
2868
|
+
* })
|
|
2869
|
+
* ```
|
|
2870
|
+
*/
|
|
2871
|
+
function prefixedId(options) {
|
|
2872
|
+
const { prefix, separator = "_", length = 12 } = options;
|
|
2873
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2874
|
+
return (_context) => {
|
|
2875
|
+
let result = "";
|
|
2876
|
+
const bytes = new Uint8Array(length);
|
|
2877
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
2878
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
2879
|
+
for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
|
|
2880
|
+
} else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
|
|
2881
|
+
return `${prefix}${separator}${result}`;
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Custom ID plugin — injects generated IDs into documents before creation.
|
|
2886
|
+
*
|
|
2887
|
+
* @param options - Configuration for ID generation
|
|
2888
|
+
* @returns Plugin instance
|
|
2889
|
+
*
|
|
2890
|
+
* @example
|
|
2891
|
+
* ```typescript
|
|
2892
|
+
* import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
|
|
2893
|
+
*
|
|
2894
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
2895
|
+
* customIdPlugin({
|
|
2896
|
+
* field: 'invoiceNumber',
|
|
2897
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
2898
|
+
* }),
|
|
2899
|
+
* ]);
|
|
2900
|
+
*
|
|
2901
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
2902
|
+
* console.log(inv.invoiceNumber); // "INV-0001"
|
|
2903
|
+
* ```
|
|
2904
|
+
*/
|
|
2905
|
+
function customIdPlugin(options) {
|
|
2906
|
+
const fieldName = options.field || "customId";
|
|
2907
|
+
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
2344
2908
|
return {
|
|
2345
|
-
name: "
|
|
2909
|
+
name: "custom-id",
|
|
2346
2910
|
apply(repo) {
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
});
|
|
2354
|
-
/**
|
|
2355
|
-
* Get subdocument from array
|
|
2356
|
-
*/
|
|
2357
|
-
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
2358
|
-
return this._executeQuery.call(this, async (Model) => {
|
|
2359
|
-
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
2360
|
-
if (!parent) throw createError(404, "Parent not found");
|
|
2361
|
-
const arrayField = parent[arrayPath];
|
|
2362
|
-
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
2363
|
-
const sub = arrayField.id(subId);
|
|
2364
|
-
if (!sub) throw createError(404, "Subdocument not found");
|
|
2365
|
-
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
2366
|
-
});
|
|
2911
|
+
const repoConnection = repo.Model.db;
|
|
2912
|
+
repo.on("before:create", async (context) => {
|
|
2913
|
+
if (!context.data) return;
|
|
2914
|
+
if (generateOnlyIfEmpty && context.data[fieldName]) return;
|
|
2915
|
+
context._counterConnection = repoConnection;
|
|
2916
|
+
context.data[fieldName] = await options.generator(context);
|
|
2367
2917
|
});
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
} } };
|
|
2381
|
-
const result = await Model.findOneAndUpdate(query, update, {
|
|
2382
|
-
returnDocument: "after",
|
|
2383
|
-
runValidators: true,
|
|
2384
|
-
session: options.session
|
|
2385
|
-
}).exec();
|
|
2386
|
-
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
2387
|
-
return result;
|
|
2918
|
+
repo.on("before:createMany", async (context) => {
|
|
2919
|
+
if (!context.dataArray) return;
|
|
2920
|
+
context._counterConnection = repoConnection;
|
|
2921
|
+
const docsNeedingIds = [];
|
|
2922
|
+
for (const doc of context.dataArray) {
|
|
2923
|
+
if (generateOnlyIfEmpty && doc[fieldName]) continue;
|
|
2924
|
+
docsNeedingIds.push(doc);
|
|
2925
|
+
}
|
|
2926
|
+
if (docsNeedingIds.length === 0) return;
|
|
2927
|
+
for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
|
|
2928
|
+
...context,
|
|
2929
|
+
data: doc
|
|
2388
2930
|
});
|
|
2389
2931
|
});
|
|
2390
|
-
/**
|
|
2391
|
-
* Delete subdocument from array
|
|
2392
|
-
*/
|
|
2393
|
-
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
2394
|
-
return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
2395
|
-
});
|
|
2396
2932
|
}
|
|
2397
2933
|
};
|
|
2398
2934
|
}
|
|
2399
|
-
|
|
2400
2935
|
//#endregion
|
|
2401
|
-
//#region src/plugins/
|
|
2402
|
-
|
|
2403
|
-
* Cache plugin factory
|
|
2404
|
-
*
|
|
2405
|
-
* @param options - Cache configuration
|
|
2406
|
-
* @returns Plugin instance
|
|
2407
|
-
*/
|
|
2408
|
-
function cachePlugin(options) {
|
|
2409
|
-
const config = {
|
|
2410
|
-
adapter: options.adapter,
|
|
2411
|
-
ttl: options.ttl ?? 60,
|
|
2412
|
-
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
2413
|
-
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
2414
|
-
prefix: options.prefix ?? "mk",
|
|
2415
|
-
debug: options.debug ?? false,
|
|
2416
|
-
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
|
|
2417
|
-
};
|
|
2418
|
-
const stats = {
|
|
2419
|
-
hits: 0,
|
|
2420
|
-
misses: 0,
|
|
2421
|
-
sets: 0,
|
|
2422
|
-
invalidations: 0,
|
|
2423
|
-
errors: 0
|
|
2424
|
-
};
|
|
2425
|
-
const log = (msg, data) => {
|
|
2426
|
-
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
2427
|
-
};
|
|
2936
|
+
//#region src/plugins/elastic.plugin.ts
|
|
2937
|
+
function elasticSearchPlugin(options) {
|
|
2428
2938
|
return {
|
|
2429
|
-
name: "
|
|
2939
|
+
name: "elastic-search",
|
|
2430
2940
|
apply(repo) {
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
try {
|
|
2443
|
-
return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
|
|
2444
|
-
} catch (e) {
|
|
2445
|
-
log(`Cache error in getVersion for ${model}:`, e);
|
|
2446
|
-
return 0;
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
/**
|
|
2450
|
-
* Bump collection version in the adapter (invalidates all list caches).
|
|
2451
|
-
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
2452
|
-
*/
|
|
2453
|
-
async function bumpVersion() {
|
|
2454
|
-
const newVersion = Date.now();
|
|
2455
|
-
try {
|
|
2456
|
-
await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
|
|
2457
|
-
stats.invalidations++;
|
|
2458
|
-
log(`Bumped version for ${model} to:`, newVersion);
|
|
2459
|
-
} catch (e) {
|
|
2460
|
-
log(`Failed to bump version for ${model}:`, e);
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
/**
|
|
2464
|
-
* Invalidate a specific document by ID (all shape variants).
|
|
2465
|
-
* Deletes every tracked shape-variant key individually via del(),
|
|
2466
|
-
* so adapters without pattern-based clear() still get full invalidation.
|
|
2467
|
-
*/
|
|
2468
|
-
async function invalidateById(id) {
|
|
2469
|
-
try {
|
|
2470
|
-
const baseKey = byIdKey(config.prefix, model, id);
|
|
2471
|
-
await config.adapter.del(baseKey);
|
|
2472
|
-
const trackedKeys = byIdKeyRegistry.get(id);
|
|
2473
|
-
if (trackedKeys) {
|
|
2474
|
-
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
|
|
2475
|
-
byIdKeyRegistry.delete(id);
|
|
2941
|
+
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
2942
|
+
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
2943
|
+
const { client, index, idField = "_id" } = options;
|
|
2944
|
+
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
2945
|
+
const from = Math.max(searchOptions.from || 0, 0);
|
|
2946
|
+
const esResponse = await client.search({
|
|
2947
|
+
index,
|
|
2948
|
+
body: {
|
|
2949
|
+
query: searchQuery,
|
|
2950
|
+
size: limit,
|
|
2951
|
+
from
|
|
2476
2952
|
}
|
|
2477
|
-
stats.invalidations++;
|
|
2478
|
-
log(`Invalidated byId cache for:`, id);
|
|
2479
|
-
} catch (e) {
|
|
2480
|
-
log(`Failed to invalidate byId cache:`, e);
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
/**
|
|
2484
|
-
* before:getById - Check cache for document
|
|
2485
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2486
|
-
*/
|
|
2487
|
-
repo.on("before:getById", async (context) => {
|
|
2488
|
-
if (context.skipCache) {
|
|
2489
|
-
log(`Skipping cache for getById: ${context.id}`);
|
|
2490
|
-
return;
|
|
2491
|
-
}
|
|
2492
|
-
const id = String(context.id);
|
|
2493
|
-
const key = byIdKey(config.prefix, model, id, {
|
|
2494
|
-
select: context.select,
|
|
2495
|
-
populate: context.populate,
|
|
2496
|
-
lean: context.lean
|
|
2497
2953
|
});
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2954
|
+
const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
|
|
2955
|
+
if (hits.length === 0) return {
|
|
2956
|
+
docs: [],
|
|
2957
|
+
total: 0,
|
|
2958
|
+
limit,
|
|
2959
|
+
from
|
|
2960
|
+
};
|
|
2961
|
+
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
2962
|
+
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
2963
|
+
const docsOrder = /* @__PURE__ */ new Map();
|
|
2964
|
+
const scores = /* @__PURE__ */ new Map();
|
|
2965
|
+
const ids = [];
|
|
2966
|
+
hits.forEach((hit, idx) => {
|
|
2967
|
+
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
2968
|
+
if (docId) {
|
|
2969
|
+
const strId = String(docId);
|
|
2970
|
+
docsOrder.set(strId, idx);
|
|
2971
|
+
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
2972
|
+
ids.push(strId);
|
|
2508
2973
|
}
|
|
2509
|
-
} catch (e) {
|
|
2510
|
-
log(`Cache error for getById:`, e);
|
|
2511
|
-
stats.errors++;
|
|
2512
|
-
}
|
|
2513
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2514
|
-
/**
|
|
2515
|
-
* before:getByQuery - Check cache for single-doc query
|
|
2516
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2517
|
-
*/
|
|
2518
|
-
repo.on("before:getByQuery", async (context) => {
|
|
2519
|
-
if (context.skipCache) {
|
|
2520
|
-
log(`Skipping cache for getByQuery`);
|
|
2521
|
-
return;
|
|
2522
|
-
}
|
|
2523
|
-
const collectionVersion = await getVersion();
|
|
2524
|
-
const query = context.query || {};
|
|
2525
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2526
|
-
select: context.select,
|
|
2527
|
-
populate: context.populate
|
|
2528
2974
|
});
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
stats.hits++;
|
|
2533
|
-
log(`Cache HIT for getByQuery:`, key);
|
|
2534
|
-
context._cacheHit = true;
|
|
2535
|
-
context._cachedResult = cached;
|
|
2536
|
-
} else {
|
|
2537
|
-
stats.misses++;
|
|
2538
|
-
log(`Cache MISS for getByQuery:`, key);
|
|
2539
|
-
}
|
|
2540
|
-
} catch (e) {
|
|
2541
|
-
log(`Cache error for getByQuery:`, e);
|
|
2542
|
-
stats.errors++;
|
|
2543
|
-
}
|
|
2544
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2545
|
-
/**
|
|
2546
|
-
* before:getAll - Check cache for list query
|
|
2547
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2548
|
-
*/
|
|
2549
|
-
repo.on("before:getAll", async (context) => {
|
|
2550
|
-
if (context.skipCache) {
|
|
2551
|
-
log(`Skipping cache for getAll`);
|
|
2552
|
-
return;
|
|
2553
|
-
}
|
|
2554
|
-
const limit = context.limit;
|
|
2555
|
-
if (limit && limit > config.skipIfLargeLimit) {
|
|
2556
|
-
log(`Skipping cache for large query (limit: ${limit})`);
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
const collectionVersion = await getVersion();
|
|
2560
|
-
const params = {
|
|
2561
|
-
filters: context.filters,
|
|
2562
|
-
sort: context.sort,
|
|
2563
|
-
page: context.page,
|
|
2975
|
+
if (ids.length === 0) return {
|
|
2976
|
+
docs: [],
|
|
2977
|
+
total,
|
|
2564
2978
|
limit,
|
|
2565
|
-
|
|
2566
|
-
select: context.select,
|
|
2567
|
-
populate: context.populate,
|
|
2568
|
-
search: context.search,
|
|
2569
|
-
mode: context.mode,
|
|
2570
|
-
lean: context.lean,
|
|
2571
|
-
readPreference: context.readPreference,
|
|
2572
|
-
hint: context.hint,
|
|
2573
|
-
maxTimeMS: context.maxTimeMS,
|
|
2574
|
-
countStrategy: context.countStrategy
|
|
2979
|
+
from
|
|
2575
2980
|
};
|
|
2576
|
-
const
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2981
|
+
const mongoQuery = this.Model.find({ _id: { $in: ids } });
|
|
2982
|
+
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
2983
|
+
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
2984
|
+
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
2985
|
+
return {
|
|
2986
|
+
docs: (await mongoQuery.exec()).sort((a, b) => {
|
|
2987
|
+
const aId = String(a._id);
|
|
2988
|
+
const bId = String(b._id);
|
|
2989
|
+
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
2990
|
+
}).map((doc) => {
|
|
2991
|
+
const strId = String(doc._id);
|
|
2992
|
+
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
2993
|
+
...doc,
|
|
2994
|
+
_score: scores.get(strId)
|
|
2995
|
+
};
|
|
2996
|
+
return doc;
|
|
2997
|
+
}),
|
|
2998
|
+
total,
|
|
2999
|
+
limit,
|
|
3000
|
+
from
|
|
3001
|
+
};
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
//#endregion
|
|
3007
|
+
//#region src/plugins/field-filter.plugin.ts
|
|
3008
|
+
/**
|
|
3009
|
+
* Field filter plugin that restricts fields based on user context
|
|
3010
|
+
*
|
|
3011
|
+
* @example
|
|
3012
|
+
* const fieldPreset = {
|
|
3013
|
+
* public: ['id', 'name'],
|
|
3014
|
+
* authenticated: ['email'],
|
|
3015
|
+
* admin: ['createdAt', 'internalNotes']
|
|
3016
|
+
* };
|
|
3017
|
+
*
|
|
3018
|
+
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
3019
|
+
*/
|
|
3020
|
+
function fieldFilterPlugin(fieldPreset) {
|
|
3021
|
+
return {
|
|
3022
|
+
name: "fieldFilter",
|
|
3023
|
+
apply(repo) {
|
|
3024
|
+
const applyFieldFiltering = (context) => {
|
|
3025
|
+
if (!fieldPreset) return;
|
|
3026
|
+
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
3027
|
+
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
3028
|
+
else context.select = presetSelect;
|
|
3029
|
+
};
|
|
3030
|
+
repo.on("before:getAll", applyFieldFiltering);
|
|
3031
|
+
repo.on("before:getById", applyFieldFiltering);
|
|
3032
|
+
repo.on("before:getByQuery", applyFieldFiltering);
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
//#endregion
|
|
3037
|
+
//#region src/plugins/method-registry.plugin.ts
|
|
3038
|
+
/**
|
|
3039
|
+
* Method registry plugin that enables dynamic method registration
|
|
3040
|
+
*/
|
|
3041
|
+
function methodRegistryPlugin() {
|
|
3042
|
+
return {
|
|
3043
|
+
name: "method-registry",
|
|
3044
|
+
apply(repo) {
|
|
3045
|
+
const registeredMethods = [];
|
|
2593
3046
|
/**
|
|
2594
|
-
*
|
|
3047
|
+
* Register a new method on the repository instance
|
|
2595
3048
|
*/
|
|
2596
|
-
repo.
|
|
2597
|
-
|
|
2598
|
-
if (
|
|
2599
|
-
if (
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
lean: context.lean
|
|
3049
|
+
repo.registerMethod = (name, fn) => {
|
|
3050
|
+
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.`);
|
|
3051
|
+
if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
|
|
3052
|
+
if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
|
|
3053
|
+
repo[name] = fn.bind(repo);
|
|
3054
|
+
registeredMethods.push(name);
|
|
3055
|
+
repo.emit("method:registered", {
|
|
3056
|
+
name,
|
|
3057
|
+
fn
|
|
2606
3058
|
});
|
|
2607
|
-
|
|
2608
|
-
try {
|
|
2609
|
-
await config.adapter.set(key, result, ttl);
|
|
2610
|
-
trackByIdKey(id, key);
|
|
2611
|
-
stats.sets++;
|
|
2612
|
-
log(`Cached getById result:`, key);
|
|
2613
|
-
} catch (e) {
|
|
2614
|
-
log(`Failed to cache getById:`, e);
|
|
2615
|
-
}
|
|
2616
|
-
});
|
|
3059
|
+
};
|
|
2617
3060
|
/**
|
|
2618
|
-
*
|
|
3061
|
+
* Check if a method is registered
|
|
2619
3062
|
*/
|
|
2620
|
-
repo.
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
3063
|
+
repo.hasMethod = (name) => typeof repo[name] === "function";
|
|
3064
|
+
/**
|
|
3065
|
+
* Get list of all dynamically registered methods
|
|
3066
|
+
*/
|
|
3067
|
+
repo.getRegisteredMethods = () => [...registeredMethods];
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
//#endregion
|
|
3072
|
+
//#region src/plugins/mongo-operations.plugin.ts
|
|
3073
|
+
/**
|
|
3074
|
+
* MongoDB Operations Plugin
|
|
3075
|
+
*
|
|
3076
|
+
* Adds MongoDB-specific operations to repositories.
|
|
3077
|
+
* Requires method-registry.plugin.js to be loaded first.
|
|
3078
|
+
*/
|
|
3079
|
+
/**
|
|
3080
|
+
* MongoDB operations plugin
|
|
3081
|
+
*
|
|
3082
|
+
* Adds MongoDB-specific atomic operations to repositories:
|
|
3083
|
+
* - upsert: Create or update document
|
|
3084
|
+
* - increment/decrement: Atomic numeric operations
|
|
3085
|
+
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
3086
|
+
* - setField/unsetField/renameField: Field operations
|
|
3087
|
+
* - multiplyField: Multiply numeric field
|
|
3088
|
+
* - setMin/setMax: Conditional min/max updates
|
|
3089
|
+
*
|
|
3090
|
+
* @example Basic usage (no TypeScript autocomplete)
|
|
3091
|
+
* ```typescript
|
|
3092
|
+
* const repo = new Repository(ProductModel, [
|
|
3093
|
+
* methodRegistryPlugin(),
|
|
3094
|
+
* mongoOperationsPlugin(),
|
|
3095
|
+
* ]);
|
|
3096
|
+
*
|
|
3097
|
+
* // Works at runtime but TypeScript doesn't know about these methods
|
|
3098
|
+
* await (repo as any).increment(productId, 'views', 1);
|
|
3099
|
+
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
3100
|
+
* ```
|
|
3101
|
+
*
|
|
3102
|
+
* @example With TypeScript type safety (recommended)
|
|
3103
|
+
* ```typescript
|
|
3104
|
+
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
3105
|
+
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
3106
|
+
*
|
|
3107
|
+
* class ProductRepo extends Repository<IProduct> {
|
|
3108
|
+
* // Add your custom methods here
|
|
3109
|
+
* }
|
|
3110
|
+
*
|
|
3111
|
+
* // Create with type assertion to get autocomplete for plugin methods
|
|
3112
|
+
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
3113
|
+
*
|
|
3114
|
+
* const repo = new ProductRepo(ProductModel, [
|
|
3115
|
+
* methodRegistryPlugin(),
|
|
3116
|
+
* mongoOperationsPlugin(),
|
|
3117
|
+
* ]) as ProductRepoWithPlugins;
|
|
3118
|
+
*
|
|
3119
|
+
* // Now TypeScript provides autocomplete and type checking!
|
|
3120
|
+
* await repo.increment(productId, 'views', 1);
|
|
3121
|
+
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
3122
|
+
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
3123
|
+
* ```
|
|
3124
|
+
*/
|
|
3125
|
+
function mongoOperationsPlugin() {
|
|
3126
|
+
return {
|
|
3127
|
+
name: "mongo-operations",
|
|
3128
|
+
apply(repo) {
|
|
3129
|
+
if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
|
|
3130
|
+
/**
|
|
3131
|
+
* Update existing document or insert new one
|
|
3132
|
+
*/
|
|
3133
|
+
repo.registerMethod("upsert", async function(query, data, options = {}) {
|
|
3134
|
+
return upsert(this.Model, query, data, options);
|
|
2639
3135
|
});
|
|
3136
|
+
const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
|
|
3137
|
+
if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
|
|
3138
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
3139
|
+
};
|
|
2640
3140
|
/**
|
|
2641
|
-
*
|
|
3141
|
+
* Atomically increment numeric field
|
|
2642
3142
|
*/
|
|
2643
|
-
repo.
|
|
2644
|
-
|
|
2645
|
-
if (context._cacheHit) return;
|
|
2646
|
-
if (context.skipCache) return;
|
|
2647
|
-
const limit = context.limit;
|
|
2648
|
-
if (limit && limit > config.skipIfLargeLimit) return;
|
|
2649
|
-
const collectionVersion = await getVersion();
|
|
2650
|
-
const params = {
|
|
2651
|
-
filters: context.filters,
|
|
2652
|
-
sort: context.sort,
|
|
2653
|
-
page: context.page,
|
|
2654
|
-
limit,
|
|
2655
|
-
after: context.after,
|
|
2656
|
-
select: context.select,
|
|
2657
|
-
populate: context.populate,
|
|
2658
|
-
search: context.search,
|
|
2659
|
-
mode: context.mode,
|
|
2660
|
-
lean: context.lean,
|
|
2661
|
-
readPreference: context.readPreference,
|
|
2662
|
-
hint: context.hint,
|
|
2663
|
-
maxTimeMS: context.maxTimeMS,
|
|
2664
|
-
countStrategy: context.countStrategy
|
|
2665
|
-
};
|
|
2666
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2667
|
-
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2668
|
-
try {
|
|
2669
|
-
await config.adapter.set(key, result, ttl);
|
|
2670
|
-
stats.sets++;
|
|
2671
|
-
log(`Cached getAll result:`, key);
|
|
2672
|
-
} catch (e) {
|
|
2673
|
-
log(`Failed to cache getAll:`, e);
|
|
2674
|
-
}
|
|
3143
|
+
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
3144
|
+
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
2675
3145
|
});
|
|
2676
3146
|
/**
|
|
2677
|
-
*
|
|
3147
|
+
* Atomically decrement numeric field
|
|
2678
3148
|
*/
|
|
2679
|
-
repo.
|
|
2680
|
-
|
|
3149
|
+
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
3150
|
+
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
2681
3151
|
});
|
|
3152
|
+
const applyOperator = function(id, field, value, operator, options) {
|
|
3153
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
3154
|
+
};
|
|
2682
3155
|
/**
|
|
2683
|
-
*
|
|
3156
|
+
* Push value to array field
|
|
2684
3157
|
*/
|
|
2685
|
-
repo.
|
|
2686
|
-
|
|
3158
|
+
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
3159
|
+
return applyOperator.call(this, id, field, value, "$push", options);
|
|
2687
3160
|
});
|
|
2688
3161
|
/**
|
|
2689
|
-
*
|
|
3162
|
+
* Remove value from array field
|
|
2690
3163
|
*/
|
|
2691
|
-
repo.
|
|
2692
|
-
|
|
2693
|
-
const id = String(context.id);
|
|
2694
|
-
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
3164
|
+
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
3165
|
+
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
2695
3166
|
});
|
|
2696
3167
|
/**
|
|
2697
|
-
*
|
|
3168
|
+
* Add value to array only if not already present (unique)
|
|
2698
3169
|
*/
|
|
2699
|
-
repo.
|
|
2700
|
-
|
|
3170
|
+
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
3171
|
+
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
2701
3172
|
});
|
|
2702
3173
|
/**
|
|
2703
|
-
*
|
|
3174
|
+
* Set field value (alias for update with $set)
|
|
2704
3175
|
*/
|
|
2705
|
-
repo.
|
|
2706
|
-
|
|
2707
|
-
const id = String(context.id);
|
|
2708
|
-
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
3176
|
+
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
3177
|
+
return applyOperator.call(this, id, field, value, "$set", options);
|
|
2709
3178
|
});
|
|
2710
3179
|
/**
|
|
2711
|
-
*
|
|
3180
|
+
* Unset (remove) field from document
|
|
2712
3181
|
*/
|
|
2713
|
-
repo.
|
|
2714
|
-
|
|
3182
|
+
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
3183
|
+
const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
|
|
3184
|
+
acc[field] = "";
|
|
3185
|
+
return acc;
|
|
3186
|
+
}, {});
|
|
3187
|
+
return this.update(id, { $unset: unsetObj }, options);
|
|
2715
3188
|
});
|
|
2716
3189
|
/**
|
|
2717
|
-
*
|
|
3190
|
+
* Rename field in document
|
|
2718
3191
|
*/
|
|
2719
|
-
repo.
|
|
2720
|
-
|
|
3192
|
+
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
3193
|
+
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
2721
3194
|
});
|
|
2722
3195
|
/**
|
|
2723
|
-
*
|
|
2724
|
-
* Use when document was updated outside this service
|
|
2725
|
-
*
|
|
2726
|
-
* @example
|
|
2727
|
-
* await userRepo.invalidateCache('507f1f77bcf86cd799439011');
|
|
3196
|
+
* Multiply numeric field by value
|
|
2728
3197
|
*/
|
|
2729
|
-
repo.
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
};
|
|
3198
|
+
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
3199
|
+
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
3200
|
+
});
|
|
2733
3201
|
/**
|
|
2734
|
-
*
|
|
2735
|
-
* Use when bulk changes happened outside this service
|
|
2736
|
-
*
|
|
2737
|
-
* @example
|
|
2738
|
-
* await userRepo.invalidateListCache();
|
|
3202
|
+
* Set field to minimum value (only if current value is greater)
|
|
2739
3203
|
*/
|
|
2740
|
-
repo.
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
};
|
|
3204
|
+
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
3205
|
+
return applyOperator.call(this, id, field, value, "$min", options);
|
|
3206
|
+
});
|
|
2744
3207
|
/**
|
|
2745
|
-
*
|
|
2746
|
-
* Nuclear option - use sparingly
|
|
2747
|
-
*
|
|
2748
|
-
* @example
|
|
2749
|
-
* await userRepo.invalidateAllCache();
|
|
3208
|
+
* Set field to maximum value (only if current value is less)
|
|
2750
3209
|
*/
|
|
2751
|
-
repo.
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
stats.invalidations++;
|
|
2755
|
-
log(`Full cache invalidation for ${model}`);
|
|
2756
|
-
} catch (e) {
|
|
2757
|
-
log(`Failed full cache invalidation for ${model}:`, e);
|
|
2758
|
-
}
|
|
2759
|
-
else {
|
|
2760
|
-
await bumpVersion();
|
|
2761
|
-
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
2762
|
-
}
|
|
2763
|
-
};
|
|
3210
|
+
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
3211
|
+
return applyOperator.call(this, id, field, value, "$max", options);
|
|
3212
|
+
});
|
|
2764
3213
|
/**
|
|
2765
|
-
*
|
|
3214
|
+
* Atomic update with multiple MongoDB operators in a single call
|
|
3215
|
+
*
|
|
3216
|
+
* Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
|
|
3217
|
+
* into one atomic database operation.
|
|
2766
3218
|
*
|
|
2767
3219
|
* @example
|
|
2768
|
-
*
|
|
2769
|
-
*
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
*
|
|
2774
|
-
|
|
2775
|
-
repo.
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
*
|
|
2790
|
-
*
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
*
|
|
2814
|
-
* Deletes related documents after the parent document is deleted.
|
|
2815
|
-
* Works with both hard delete and soft delete scenarios.
|
|
2816
|
-
*
|
|
2817
|
-
* @param options - Cascade configuration
|
|
2818
|
-
* @returns Plugin
|
|
2819
|
-
*/
|
|
2820
|
-
function cascadePlugin(options) {
|
|
2821
|
-
const { relations, parallel = true, logger } = options;
|
|
2822
|
-
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
2823
|
-
return {
|
|
2824
|
-
name: "cascade",
|
|
2825
|
-
apply(repo) {
|
|
2826
|
-
repo.on("after:delete", async (payload) => {
|
|
2827
|
-
const { context } = payload;
|
|
2828
|
-
const deletedId = context.id;
|
|
2829
|
-
if (!deletedId) {
|
|
2830
|
-
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
2831
|
-
return;
|
|
2832
|
-
}
|
|
2833
|
-
const isSoftDelete = context.softDeleted === true;
|
|
2834
|
-
const cascadeDelete = async (relation) => {
|
|
2835
|
-
const RelatedModel = mongoose.models[relation.model];
|
|
2836
|
-
if (!RelatedModel) {
|
|
2837
|
-
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2838
|
-
parentModel: context.model,
|
|
2839
|
-
parentId: String(deletedId)
|
|
2840
|
-
});
|
|
2841
|
-
return;
|
|
2842
|
-
}
|
|
2843
|
-
const query = { [relation.foreignKey]: deletedId };
|
|
2844
|
-
try {
|
|
2845
|
-
if (relation.softDelete ?? isSoftDelete) {
|
|
2846
|
-
const updateResult = await RelatedModel.updateMany(query, {
|
|
2847
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
2848
|
-
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2849
|
-
}, { session: context.session });
|
|
2850
|
-
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
|
|
2851
|
-
parentModel: context.model,
|
|
2852
|
-
parentId: String(deletedId),
|
|
2853
|
-
relatedModel: relation.model,
|
|
2854
|
-
foreignKey: relation.foreignKey,
|
|
2855
|
-
count: updateResult.modifiedCount
|
|
2856
|
-
});
|
|
2857
|
-
} else {
|
|
2858
|
-
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2859
|
-
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
|
|
2860
|
-
parentModel: context.model,
|
|
2861
|
-
parentId: String(deletedId),
|
|
2862
|
-
relatedModel: relation.model,
|
|
2863
|
-
foreignKey: relation.foreignKey,
|
|
2864
|
-
count: deleteResult.deletedCount
|
|
2865
|
-
});
|
|
2866
|
-
}
|
|
2867
|
-
} catch (error) {
|
|
2868
|
-
logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
|
|
2869
|
-
parentModel: context.model,
|
|
2870
|
-
parentId: String(deletedId),
|
|
2871
|
-
relatedModel: relation.model,
|
|
2872
|
-
foreignKey: relation.foreignKey,
|
|
2873
|
-
error: error.message
|
|
2874
|
-
});
|
|
2875
|
-
throw error;
|
|
2876
|
-
}
|
|
2877
|
-
};
|
|
2878
|
-
if (parallel) {
|
|
2879
|
-
const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
|
|
2880
|
-
if (failures.length) {
|
|
2881
|
-
const err = failures[0].reason;
|
|
2882
|
-
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2883
|
-
throw err;
|
|
2884
|
-
}
|
|
2885
|
-
} else for (const relation of relations) await cascadeDelete(relation);
|
|
2886
|
-
});
|
|
2887
|
-
repo.on("before:deleteMany", async (context) => {
|
|
2888
|
-
const query = context.query;
|
|
2889
|
-
if (!query || Object.keys(query).length === 0) return;
|
|
2890
|
-
context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
|
|
2891
|
-
});
|
|
2892
|
-
repo.on("after:deleteMany", async (payload) => {
|
|
2893
|
-
const { context } = payload;
|
|
2894
|
-
const ids = context._cascadeIds;
|
|
2895
|
-
if (!ids || ids.length === 0) return;
|
|
2896
|
-
const isSoftDelete = context.softDeleted === true;
|
|
2897
|
-
const cascadeDeleteMany = async (relation) => {
|
|
2898
|
-
const RelatedModel = mongoose.models[relation.model];
|
|
2899
|
-
if (!RelatedModel) {
|
|
2900
|
-
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
|
|
2901
|
-
return;
|
|
2902
|
-
}
|
|
2903
|
-
const query = { [relation.foreignKey]: { $in: ids } };
|
|
2904
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
2905
|
-
try {
|
|
2906
|
-
if (shouldSoftDelete) {
|
|
2907
|
-
const updateResult = await RelatedModel.updateMany(query, {
|
|
2908
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
2909
|
-
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2910
|
-
}, { session: context.session });
|
|
2911
|
-
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
|
|
2912
|
-
parentModel: context.model,
|
|
2913
|
-
parentCount: ids.length,
|
|
2914
|
-
relatedModel: relation.model,
|
|
2915
|
-
foreignKey: relation.foreignKey,
|
|
2916
|
-
count: updateResult.modifiedCount
|
|
2917
|
-
});
|
|
2918
|
-
} else {
|
|
2919
|
-
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2920
|
-
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
|
|
2921
|
-
parentModel: context.model,
|
|
2922
|
-
parentCount: ids.length,
|
|
2923
|
-
relatedModel: relation.model,
|
|
2924
|
-
foreignKey: relation.foreignKey,
|
|
2925
|
-
count: deleteResult.deletedCount
|
|
2926
|
-
});
|
|
2927
|
-
}
|
|
2928
|
-
} catch (error) {
|
|
2929
|
-
logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
|
|
2930
|
-
parentModel: context.model,
|
|
2931
|
-
relatedModel: relation.model,
|
|
2932
|
-
foreignKey: relation.foreignKey,
|
|
2933
|
-
error: error.message
|
|
2934
|
-
});
|
|
2935
|
-
throw error;
|
|
2936
|
-
}
|
|
2937
|
-
};
|
|
2938
|
-
if (parallel) {
|
|
2939
|
-
const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
|
|
2940
|
-
if (failures.length) {
|
|
2941
|
-
const err = failures[0].reason;
|
|
2942
|
-
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2943
|
-
throw err;
|
|
2944
|
-
}
|
|
2945
|
-
} else for (const relation of relations) await cascadeDeleteMany(relation);
|
|
3220
|
+
* // Combine $inc + $set in one atomic call
|
|
3221
|
+
* await repo.atomicUpdate(id, {
|
|
3222
|
+
* $inc: { views: 1, commentCount: 1 },
|
|
3223
|
+
* $set: { lastActiveAt: new Date() }
|
|
3224
|
+
* });
|
|
3225
|
+
*
|
|
3226
|
+
* // Multiple operators: $inc + $set + $push
|
|
3227
|
+
* await repo.atomicUpdate(id, {
|
|
3228
|
+
* $inc: { 'metrics.total': 1 },
|
|
3229
|
+
* $set: { updatedAt: new Date() },
|
|
3230
|
+
* $push: { history: { action: 'update', at: new Date() } }
|
|
3231
|
+
* });
|
|
3232
|
+
*
|
|
3233
|
+
* // $push with $each modifier
|
|
3234
|
+
* await repo.atomicUpdate(id, {
|
|
3235
|
+
* $push: { tags: { $each: ['featured', 'popular'] } },
|
|
3236
|
+
* $inc: { tagCount: 2 }
|
|
3237
|
+
* });
|
|
3238
|
+
*
|
|
3239
|
+
* // With arrayFilters for positional updates
|
|
3240
|
+
* await repo.atomicUpdate(id, {
|
|
3241
|
+
* $set: { 'items.$[elem].quantity': 5 }
|
|
3242
|
+
* }, { arrayFilters: [{ 'elem._id': itemId }] });
|
|
3243
|
+
*/
|
|
3244
|
+
repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
|
|
3245
|
+
const validOperators = new Set([
|
|
3246
|
+
"$inc",
|
|
3247
|
+
"$set",
|
|
3248
|
+
"$unset",
|
|
3249
|
+
"$push",
|
|
3250
|
+
"$pull",
|
|
3251
|
+
"$addToSet",
|
|
3252
|
+
"$pop",
|
|
3253
|
+
"$rename",
|
|
3254
|
+
"$min",
|
|
3255
|
+
"$max",
|
|
3256
|
+
"$mul",
|
|
3257
|
+
"$setOnInsert",
|
|
3258
|
+
"$bit",
|
|
3259
|
+
"$currentDate"
|
|
3260
|
+
]);
|
|
3261
|
+
const keys = Object.keys(operators);
|
|
3262
|
+
if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
|
|
3263
|
+
for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
|
|
3264
|
+
return this.update(id, operators, options);
|
|
2946
3265
|
});
|
|
2947
3266
|
}
|
|
2948
3267
|
};
|
|
2949
3268
|
}
|
|
2950
|
-
|
|
2951
3269
|
//#endregion
|
|
2952
3270
|
//#region src/plugins/multi-tenant.plugin.ts
|
|
2953
3271
|
/**
|
|
@@ -3086,7 +3404,6 @@ function multiTenantPlugin(options = {}) {
|
|
|
3086
3404
|
}
|
|
3087
3405
|
};
|
|
3088
3406
|
}
|
|
3089
|
-
|
|
3090
3407
|
//#endregion
|
|
3091
3408
|
//#region src/plugins/observability.plugin.ts
|
|
3092
3409
|
const DEFAULT_OPS = [
|
|
@@ -3147,691 +3464,538 @@ function observabilityPlugin(options) {
|
|
|
3147
3464
|
}
|
|
3148
3465
|
};
|
|
3149
3466
|
}
|
|
3150
|
-
|
|
3151
3467
|
//#endregion
|
|
3152
|
-
//#region src/plugins/
|
|
3468
|
+
//#region src/plugins/soft-delete.plugin.ts
|
|
3153
3469
|
/**
|
|
3154
|
-
*
|
|
3470
|
+
* Build filter condition based on filter mode
|
|
3471
|
+
*/
|
|
3472
|
+
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
3473
|
+
if (includeDeleted) return {};
|
|
3474
|
+
if (filterMode === "exists") return { [deletedField]: { $exists: false } };
|
|
3475
|
+
return { [deletedField]: null };
|
|
3476
|
+
}
|
|
3477
|
+
/**
|
|
3478
|
+
* Build filter condition for finding deleted documents
|
|
3479
|
+
*/
|
|
3480
|
+
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
3481
|
+
if (filterMode === "exists") return { [deletedField]: {
|
|
3482
|
+
$exists: true,
|
|
3483
|
+
$ne: null
|
|
3484
|
+
} };
|
|
3485
|
+
return { [deletedField]: { $ne: null } };
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* Soft delete plugin
|
|
3155
3489
|
*
|
|
3156
|
-
*
|
|
3157
|
-
*
|
|
3490
|
+
* @example Basic usage
|
|
3491
|
+
* ```typescript
|
|
3492
|
+
* const repo = new Repository(Model, [
|
|
3493
|
+
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
3494
|
+
* ]);
|
|
3158
3495
|
*
|
|
3159
|
-
*
|
|
3160
|
-
*
|
|
3161
|
-
* - Field-level change tracking (before/after diff on updates)
|
|
3162
|
-
* - TTL auto-cleanup via MongoDB TTL index
|
|
3163
|
-
* - Custom metadata per entry (IP, user-agent, etc.)
|
|
3164
|
-
* - Shared `audit_trails` collection across all models
|
|
3496
|
+
* // Delete (soft)
|
|
3497
|
+
* await repo.delete(id);
|
|
3165
3498
|
*
|
|
3166
|
-
*
|
|
3499
|
+
* // Restore
|
|
3500
|
+
* await repo.restore(id);
|
|
3501
|
+
*
|
|
3502
|
+
* // Get deleted documents
|
|
3503
|
+
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
3504
|
+
* ```
|
|
3505
|
+
*
|
|
3506
|
+
* @example With null filter mode (for schemas with default: null)
|
|
3167
3507
|
* ```typescript
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
3175
|
-
*
|
|
3176
|
-
*
|
|
3508
|
+
* // Schema: { deletedAt: { type: Date, default: null } }
|
|
3509
|
+
* const repo = new Repository(Model, [
|
|
3510
|
+
* softDeletePlugin({
|
|
3511
|
+
* deletedField: 'deletedAt',
|
|
3512
|
+
* filterMode: 'null', // default - works with default: null
|
|
3513
|
+
* })
|
|
3514
|
+
* ]);
|
|
3515
|
+
* ```
|
|
3516
|
+
*
|
|
3517
|
+
* @example With TTL for auto-cleanup
|
|
3518
|
+
* ```typescript
|
|
3519
|
+
* const repo = new Repository(Model, [
|
|
3520
|
+
* softDeletePlugin({
|
|
3521
|
+
* deletedField: 'deletedAt',
|
|
3522
|
+
* ttlDays: 30, // Auto-delete after 30 days
|
|
3523
|
+
* })
|
|
3177
3524
|
* ]);
|
|
3178
3525
|
* ```
|
|
3179
3526
|
*/
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
const
|
|
3183
|
-
|
|
3184
|
-
const
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
required: true,
|
|
3188
|
-
index: true
|
|
3189
|
-
},
|
|
3190
|
-
operation: {
|
|
3191
|
-
type: String,
|
|
3192
|
-
required: true,
|
|
3193
|
-
enum: [
|
|
3194
|
-
"create",
|
|
3195
|
-
"update",
|
|
3196
|
-
"delete"
|
|
3197
|
-
]
|
|
3198
|
-
},
|
|
3199
|
-
documentId: {
|
|
3200
|
-
type: mongoose.Schema.Types.Mixed,
|
|
3201
|
-
required: true,
|
|
3202
|
-
index: true
|
|
3203
|
-
},
|
|
3204
|
-
userId: {
|
|
3205
|
-
type: mongoose.Schema.Types.Mixed,
|
|
3206
|
-
index: true
|
|
3207
|
-
},
|
|
3208
|
-
orgId: {
|
|
3209
|
-
type: mongoose.Schema.Types.Mixed,
|
|
3210
|
-
index: true
|
|
3211
|
-
},
|
|
3212
|
-
changes: { type: mongoose.Schema.Types.Mixed },
|
|
3213
|
-
document: { type: mongoose.Schema.Types.Mixed },
|
|
3214
|
-
metadata: { type: mongoose.Schema.Types.Mixed },
|
|
3215
|
-
timestamp: {
|
|
3216
|
-
type: Date,
|
|
3217
|
-
default: Date.now,
|
|
3218
|
-
index: true
|
|
3219
|
-
}
|
|
3220
|
-
}, {
|
|
3221
|
-
collection: collectionName,
|
|
3222
|
-
versionKey: false
|
|
3223
|
-
});
|
|
3224
|
-
schema.index({
|
|
3225
|
-
model: 1,
|
|
3226
|
-
documentId: 1,
|
|
3227
|
-
timestamp: -1
|
|
3228
|
-
});
|
|
3229
|
-
schema.index({
|
|
3230
|
-
orgId: 1,
|
|
3231
|
-
userId: 1,
|
|
3232
|
-
timestamp: -1
|
|
3233
|
-
});
|
|
3234
|
-
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
3235
|
-
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
3236
|
-
schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlSeconds });
|
|
3237
|
-
}
|
|
3238
|
-
const modelName = `AuditTrail_${collectionName}`;
|
|
3239
|
-
const model = mongoose.models[modelName] || mongoose.model(modelName, schema);
|
|
3240
|
-
modelCache.set(collectionName, model);
|
|
3241
|
-
return model;
|
|
3242
|
-
}
|
|
3243
|
-
/** Compute field-level diff between previous and updated document */
|
|
3244
|
-
function computeChanges(prev, next, excludeFields) {
|
|
3245
|
-
const changes = {};
|
|
3246
|
-
const exclude = new Set(excludeFields);
|
|
3247
|
-
for (const key of Object.keys(next)) {
|
|
3248
|
-
if (exclude.has(key)) continue;
|
|
3249
|
-
if (key === "_id" || key === "__v" || key === "updatedAt") continue;
|
|
3250
|
-
const prevVal = prev[key];
|
|
3251
|
-
const nextVal = next[key];
|
|
3252
|
-
if (!deepEqual(prevVal, nextVal)) changes[key] = {
|
|
3253
|
-
from: prevVal,
|
|
3254
|
-
to: nextVal
|
|
3255
|
-
};
|
|
3256
|
-
}
|
|
3257
|
-
return Object.keys(changes).length > 0 ? changes : void 0;
|
|
3258
|
-
}
|
|
3259
|
-
/** Simple deep equality check for audit diffing */
|
|
3260
|
-
function deepEqual(a, b) {
|
|
3261
|
-
if (a === b) return true;
|
|
3262
|
-
if (a == null && b == null) return true;
|
|
3263
|
-
if (a == null || b == null) return false;
|
|
3264
|
-
if (typeof a === "object" && typeof b === "object") {
|
|
3265
|
-
const aStr = a.toString?.();
|
|
3266
|
-
const bStr = b.toString?.();
|
|
3267
|
-
if (aStr && bStr && aStr === bStr) return true;
|
|
3268
|
-
}
|
|
3269
|
-
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
3270
|
-
try {
|
|
3271
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
3272
|
-
} catch {
|
|
3273
|
-
return false;
|
|
3274
|
-
}
|
|
3275
|
-
}
|
|
3276
|
-
/** Extract user ID from context */
|
|
3277
|
-
function getUserId(context) {
|
|
3278
|
-
return context.user?._id || context.user?.id;
|
|
3279
|
-
}
|
|
3280
|
-
/** Fire-and-forget: write audit entry, never throw */
|
|
3281
|
-
function writeAudit(AuditModel, entry) {
|
|
3282
|
-
Promise.resolve().then(() => {
|
|
3283
|
-
AuditModel.create({
|
|
3284
|
-
...entry,
|
|
3285
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
3286
|
-
}).catch((err) => {
|
|
3287
|
-
warn(`[auditTrailPlugin] Failed to write audit entry: ${err.message}`);
|
|
3288
|
-
});
|
|
3289
|
-
});
|
|
3290
|
-
}
|
|
3291
|
-
const snapshots = /* @__PURE__ */ new WeakMap();
|
|
3292
|
-
function auditTrailPlugin(options = {}) {
|
|
3293
|
-
const { operations = [
|
|
3294
|
-
"create",
|
|
3295
|
-
"update",
|
|
3296
|
-
"delete"
|
|
3297
|
-
], trackChanges = true, trackDocument = false, ttlDays, collectionName = "audit_trails", metadata, excludeFields = [] } = options;
|
|
3298
|
-
const opsSet = new Set(operations);
|
|
3527
|
+
function softDeletePlugin(options = {}) {
|
|
3528
|
+
const deletedField = options.deletedField || "deletedAt";
|
|
3529
|
+
const deletedByField = options.deletedByField || "deletedBy";
|
|
3530
|
+
const filterMode = options.filterMode || "null";
|
|
3531
|
+
const addRestoreMethod = options.addRestoreMethod !== false;
|
|
3532
|
+
const addGetDeletedMethod = options.addGetDeletedMethod !== false;
|
|
3533
|
+
const ttlDays = options.ttlDays;
|
|
3299
3534
|
return {
|
|
3300
|
-
name: "
|
|
3535
|
+
name: "softDelete",
|
|
3301
3536
|
apply(repo) {
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
orgId: context.organizationId,
|
|
3311
|
-
document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
|
|
3312
|
-
metadata: metadata?.(context)
|
|
3313
|
-
});
|
|
3314
|
-
});
|
|
3315
|
-
if (opsSet.has("update")) {
|
|
3316
|
-
if (trackChanges) repo.on("before:update", async (context) => {
|
|
3317
|
-
if (!context.id) return;
|
|
3318
|
-
try {
|
|
3319
|
-
const prev = await repo.Model.findById(context.id).lean();
|
|
3320
|
-
if (prev) snapshots.set(context, prev);
|
|
3321
|
-
} catch (err) {
|
|
3322
|
-
warn(`[auditTrailPlugin] Failed to snapshot before update: ${err.message}`);
|
|
3323
|
-
}
|
|
3324
|
-
});
|
|
3325
|
-
repo.on("after:update", ({ context, result }) => {
|
|
3326
|
-
const doc = result;
|
|
3327
|
-
let changes;
|
|
3328
|
-
if (trackChanges) {
|
|
3329
|
-
const prev = snapshots.get(context);
|
|
3330
|
-
if (prev && context.data) changes = computeChanges(prev, context.data, excludeFields);
|
|
3331
|
-
snapshots.delete(context);
|
|
3332
|
-
}
|
|
3333
|
-
writeAudit(AuditModel, {
|
|
3334
|
-
model: context.model || repo.model,
|
|
3335
|
-
operation: "update",
|
|
3336
|
-
documentId: context.id || doc?._id,
|
|
3337
|
-
userId: getUserId(context),
|
|
3338
|
-
orgId: context.organizationId,
|
|
3339
|
-
changes,
|
|
3340
|
-
metadata: metadata?.(context)
|
|
3341
|
-
});
|
|
3342
|
-
});
|
|
3537
|
+
try {
|
|
3538
|
+
const schemaPaths = repo.Model.schema.paths;
|
|
3539
|
+
for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
|
|
3540
|
+
if (pathName === "_id" || pathName === deletedField) continue;
|
|
3541
|
+
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 } }`);
|
|
3542
|
+
}
|
|
3543
|
+
} catch (err) {
|
|
3544
|
+
warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3343
3545
|
}
|
|
3344
|
-
if (
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
metadata: metadata?.(context)
|
|
3546
|
+
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
3547
|
+
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
3548
|
+
repo.Model.collection.createIndex({ [deletedField]: 1 }, {
|
|
3549
|
+
expireAfterSeconds: ttlSeconds,
|
|
3550
|
+
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
3551
|
+
}).catch((err) => {
|
|
3552
|
+
if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
3352
3553
|
});
|
|
3353
|
-
}
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
const [docs, total] = await Promise.all([AuditModel.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), AuditModel.countDocuments(filter)]);
|
|
3367
|
-
return {
|
|
3368
|
-
docs,
|
|
3369
|
-
page,
|
|
3370
|
-
limit,
|
|
3371
|
-
total,
|
|
3372
|
-
pages: Math.ceil(total / limit),
|
|
3373
|
-
hasNext: page < Math.ceil(total / limit),
|
|
3374
|
-
hasPrev: page > 1
|
|
3375
|
-
};
|
|
3376
|
-
});
|
|
3377
|
-
}
|
|
3378
|
-
};
|
|
3379
|
-
}
|
|
3380
|
-
/** Convert Mongoose document to plain object */
|
|
3381
|
-
function toPlainObject(doc) {
|
|
3382
|
-
if (!doc) return {};
|
|
3383
|
-
if (typeof doc.toObject === "function") return doc.toObject();
|
|
3384
|
-
return doc;
|
|
3385
|
-
}
|
|
3386
|
-
/** Remove excluded fields from a document snapshot */
|
|
3387
|
-
function sanitizeDoc(doc, excludeFields) {
|
|
3388
|
-
if (excludeFields.length === 0) return doc;
|
|
3389
|
-
const result = { ...doc };
|
|
3390
|
-
for (const field of excludeFields) delete result[field];
|
|
3391
|
-
return result;
|
|
3392
|
-
}
|
|
3393
|
-
/**
|
|
3394
|
-
* Standalone audit trail query utility.
|
|
3395
|
-
* Use this to query audits across all models — e.g., admin dashboards, audit APIs.
|
|
3396
|
-
*
|
|
3397
|
-
* @example
|
|
3398
|
-
* ```typescript
|
|
3399
|
-
* import { AuditTrailQuery } from '@classytic/mongokit';
|
|
3400
|
-
*
|
|
3401
|
-
* const auditQuery = new AuditTrailQuery(); // defaults to 'audit_trails' collection
|
|
3402
|
-
*
|
|
3403
|
-
* // All audits for an org
|
|
3404
|
-
* const orgAudits = await auditQuery.query({ orgId: '...' });
|
|
3405
|
-
*
|
|
3406
|
-
* // All updates by a user
|
|
3407
|
-
* const userUpdates = await auditQuery.query({
|
|
3408
|
-
* userId: '...',
|
|
3409
|
-
* operation: 'update',
|
|
3410
|
-
* });
|
|
3411
|
-
*
|
|
3412
|
-
* // All audits for a specific document
|
|
3413
|
-
* const docHistory = await auditQuery.query({
|
|
3414
|
-
* model: 'Job',
|
|
3415
|
-
* documentId: '...',
|
|
3416
|
-
* });
|
|
3417
|
-
*
|
|
3418
|
-
* // Date range
|
|
3419
|
-
* const recent = await auditQuery.query({
|
|
3420
|
-
* from: new Date('2025-01-01'),
|
|
3421
|
-
* to: new Date(),
|
|
3422
|
-
* page: 1,
|
|
3423
|
-
* limit: 50,
|
|
3424
|
-
* });
|
|
3425
|
-
*
|
|
3426
|
-
* // Direct model access for custom queries
|
|
3427
|
-
* const model = auditQuery.getModel();
|
|
3428
|
-
* const count = await model.countDocuments({ operation: 'delete' });
|
|
3429
|
-
* ```
|
|
3430
|
-
*/
|
|
3431
|
-
var AuditTrailQuery = class {
|
|
3432
|
-
model;
|
|
3433
|
-
constructor(collectionName = "audit_trails", ttlDays) {
|
|
3434
|
-
this.model = getAuditModel(collectionName, ttlDays);
|
|
3435
|
-
}
|
|
3436
|
-
/**
|
|
3437
|
-
* Get the underlying Mongoose model for custom queries
|
|
3438
|
-
*/
|
|
3439
|
-
getModel() {
|
|
3440
|
-
return this.model;
|
|
3441
|
-
}
|
|
3442
|
-
/**
|
|
3443
|
-
* Query audit entries with filters and pagination
|
|
3444
|
-
*/
|
|
3445
|
-
async query(options = {}) {
|
|
3446
|
-
const { page = 1, limit = 20 } = options;
|
|
3447
|
-
const skip = (page - 1) * limit;
|
|
3448
|
-
const filter = {};
|
|
3449
|
-
if (options.model) filter.model = options.model;
|
|
3450
|
-
if (options.documentId) filter.documentId = options.documentId;
|
|
3451
|
-
if (options.userId) filter.userId = options.userId;
|
|
3452
|
-
if (options.orgId) filter.orgId = options.orgId;
|
|
3453
|
-
if (options.operation) filter.operation = options.operation;
|
|
3454
|
-
if (options.from || options.to) {
|
|
3455
|
-
const dateFilter = {};
|
|
3456
|
-
if (options.from) dateFilter.$gte = options.from;
|
|
3457
|
-
if (options.to) dateFilter.$lte = options.to;
|
|
3458
|
-
filter.timestamp = dateFilter;
|
|
3459
|
-
}
|
|
3460
|
-
const [docs, total] = await Promise.all([this.model.find(filter).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(), this.model.countDocuments(filter)]);
|
|
3461
|
-
const pages = Math.ceil(total / limit);
|
|
3462
|
-
return {
|
|
3463
|
-
docs,
|
|
3464
|
-
page,
|
|
3465
|
-
limit,
|
|
3466
|
-
total,
|
|
3467
|
-
pages,
|
|
3468
|
-
hasNext: page < pages,
|
|
3469
|
-
hasPrev: page > 1
|
|
3470
|
-
};
|
|
3471
|
-
}
|
|
3472
|
-
/**
|
|
3473
|
-
* Get audit trail for a specific document
|
|
3474
|
-
*/
|
|
3475
|
-
async getDocumentTrail(model, documentId, options = {}) {
|
|
3476
|
-
return this.query({
|
|
3477
|
-
model,
|
|
3478
|
-
documentId,
|
|
3479
|
-
...options
|
|
3480
|
-
});
|
|
3481
|
-
}
|
|
3482
|
-
/**
|
|
3483
|
-
* Get all audits for a user
|
|
3484
|
-
*/
|
|
3485
|
-
async getUserTrail(userId, options = {}) {
|
|
3486
|
-
return this.query({
|
|
3487
|
-
userId,
|
|
3488
|
-
...options
|
|
3489
|
-
});
|
|
3490
|
-
}
|
|
3491
|
-
/**
|
|
3492
|
-
* Get all audits for an organization
|
|
3493
|
-
*/
|
|
3494
|
-
async getOrgTrail(orgId, options = {}) {
|
|
3495
|
-
return this.query({
|
|
3496
|
-
orgId,
|
|
3497
|
-
...options
|
|
3498
|
-
});
|
|
3499
|
-
}
|
|
3500
|
-
};
|
|
3501
|
-
|
|
3502
|
-
//#endregion
|
|
3503
|
-
//#region src/plugins/elastic.plugin.ts
|
|
3504
|
-
function elasticSearchPlugin(options) {
|
|
3505
|
-
return {
|
|
3506
|
-
name: "elastic-search",
|
|
3507
|
-
apply(repo) {
|
|
3508
|
-
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
3509
|
-
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
3510
|
-
const { client, index, idField = "_id" } = options;
|
|
3511
|
-
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
3512
|
-
const from = Math.max(searchOptions.from || 0, 0);
|
|
3513
|
-
const esResponse = await client.search({
|
|
3514
|
-
index,
|
|
3515
|
-
body: {
|
|
3516
|
-
query: searchQuery,
|
|
3517
|
-
size: limit,
|
|
3518
|
-
from
|
|
3554
|
+
}
|
|
3555
|
+
repo.on("before:delete", async (context) => {
|
|
3556
|
+
if (options.soft !== false) {
|
|
3557
|
+
const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
|
|
3558
|
+
if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
|
|
3559
|
+
const deleteQuery = {
|
|
3560
|
+
_id: context.id,
|
|
3561
|
+
...context.query || {}
|
|
3562
|
+
};
|
|
3563
|
+
if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
|
|
3564
|
+
const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
|
|
3565
|
+
error.status = 404;
|
|
3566
|
+
throw error;
|
|
3519
3567
|
}
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
const
|
|
3535
|
-
if (
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3568
|
+
context.softDeleted = true;
|
|
3569
|
+
}
|
|
3570
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3571
|
+
repo.on("before:getAll", (context) => {
|
|
3572
|
+
if (options.soft !== false) {
|
|
3573
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3574
|
+
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
3575
|
+
...context.filters || {},
|
|
3576
|
+
...deleteFilter
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3580
|
+
repo.on("before:getById", (context) => {
|
|
3581
|
+
if (options.soft !== false) {
|
|
3582
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3583
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3584
|
+
...context.query || {},
|
|
3585
|
+
...deleteFilter
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3589
|
+
repo.on("before:getByQuery", (context) => {
|
|
3590
|
+
if (options.soft !== false) {
|
|
3591
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3592
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3593
|
+
...context.query || {},
|
|
3594
|
+
...deleteFilter
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3598
|
+
repo.on("before:count", (context) => {
|
|
3599
|
+
if (options.soft !== false) {
|
|
3600
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3601
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3602
|
+
...context.query || {},
|
|
3603
|
+
...deleteFilter
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3607
|
+
repo.on("before:exists", (context) => {
|
|
3608
|
+
if (options.soft !== false) {
|
|
3609
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3610
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3611
|
+
...context.query || {},
|
|
3612
|
+
...deleteFilter
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3616
|
+
repo.on("before:getOrCreate", (context) => {
|
|
3617
|
+
if (options.soft !== false) {
|
|
3618
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3619
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3620
|
+
...context.query || {},
|
|
3621
|
+
...deleteFilter
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3625
|
+
repo.on("before:distinct", (context) => {
|
|
3626
|
+
if (options.soft !== false) {
|
|
3627
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3628
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3629
|
+
...context.query || {},
|
|
3630
|
+
...deleteFilter
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3634
|
+
repo.on("before:updateMany", (context) => {
|
|
3635
|
+
if (options.soft !== false) {
|
|
3636
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3637
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3638
|
+
...context.query || {},
|
|
3639
|
+
...deleteFilter
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3643
|
+
repo.on("before:deleteMany", async (context) => {
|
|
3644
|
+
if (options.soft !== false) {
|
|
3645
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
|
|
3646
|
+
const finalQuery = {
|
|
3647
|
+
...context.query || {},
|
|
3648
|
+
...deleteFilter
|
|
3649
|
+
};
|
|
3650
|
+
await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
|
|
3651
|
+
context.softDeleted = true;
|
|
3652
|
+
}
|
|
3653
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3654
|
+
repo.on("before:aggregate", (context) => {
|
|
3655
|
+
if (options.soft !== false) {
|
|
3656
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3657
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3658
|
+
...context.query || {},
|
|
3659
|
+
...deleteFilter
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3663
|
+
repo.on("before:aggregatePaginate", (context) => {
|
|
3664
|
+
if (options.soft !== false) {
|
|
3665
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3666
|
+
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
3667
|
+
...context.filters || {},
|
|
3668
|
+
...deleteFilter
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3672
|
+
if (addRestoreMethod) {
|
|
3673
|
+
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
3674
|
+
const context = await this._buildContext("restore", {
|
|
3675
|
+
id,
|
|
3676
|
+
...restoreOptions
|
|
3677
|
+
});
|
|
3678
|
+
const updateData = {
|
|
3679
|
+
[deletedField]: null,
|
|
3680
|
+
[deletedByField]: null
|
|
3681
|
+
};
|
|
3682
|
+
const restoreQuery = {
|
|
3683
|
+
_id: id,
|
|
3684
|
+
...context.query || {}
|
|
3685
|
+
};
|
|
3686
|
+
const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
|
|
3687
|
+
returnDocument: "after",
|
|
3688
|
+
session: restoreOptions.session
|
|
3689
|
+
});
|
|
3690
|
+
if (!result) {
|
|
3691
|
+
const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
|
|
3692
|
+
error.status = 404;
|
|
3693
|
+
throw error;
|
|
3540
3694
|
}
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3695
|
+
await this.emitAsync("after:restore", {
|
|
3696
|
+
id,
|
|
3697
|
+
result,
|
|
3698
|
+
context
|
|
3699
|
+
});
|
|
3700
|
+
return result;
|
|
3547
3701
|
};
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
}
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
limit
|
|
3567
|
-
|
|
3702
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
|
|
3703
|
+
else repo.restore = restoreMethod.bind(repo);
|
|
3704
|
+
}
|
|
3705
|
+
if (addGetDeletedMethod) {
|
|
3706
|
+
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
3707
|
+
const context = await this._buildContext("getDeleted", {
|
|
3708
|
+
...params,
|
|
3709
|
+
...getDeletedOptions
|
|
3710
|
+
});
|
|
3711
|
+
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
3712
|
+
const combinedFilters = {
|
|
3713
|
+
...params.filters || {},
|
|
3714
|
+
...deletedFilter,
|
|
3715
|
+
...context.filters || {},
|
|
3716
|
+
...context.query || {}
|
|
3717
|
+
};
|
|
3718
|
+
const page = params.page || 1;
|
|
3719
|
+
const limit = params.limit || 20;
|
|
3720
|
+
const skip = (page - 1) * limit;
|
|
3721
|
+
let sortSpec = { [deletedField]: -1 };
|
|
3722
|
+
if (params.sort) if (typeof params.sort === "string") {
|
|
3723
|
+
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
3724
|
+
sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
|
|
3725
|
+
} else sortSpec = params.sort;
|
|
3726
|
+
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
3727
|
+
if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
|
|
3728
|
+
if (getDeletedOptions.select) {
|
|
3729
|
+
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
3730
|
+
query = query.select(selectValue);
|
|
3731
|
+
}
|
|
3732
|
+
if (getDeletedOptions.populate) {
|
|
3733
|
+
const populateSpec = getDeletedOptions.populate;
|
|
3734
|
+
if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
3735
|
+
else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
|
|
3736
|
+
else query = query.populate(populateSpec);
|
|
3737
|
+
}
|
|
3738
|
+
if (getDeletedOptions.lean !== false) query = query.lean();
|
|
3739
|
+
const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
|
|
3740
|
+
const pages = Math.ceil(total / limit);
|
|
3741
|
+
return {
|
|
3742
|
+
method: "offset",
|
|
3743
|
+
docs,
|
|
3744
|
+
page,
|
|
3745
|
+
limit,
|
|
3746
|
+
total,
|
|
3747
|
+
pages,
|
|
3748
|
+
hasNext: page < pages,
|
|
3749
|
+
hasPrev: page > 1
|
|
3750
|
+
};
|
|
3568
3751
|
};
|
|
3569
|
-
|
|
3752
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
|
|
3753
|
+
else repo.getDeleted = getDeletedMethod.bind(repo);
|
|
3754
|
+
}
|
|
3570
3755
|
}
|
|
3571
3756
|
};
|
|
3572
3757
|
}
|
|
3573
|
-
|
|
3574
3758
|
//#endregion
|
|
3575
|
-
//#region src/plugins/
|
|
3759
|
+
//#region src/plugins/subdocument.plugin.ts
|
|
3576
3760
|
/**
|
|
3577
|
-
*
|
|
3578
|
-
*
|
|
3579
|
-
* Generates custom document IDs using pluggable generators.
|
|
3580
|
-
* Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
|
|
3581
|
-
* date-partitioned sequences, and fully custom generators.
|
|
3582
|
-
*
|
|
3583
|
-
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
3584
|
-
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
3585
|
-
*
|
|
3586
|
-
* @example Basic sequential counter
|
|
3587
|
-
* ```typescript
|
|
3588
|
-
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
3589
|
-
* customIdPlugin({
|
|
3590
|
-
* field: 'invoiceNumber',
|
|
3591
|
-
* generator: sequentialId({
|
|
3592
|
-
* prefix: 'INV',
|
|
3593
|
-
* model: InvoiceModel,
|
|
3594
|
-
* }),
|
|
3595
|
-
* }),
|
|
3596
|
-
* ]);
|
|
3597
|
-
*
|
|
3598
|
-
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
3599
|
-
* // inv.invoiceNumber → "INV-0001"
|
|
3600
|
-
* ```
|
|
3761
|
+
* Subdocument plugin for managing nested arrays
|
|
3601
3762
|
*
|
|
3602
|
-
* @example
|
|
3603
|
-
*
|
|
3604
|
-
*
|
|
3605
|
-
*
|
|
3606
|
-
* field: 'billNumber',
|
|
3607
|
-
* generator: dateSequentialId({
|
|
3608
|
-
* prefix: 'BILL',
|
|
3609
|
-
* model: BillModel,
|
|
3610
|
-
* partition: 'monthly',
|
|
3611
|
-
* separator: '-',
|
|
3612
|
-
* padding: 4,
|
|
3613
|
-
* }),
|
|
3614
|
-
* }),
|
|
3763
|
+
* @example
|
|
3764
|
+
* const repo = new Repository(Model, [
|
|
3765
|
+
* methodRegistryPlugin(),
|
|
3766
|
+
* subdocumentPlugin(),
|
|
3615
3767
|
* ]);
|
|
3616
3768
|
*
|
|
3617
|
-
*
|
|
3618
|
-
*
|
|
3619
|
-
* ```
|
|
3620
|
-
*
|
|
3621
|
-
* @example Custom generator function
|
|
3622
|
-
* ```typescript
|
|
3623
|
-
* const orderRepo = new Repository(OrderModel, [
|
|
3624
|
-
* customIdPlugin({
|
|
3625
|
-
* field: 'orderRef',
|
|
3626
|
-
* generator: async (context) => {
|
|
3627
|
-
* const region = context.data?.region || 'US';
|
|
3628
|
-
* const seq = await getNextSequence('orders');
|
|
3629
|
-
* return `ORD-${region}-${seq}`;
|
|
3630
|
-
* },
|
|
3631
|
-
* }),
|
|
3632
|
-
* ]);
|
|
3633
|
-
* ```
|
|
3769
|
+
* await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
|
|
3770
|
+
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
3634
3771
|
*/
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
}
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3772
|
+
function subdocumentPlugin() {
|
|
3773
|
+
return {
|
|
3774
|
+
name: "subdocument",
|
|
3775
|
+
apply(repo) {
|
|
3776
|
+
if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
3777
|
+
/**
|
|
3778
|
+
* Add subdocument to array
|
|
3779
|
+
*/
|
|
3780
|
+
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
3781
|
+
return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
|
|
3782
|
+
});
|
|
3783
|
+
/**
|
|
3784
|
+
* Get subdocument from array
|
|
3785
|
+
*/
|
|
3786
|
+
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
3787
|
+
return this._executeQuery(async (Model) => {
|
|
3788
|
+
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
3789
|
+
if (!parent) throw createError(404, "Parent not found");
|
|
3790
|
+
const arrayField = parent[arrayPath];
|
|
3791
|
+
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
3792
|
+
const sub = arrayField.id(subId);
|
|
3793
|
+
if (!sub) throw createError(404, "Subdocument not found");
|
|
3794
|
+
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
3795
|
+
});
|
|
3796
|
+
});
|
|
3797
|
+
/**
|
|
3798
|
+
* Update subdocument in array
|
|
3799
|
+
*/
|
|
3800
|
+
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
3801
|
+
return this._executeQuery(async (Model) => {
|
|
3802
|
+
const query = {
|
|
3803
|
+
_id: parentId,
|
|
3804
|
+
[`${arrayPath}._id`]: subId
|
|
3805
|
+
};
|
|
3806
|
+
const update = { $set: { [`${arrayPath}.$`]: {
|
|
3807
|
+
...updateData,
|
|
3808
|
+
_id: subId
|
|
3809
|
+
} } };
|
|
3810
|
+
const result = await Model.findOneAndUpdate(query, update, {
|
|
3811
|
+
returnDocument: "after",
|
|
3812
|
+
runValidators: true,
|
|
3813
|
+
session: options.session
|
|
3814
|
+
}).exec();
|
|
3815
|
+
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
3816
|
+
return result;
|
|
3817
|
+
});
|
|
3818
|
+
});
|
|
3819
|
+
/**
|
|
3820
|
+
* Delete subdocument from array
|
|
3821
|
+
*/
|
|
3822
|
+
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
3823
|
+
return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
//#endregion
|
|
3829
|
+
//#region src/plugins/timestamp.plugin.ts
|
|
3649
3830
|
/**
|
|
3650
|
-
*
|
|
3651
|
-
*
|
|
3652
|
-
*
|
|
3831
|
+
* Timestamp plugin that auto-injects timestamps
|
|
3832
|
+
*
|
|
3833
|
+
* @example
|
|
3834
|
+
* const repo = new Repository(Model, [timestampPlugin()]);
|
|
3653
3835
|
*/
|
|
3654
|
-
function
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3836
|
+
function timestampPlugin() {
|
|
3837
|
+
return {
|
|
3838
|
+
name: "timestamp",
|
|
3839
|
+
apply(repo) {
|
|
3840
|
+
repo.on("before:create", (context) => {
|
|
3841
|
+
if (!context.data) return;
|
|
3842
|
+
const now = /* @__PURE__ */ new Date();
|
|
3843
|
+
if (!context.data.createdAt) context.data.createdAt = now;
|
|
3844
|
+
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
3845
|
+
});
|
|
3846
|
+
repo.on("before:update", (context) => {
|
|
3847
|
+
if (!context.data) return;
|
|
3848
|
+
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
};
|
|
3658
3852
|
}
|
|
3853
|
+
//#endregion
|
|
3854
|
+
//#region src/plugins/validation-chain.plugin.ts
|
|
3659
3855
|
/**
|
|
3660
|
-
*
|
|
3661
|
-
* Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
|
|
3662
|
-
* heavy concurrency.
|
|
3663
|
-
*
|
|
3664
|
-
* @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
|
|
3665
|
-
* @param increment - Value to increment by (default: 1)
|
|
3666
|
-
* @returns The next sequence number (after increment)
|
|
3856
|
+
* Validation chain plugin
|
|
3667
3857
|
*
|
|
3668
3858
|
* @example
|
|
3669
|
-
* const
|
|
3670
|
-
*
|
|
3671
|
-
*
|
|
3672
|
-
*
|
|
3673
|
-
*
|
|
3674
|
-
*
|
|
3859
|
+
* const repo = new Repository(Model, [
|
|
3860
|
+
* validationChainPlugin([
|
|
3861
|
+
* requireField('email'),
|
|
3862
|
+
* uniqueField('email', 'Email already exists'),
|
|
3863
|
+
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
3864
|
+
* ])
|
|
3865
|
+
* ]);
|
|
3675
3866
|
*/
|
|
3676
|
-
|
|
3677
|
-
const
|
|
3678
|
-
|
|
3679
|
-
|
|
3867
|
+
function validationChainPlugin(validators = [], options = {}) {
|
|
3868
|
+
const { stopOnFirstError = true } = options;
|
|
3869
|
+
validators.forEach((v, idx) => {
|
|
3870
|
+
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
3871
|
+
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
3680
3872
|
});
|
|
3681
|
-
|
|
3682
|
-
|
|
3873
|
+
const validatorsByOperation = {
|
|
3874
|
+
create: [],
|
|
3875
|
+
update: [],
|
|
3876
|
+
delete: [],
|
|
3877
|
+
createMany: []
|
|
3878
|
+
};
|
|
3879
|
+
const allOperationsValidators = [];
|
|
3880
|
+
validators.forEach((v) => {
|
|
3881
|
+
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
3882
|
+
else v.operations.forEach((op) => {
|
|
3883
|
+
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
3884
|
+
});
|
|
3885
|
+
});
|
|
3886
|
+
return {
|
|
3887
|
+
name: "validation-chain",
|
|
3888
|
+
apply(repo) {
|
|
3889
|
+
const getValidatorsForOperation = (operation) => {
|
|
3890
|
+
const specific = validatorsByOperation[operation] || [];
|
|
3891
|
+
return [...allOperationsValidators, ...specific];
|
|
3892
|
+
};
|
|
3893
|
+
const runValidators = async (operation, context) => {
|
|
3894
|
+
const operationValidators = getValidatorsForOperation(operation);
|
|
3895
|
+
const errors = [];
|
|
3896
|
+
for (const validator of operationValidators) try {
|
|
3897
|
+
await validator.validate(context, repo);
|
|
3898
|
+
} catch (error) {
|
|
3899
|
+
if (stopOnFirstError) throw error;
|
|
3900
|
+
errors.push({
|
|
3901
|
+
validator: validator.name,
|
|
3902
|
+
error: error.message || String(error)
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3905
|
+
if (errors.length > 0) {
|
|
3906
|
+
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
3907
|
+
err.validationErrors = errors;
|
|
3908
|
+
throw err;
|
|
3909
|
+
}
|
|
3910
|
+
};
|
|
3911
|
+
repo.on("before:create", async (context) => runValidators("create", context));
|
|
3912
|
+
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
3913
|
+
repo.on("before:update", async (context) => runValidators("update", context));
|
|
3914
|
+
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3683
3917
|
}
|
|
3684
3918
|
/**
|
|
3685
|
-
*
|
|
3686
|
-
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
3687
|
-
*
|
|
3688
|
-
* Uses atomic MongoDB counters — safe under concurrency.
|
|
3919
|
+
* Block operation if condition is true
|
|
3689
3920
|
*
|
|
3690
3921
|
* @example
|
|
3691
|
-
*
|
|
3692
|
-
* customIdPlugin({
|
|
3693
|
-
* field: 'invoiceNumber',
|
|
3694
|
-
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
3695
|
-
* })
|
|
3696
|
-
* ```
|
|
3922
|
+
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
3697
3923
|
*/
|
|
3698
|
-
function
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3924
|
+
function blockIf(name, operations, condition, errorMessage) {
|
|
3925
|
+
return {
|
|
3926
|
+
name,
|
|
3927
|
+
operations,
|
|
3928
|
+
validate: (context) => {
|
|
3929
|
+
if (condition(context)) throw createError(403, errorMessage);
|
|
3930
|
+
}
|
|
3704
3931
|
};
|
|
3705
3932
|
}
|
|
3706
3933
|
/**
|
|
3707
|
-
*
|
|
3708
|
-
* Counter resets per period — great for invoice/bill numbering.
|
|
3709
|
-
*
|
|
3710
|
-
* Produces IDs like:
|
|
3711
|
-
* - yearly: `BILL-2026-0001`
|
|
3712
|
-
* - monthly: `BILL-2026-02-0001`
|
|
3713
|
-
* - daily: `BILL-2026-02-20-0001`
|
|
3714
|
-
*
|
|
3715
|
-
* @example
|
|
3716
|
-
* ```typescript
|
|
3717
|
-
* customIdPlugin({
|
|
3718
|
-
* field: 'billNumber',
|
|
3719
|
-
* generator: dateSequentialId({
|
|
3720
|
-
* prefix: 'BILL',
|
|
3721
|
-
* model: BillModel,
|
|
3722
|
-
* partition: 'monthly',
|
|
3723
|
-
* }),
|
|
3724
|
-
* })
|
|
3725
|
-
* ```
|
|
3934
|
+
* Require a field to be present
|
|
3726
3935
|
*/
|
|
3727
|
-
function
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
const day = String(now.getDate()).padStart(2, "0");
|
|
3734
|
-
let datePart;
|
|
3735
|
-
let counterKey;
|
|
3736
|
-
switch (partition) {
|
|
3737
|
-
case "yearly":
|
|
3738
|
-
datePart = year;
|
|
3739
|
-
counterKey = `${model.modelName}:${year}`;
|
|
3740
|
-
break;
|
|
3741
|
-
case "daily":
|
|
3742
|
-
datePart = `${year}${separator}${month}${separator}${day}`;
|
|
3743
|
-
counterKey = `${model.modelName}:${year}-${month}-${day}`;
|
|
3744
|
-
break;
|
|
3745
|
-
default:
|
|
3746
|
-
datePart = `${year}${separator}${month}`;
|
|
3747
|
-
counterKey = `${model.modelName}:${year}-${month}`;
|
|
3748
|
-
break;
|
|
3936
|
+
function requireField(field, operations = ["create"]) {
|
|
3937
|
+
return {
|
|
3938
|
+
name: `require-${field}`,
|
|
3939
|
+
operations,
|
|
3940
|
+
validate: (context) => {
|
|
3941
|
+
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
3749
3942
|
}
|
|
3750
|
-
const seq = await getNextSequence(counterKey, 1, context._counterConnection);
|
|
3751
|
-
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
3752
3943
|
};
|
|
3753
3944
|
}
|
|
3754
3945
|
/**
|
|
3755
|
-
*
|
|
3756
|
-
* Does NOT require a database round-trip — purely in-memory.
|
|
3757
|
-
*
|
|
3758
|
-
* Produces IDs like: `USR_a7b3xk9m2p1q`
|
|
3759
|
-
*
|
|
3760
|
-
* Good for: user-facing IDs where ordering doesn't matter.
|
|
3761
|
-
* Not suitable for sequential numbering.
|
|
3762
|
-
*
|
|
3763
|
-
* @example
|
|
3764
|
-
* ```typescript
|
|
3765
|
-
* customIdPlugin({
|
|
3766
|
-
* field: 'publicId',
|
|
3767
|
-
* generator: prefixedId({ prefix: 'USR', length: 10 }),
|
|
3768
|
-
* })
|
|
3769
|
-
* ```
|
|
3946
|
+
* Auto-inject a value if not present
|
|
3770
3947
|
*/
|
|
3771
|
-
function
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
}
|
|
3781
|
-
return `${prefix}${separator}${result}`;
|
|
3948
|
+
function autoInject(field, getter, operations = ["create"]) {
|
|
3949
|
+
return {
|
|
3950
|
+
name: `auto-inject-${field}`,
|
|
3951
|
+
operations,
|
|
3952
|
+
validate: (context) => {
|
|
3953
|
+
if (context.data && !(field in context.data)) {
|
|
3954
|
+
const value = getter(context);
|
|
3955
|
+
if (value !== null && value !== void 0) context.data[field] = value;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3782
3958
|
};
|
|
3783
3959
|
}
|
|
3784
3960
|
/**
|
|
3785
|
-
*
|
|
3786
|
-
*
|
|
3787
|
-
* @param options - Configuration for ID generation
|
|
3788
|
-
* @returns Plugin instance
|
|
3789
|
-
*
|
|
3790
|
-
* @example
|
|
3791
|
-
* ```typescript
|
|
3792
|
-
* import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
|
|
3793
|
-
*
|
|
3794
|
-
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
3795
|
-
* customIdPlugin({
|
|
3796
|
-
* field: 'invoiceNumber',
|
|
3797
|
-
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
3798
|
-
* }),
|
|
3799
|
-
* ]);
|
|
3800
|
-
*
|
|
3801
|
-
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
3802
|
-
* console.log(inv.invoiceNumber); // "INV-0001"
|
|
3803
|
-
* ```
|
|
3961
|
+
* Make a field immutable (cannot be updated)
|
|
3804
3962
|
*/
|
|
3805
|
-
function
|
|
3806
|
-
const fieldName = options.field || "customId";
|
|
3807
|
-
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
3963
|
+
function immutableField(field) {
|
|
3808
3964
|
return {
|
|
3809
|
-
name:
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3965
|
+
name: `immutable-${field}`,
|
|
3966
|
+
operations: ["update"],
|
|
3967
|
+
validate: (context) => {
|
|
3968
|
+
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3973
|
+
* Ensure field value is unique
|
|
3974
|
+
*/
|
|
3975
|
+
function uniqueField(field, errorMessage) {
|
|
3976
|
+
return {
|
|
3977
|
+
name: `unique-${field}`,
|
|
3978
|
+
operations: ["create", "update"],
|
|
3979
|
+
validate: async (context, repo) => {
|
|
3980
|
+
if (!context.data?.[field]) return;
|
|
3981
|
+
if (!repo) {
|
|
3982
|
+
warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
|
|
3983
|
+
return;
|
|
3984
|
+
}
|
|
3985
|
+
const query = { [field]: context.data[field] };
|
|
3986
|
+
const getByQuery = repo.getByQuery;
|
|
3987
|
+
if (typeof getByQuery !== "function") {
|
|
3988
|
+
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
3991
|
+
const existing = await getByQuery.call(repo, query, {
|
|
3992
|
+
select: "_id",
|
|
3993
|
+
lean: true,
|
|
3994
|
+
throwOnNotFound: false
|
|
3831
3995
|
});
|
|
3996
|
+
if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
3832
3997
|
}
|
|
3833
3998
|
};
|
|
3834
3999
|
}
|
|
3835
|
-
|
|
3836
4000
|
//#endregion
|
|
3837
|
-
export {
|
|
4001
|
+
export { aggregateHelpersPlugin as A, HOOK_PRIORITY as C, AuditTrailQuery as D, batchOperationsPlugin as E, auditTrailPlugin as O, cachePlugin as S, AggregationBuilder as T, dateSequentialId as _, uniqueField as a, sequentialId as b, subdocumentPlugin as c, multiTenantPlugin as d, mongoOperationsPlugin as f, customIdPlugin as g, elasticSearchPlugin as h, requireField as i, auditLogPlugin as k, softDeletePlugin as l, fieldFilterPlugin as m, blockIf as n, validationChainPlugin as o, methodRegistryPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, observabilityPlugin as u, getNextSequence as v, Repository as w, cascadePlugin as x, prefixedId as y };
|