@classytic/mongokit 3.3.2 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -5
- package/dist/{limits-s1-d8rWb.mjs → PaginationEngine-PLyDhrO7.mjs} +260 -60
- 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 +1 -2
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/{logger-D8ily-PP.mjs → error-Bpbi_NKo.mjs} +34 -22
- package/dist/{cache-keys-CzFwVnLy.mjs → field-selection-CalOB7yM.mjs} +110 -112
- package/dist/{aggregate-BkOG9qwr.d.mts → index-Df3ernpC.d.mts} +132 -129
- package/dist/index.d.mts +543 -543
- package/dist/index.mjs +25 -100
- package/dist/{mongooseToJsonSchema-B6O2ED3n.d.mts → mongooseToJsonSchema-BqgVOlrR.d.mts} +24 -17
- package/dist/{mongooseToJsonSchema-D_i2Am_O.mjs → mongooseToJsonSchema-OmdmnHtx.mjs} +13 -12
- 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/{types-pVY0w1Pp.d.mts → types-BlCwDszq.d.mts} +25 -23
- package/dist/{aggregate-BClp040M.mjs → update-DXwVh6M1.mjs} +674 -671
- 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-DxqiHv-E.d.mts} +832 -832
- package/dist/{custom-id.plugin-FInXDsUX.mjs → validation-chain.plugin-Ow6EUIoo.mjs} +2272 -2210
- package/package.json +10 -5
- package/dist/chunk-DQk6qfdC.mjs +0 -18
|
@@ -1,98 +1,712 @@
|
|
|
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-DXwVh6M1.mjs";
|
|
3
|
+
import { t as PaginationEngine } from "./PaginationEngine-PLyDhrO7.mjs";
|
|
4
|
+
import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-CalOB7yM.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.call(this, 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.call(this, 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
|
|
473
|
+
*/
|
|
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.call(this, "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.call(this, 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.call(this, "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.call(this, error);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* Delete multiple documents
|
|
583
|
+
*/
|
|
584
|
+
repo.registerMethod("deleteMany", async function(query, options = {}) {
|
|
585
|
+
const context = await this._buildContext.call(this, "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.call(this, 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
|
|
96
710
|
*/
|
|
97
711
|
project(projection) {
|
|
98
712
|
this.pipeline.push({ $project: projection });
|
|
@@ -489,36 +1103,9 @@ 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
1108
|
/**
|
|
496
|
-
* Repository Pattern - Data Access Layer
|
|
497
|
-
*
|
|
498
|
-
* Event-driven, plugin-based abstraction for MongoDB operations
|
|
499
|
-
* Inspired by Meta & Stripe's repository patterns
|
|
500
|
-
*
|
|
501
|
-
* @example
|
|
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
|
-
*/
|
|
521
|
-
/**
|
|
522
1109
|
* Plugin phase priorities (lower = runs first)
|
|
523
1110
|
* Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
|
|
524
1111
|
* to ensure filters are injected before cache keys are computed.
|
|
@@ -546,7 +1133,9 @@ var Repository = class {
|
|
|
546
1133
|
this._hooks = /* @__PURE__ */ new Map();
|
|
547
1134
|
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
548
1135
|
this._hookMode = options.hooks ?? "async";
|
|
549
|
-
plugins.forEach((plugin) =>
|
|
1136
|
+
plugins.forEach((plugin) => {
|
|
1137
|
+
this.use(plugin);
|
|
1138
|
+
});
|
|
550
1139
|
}
|
|
551
1140
|
/**
|
|
552
1141
|
* Register a plugin
|
|
@@ -807,7 +1396,7 @@ var Repository = class {
|
|
|
807
1396
|
let useKeyset = false;
|
|
808
1397
|
if (mode) useKeyset = mode === "keyset";
|
|
809
1398
|
else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
|
|
810
|
-
|
|
1399
|
+
const query = { ...filters };
|
|
811
1400
|
if (search) {
|
|
812
1401
|
if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
|
|
813
1402
|
if (this._hasTextIndex) query.$text = { $search: search };
|
|
@@ -826,18 +1415,30 @@ var Repository = class {
|
|
|
826
1415
|
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
827
1416
|
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
|
|
828
1417
|
};
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1418
|
+
const lookups = params.lookups;
|
|
1419
|
+
if (lookups && lookups.length > 0) try {
|
|
1420
|
+
const lookupResult = await this.lookupPopulate({
|
|
1421
|
+
filters: query,
|
|
1422
|
+
lookups,
|
|
833
1423
|
sort: paginationOptions.sort,
|
|
834
|
-
after
|
|
835
|
-
});
|
|
836
|
-
else result = await this._pagination.paginate({
|
|
837
|
-
...paginationOptions,
|
|
838
1424
|
page: page || 1,
|
|
839
|
-
|
|
840
|
-
|
|
1425
|
+
limit,
|
|
1426
|
+
select: paginationOptions.select,
|
|
1427
|
+
session: options.session,
|
|
1428
|
+
readPreference: paginationOptions.readPreference
|
|
1429
|
+
});
|
|
1430
|
+
const totalPages = Math.ceil((lookupResult.total ?? 0) / (lookupResult.limit ?? limit));
|
|
1431
|
+
const currentPage = lookupResult.page ?? 1;
|
|
1432
|
+
const result = {
|
|
1433
|
+
method: "offset",
|
|
1434
|
+
docs: lookupResult.data,
|
|
1435
|
+
page: currentPage,
|
|
1436
|
+
limit: lookupResult.limit ?? limit,
|
|
1437
|
+
total: lookupResult.total ?? 0,
|
|
1438
|
+
pages: totalPages,
|
|
1439
|
+
hasNext: currentPage < totalPages,
|
|
1440
|
+
hasPrev: currentPage > 1
|
|
1441
|
+
};
|
|
841
1442
|
await this._emitHook("after:getAll", {
|
|
842
1443
|
context,
|
|
843
1444
|
result
|
|
@@ -850,9 +1451,33 @@ var Repository = class {
|
|
|
850
1451
|
});
|
|
851
1452
|
throw this._handleError(error);
|
|
852
1453
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1454
|
+
try {
|
|
1455
|
+
let result;
|
|
1456
|
+
if (useKeyset) result = await this._pagination.stream({
|
|
1457
|
+
...paginationOptions,
|
|
1458
|
+
sort: paginationOptions.sort,
|
|
1459
|
+
after
|
|
1460
|
+
});
|
|
1461
|
+
else result = await this._pagination.paginate({
|
|
1462
|
+
...paginationOptions,
|
|
1463
|
+
page: page || 1,
|
|
1464
|
+
countStrategy: context.countStrategy ?? params.countStrategy
|
|
1465
|
+
});
|
|
1466
|
+
await this._emitHook("after:getAll", {
|
|
1467
|
+
context,
|
|
1468
|
+
result
|
|
1469
|
+
});
|
|
1470
|
+
return result;
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
await this._emitErrorHook("error:getAll", {
|
|
1473
|
+
context,
|
|
1474
|
+
error
|
|
1475
|
+
});
|
|
1476
|
+
throw this._handleError(error);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Get or create document
|
|
856
1481
|
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
857
1482
|
*/
|
|
858
1483
|
async getOrCreate(query, createData, options = {}) {
|
|
@@ -1340,1614 +1965,1205 @@ var Repository = class {
|
|
|
1340
1965
|
_handleError(error) {
|
|
1341
1966
|
if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
|
|
1342
1967
|
if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
1968
|
+
const duplicateErr = parseDuplicateKeyError(error);
|
|
1969
|
+
if (duplicateErr) return duplicateErr;
|
|
1343
1970
|
if (error.status && error.message) return error;
|
|
1344
1971
|
return createError(500, error.message || "Internal Server Error");
|
|
1345
1972
|
}
|
|
1346
1973
|
};
|
|
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
1974
|
//#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
|
-
}
|
|
1975
|
+
//#region src/plugins/cache.plugin.ts
|
|
1492
1976
|
/**
|
|
1493
|
-
*
|
|
1977
|
+
* Cache Plugin
|
|
1494
1978
|
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
* const repo = new Repository(Model, [
|
|
1498
|
-
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
1499
|
-
* ]);
|
|
1979
|
+
* Optional caching layer for MongoKit with automatic invalidation.
|
|
1980
|
+
* Bring-your-own cache adapter (Redis, Memcached, in-memory, etc.)
|
|
1500
1981
|
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1982
|
+
* Features:
|
|
1983
|
+
* - Cache-aside (read-through) pattern with configurable TTLs
|
|
1984
|
+
* - Automatic invalidation on create/update/delete
|
|
1985
|
+
* - Collection version tags for efficient list cache invalidation
|
|
1986
|
+
* - Manual invalidation methods for microservice scenarios
|
|
1987
|
+
* - Skip cache per-operation with `skipCache: true`
|
|
1503
1988
|
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1989
|
+
* @example
|
|
1990
|
+
* ```typescript
|
|
1991
|
+
* import { Repository, cachePlugin } from '@classytic/mongokit';
|
|
1992
|
+
* import Redis from 'ioredis';
|
|
1506
1993
|
*
|
|
1507
|
-
*
|
|
1508
|
-
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
1509
|
-
* ```
|
|
1994
|
+
* const redis = new Redis();
|
|
1510
1995
|
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1996
|
+
* const userRepo = new Repository(UserModel, [
|
|
1997
|
+
* cachePlugin({
|
|
1998
|
+
* adapter: {
|
|
1999
|
+
* async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
|
|
2000
|
+
* async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
|
|
2001
|
+
* async del(key) { await redis.del(key); },
|
|
2002
|
+
* async clear(pattern) {
|
|
2003
|
+
* const keys = await redis.keys(pattern || '*');
|
|
2004
|
+
* if (keys.length) await redis.del(...keys);
|
|
2005
|
+
* }
|
|
2006
|
+
* },
|
|
2007
|
+
* ttl: 60, // 1 minute default
|
|
1518
2008
|
* })
|
|
1519
2009
|
* ]);
|
|
1520
|
-
* ```
|
|
1521
2010
|
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
2011
|
+
* // Reads check cache first
|
|
2012
|
+
* const user = await userRepo.getById(id); // cached
|
|
2013
|
+
*
|
|
2014
|
+
* // Skip cache for fresh data
|
|
2015
|
+
* const fresh = await userRepo.getById(id, { skipCache: true });
|
|
2016
|
+
*
|
|
2017
|
+
* // Mutations auto-invalidate
|
|
2018
|
+
* await userRepo.update(id, { name: 'New Name' }); // invalidates cache
|
|
2019
|
+
*
|
|
2020
|
+
* // Manual invalidation for microservice sync
|
|
2021
|
+
* await userRepo.invalidateCache(id); // invalidate single doc
|
|
2022
|
+
* await userRepo.invalidateAllCache(); // invalidate all for this model
|
|
1530
2023
|
* ```
|
|
1531
2024
|
*/
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
2025
|
+
/**
|
|
2026
|
+
* Cache plugin factory
|
|
2027
|
+
*
|
|
2028
|
+
* @param options - Cache configuration
|
|
2029
|
+
* @returns Plugin instance
|
|
2030
|
+
*/
|
|
2031
|
+
function cachePlugin(options) {
|
|
2032
|
+
const config = {
|
|
2033
|
+
adapter: options.adapter,
|
|
2034
|
+
ttl: options.ttl ?? 60,
|
|
2035
|
+
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
2036
|
+
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
2037
|
+
prefix: options.prefix ?? "mk",
|
|
2038
|
+
debug: options.debug ?? false,
|
|
2039
|
+
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
|
|
2040
|
+
};
|
|
2041
|
+
const stats = {
|
|
2042
|
+
hits: 0,
|
|
2043
|
+
misses: 0,
|
|
2044
|
+
sets: 0,
|
|
2045
|
+
invalidations: 0,
|
|
2046
|
+
errors: 0
|
|
2047
|
+
};
|
|
2048
|
+
const log = (msg, data) => {
|
|
2049
|
+
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
2050
|
+
};
|
|
1539
2051
|
return {
|
|
1540
|
-
name: "
|
|
2052
|
+
name: "cache",
|
|
1541
2053
|
apply(repo) {
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
2054
|
+
const model = repo.model;
|
|
2055
|
+
const byIdKeyRegistry = /* @__PURE__ */ new Map();
|
|
2056
|
+
function trackByIdKey(docId, cacheKey) {
|
|
2057
|
+
let keys = byIdKeyRegistry.get(docId);
|
|
2058
|
+
if (!keys) {
|
|
2059
|
+
keys = /* @__PURE__ */ new Set();
|
|
2060
|
+
byIdKeyRegistry.set(docId, keys);
|
|
1547
2061
|
}
|
|
1548
|
-
|
|
1549
|
-
warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2062
|
+
keys.add(cacheKey);
|
|
1550
2063
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
});
|
|
2064
|
+
async function getVersion() {
|
|
2065
|
+
try {
|
|
2066
|
+
return await config.adapter.get(versionKey(config.prefix, model)) ?? 0;
|
|
2067
|
+
} catch (e) {
|
|
2068
|
+
log(`Cache error in getVersion for ${model}:`, e);
|
|
2069
|
+
return 0;
|
|
2070
|
+
}
|
|
1559
2071
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
}
|
|
1573
|
-
context.softDeleted = true;
|
|
2072
|
+
/**
|
|
2073
|
+
* Bump collection version in the adapter (invalidates all list caches).
|
|
2074
|
+
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
2075
|
+
*/
|
|
2076
|
+
async function bumpVersion() {
|
|
2077
|
+
const newVersion = Date.now();
|
|
2078
|
+
try {
|
|
2079
|
+
await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
|
|
2080
|
+
stats.invalidations++;
|
|
2081
|
+
log(`Bumped version for ${model} to:`, newVersion);
|
|
2082
|
+
} catch (e) {
|
|
2083
|
+
log(`Failed to bump version for ${model}:`, e);
|
|
1574
2084
|
}
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Invalidate a specific document by ID (all shape variants).
|
|
2088
|
+
* Deletes every tracked shape-variant key individually via del(),
|
|
2089
|
+
* so adapters without pattern-based clear() still get full invalidation.
|
|
2090
|
+
*/
|
|
2091
|
+
async function invalidateById(id) {
|
|
2092
|
+
try {
|
|
2093
|
+
const baseKey = byIdKey(config.prefix, model, id);
|
|
2094
|
+
await config.adapter.del(baseKey);
|
|
2095
|
+
const trackedKeys = byIdKeyRegistry.get(id);
|
|
2096
|
+
if (trackedKeys) {
|
|
2097
|
+
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.del(key);
|
|
2098
|
+
byIdKeyRegistry.delete(id);
|
|
2099
|
+
}
|
|
2100
|
+
stats.invalidations++;
|
|
2101
|
+
log(`Invalidated byId cache for:`, id);
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
log(`Failed to invalidate byId cache:`, e);
|
|
1583
2104
|
}
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
};
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* before:getById - Check cache for document
|
|
2108
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2109
|
+
*/
|
|
2110
|
+
repo.on("before:getById", async (context) => {
|
|
2111
|
+
if (context.skipCache) {
|
|
2112
|
+
log(`Skipping cache for getById: ${context.id}`);
|
|
2113
|
+
return;
|
|
1592
2114
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2115
|
+
const id = String(context.id);
|
|
2116
|
+
const key = byIdKey(config.prefix, model, id, {
|
|
2117
|
+
select: context.select,
|
|
2118
|
+
populate: context.populate,
|
|
2119
|
+
lean: context.lean
|
|
2120
|
+
});
|
|
2121
|
+
try {
|
|
2122
|
+
const cached = await config.adapter.get(key);
|
|
2123
|
+
if (cached !== null) {
|
|
2124
|
+
stats.hits++;
|
|
2125
|
+
log(`Cache HIT for getById:`, key);
|
|
2126
|
+
context._cacheHit = true;
|
|
2127
|
+
context._cachedResult = cached;
|
|
2128
|
+
} else {
|
|
2129
|
+
stats.misses++;
|
|
2130
|
+
log(`Cache MISS for getById:`, key);
|
|
2131
|
+
}
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
log(`Cache error for getById:`, e);
|
|
2134
|
+
stats.errors++;
|
|
1601
2135
|
}
|
|
1602
|
-
}, { priority: HOOK_PRIORITY.
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2136
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2137
|
+
/**
|
|
2138
|
+
* before:getByQuery - Check cache for single-doc query
|
|
2139
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2140
|
+
*/
|
|
2141
|
+
repo.on("before:getByQuery", async (context) => {
|
|
2142
|
+
if (context.skipCache) {
|
|
2143
|
+
log(`Skipping cache for getByQuery`);
|
|
2144
|
+
return;
|
|
1610
2145
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
2146
|
+
const collectionVersion = await getVersion();
|
|
2147
|
+
const query = context.query || {};
|
|
2148
|
+
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2149
|
+
select: context.select,
|
|
2150
|
+
populate: context.populate
|
|
2151
|
+
});
|
|
2152
|
+
try {
|
|
2153
|
+
const cached = await config.adapter.get(key);
|
|
2154
|
+
if (cached !== null) {
|
|
2155
|
+
stats.hits++;
|
|
2156
|
+
log(`Cache HIT for getByQuery:`, key);
|
|
2157
|
+
context._cacheHit = true;
|
|
2158
|
+
context._cachedResult = cached;
|
|
2159
|
+
} else {
|
|
2160
|
+
stats.misses++;
|
|
2161
|
+
log(`Cache MISS for getByQuery:`, key);
|
|
2162
|
+
}
|
|
2163
|
+
} catch (e) {
|
|
2164
|
+
log(`Cache error for getByQuery:`, e);
|
|
2165
|
+
stats.errors++;
|
|
1619
2166
|
}
|
|
1620
|
-
}, { priority: HOOK_PRIORITY.
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2167
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2168
|
+
/**
|
|
2169
|
+
* before:getAll - Check cache for list query
|
|
2170
|
+
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2171
|
+
*/
|
|
2172
|
+
repo.on("before:getAll", async (context) => {
|
|
2173
|
+
if (context.skipCache) {
|
|
2174
|
+
log(`Skipping cache for getAll`);
|
|
2175
|
+
return;
|
|
1628
2176
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
1634
|
-
...context.query || {},
|
|
1635
|
-
...deleteFilter
|
|
1636
|
-
};
|
|
2177
|
+
const limit = context.limit;
|
|
2178
|
+
if (limit && limit > config.skipIfLargeLimit) {
|
|
2179
|
+
log(`Skipping cache for large query (limit: ${limit})`);
|
|
2180
|
+
return;
|
|
1637
2181
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2182
|
+
const collectionVersion = await getVersion();
|
|
2183
|
+
const params = {
|
|
2184
|
+
filters: context.filters,
|
|
2185
|
+
sort: context.sort,
|
|
2186
|
+
page: context.page,
|
|
2187
|
+
limit,
|
|
2188
|
+
after: context.after,
|
|
2189
|
+
select: context.select,
|
|
2190
|
+
populate: context.populate,
|
|
2191
|
+
search: context.search,
|
|
2192
|
+
mode: context.mode,
|
|
2193
|
+
lean: context.lean,
|
|
2194
|
+
readPreference: context.readPreference,
|
|
2195
|
+
hint: context.hint,
|
|
2196
|
+
maxTimeMS: context.maxTimeMS,
|
|
2197
|
+
countStrategy: context.countStrategy
|
|
2198
|
+
};
|
|
2199
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2200
|
+
try {
|
|
2201
|
+
const cached = await config.adapter.get(key);
|
|
2202
|
+
if (cached !== null) {
|
|
2203
|
+
stats.hits++;
|
|
2204
|
+
log(`Cache HIT for getAll:`, key);
|
|
2205
|
+
context._cacheHit = true;
|
|
2206
|
+
context._cachedResult = cached;
|
|
2207
|
+
} else {
|
|
2208
|
+
stats.misses++;
|
|
2209
|
+
log(`Cache MISS for getAll:`, key);
|
|
2210
|
+
}
|
|
2211
|
+
} catch (e) {
|
|
2212
|
+
log(`Cache error for getAll:`, e);
|
|
2213
|
+
stats.errors++;
|
|
1646
2214
|
}
|
|
1647
|
-
}, { priority: HOOK_PRIORITY.
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2215
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
2216
|
+
/**
|
|
2217
|
+
* after:getById - Cache the result
|
|
2218
|
+
*/
|
|
2219
|
+
repo.on("after:getById", async (payload) => {
|
|
2220
|
+
const { context, result } = payload;
|
|
2221
|
+
if (context._cacheHit) return;
|
|
2222
|
+
if (context.skipCache) return;
|
|
2223
|
+
if (result === null) return;
|
|
2224
|
+
const id = String(context.id);
|
|
2225
|
+
const key = byIdKey(config.prefix, model, id, {
|
|
2226
|
+
select: context.select,
|
|
2227
|
+
populate: context.populate,
|
|
2228
|
+
lean: context.lean
|
|
2229
|
+
});
|
|
2230
|
+
const ttl = context.cacheTtl ?? config.byIdTtl;
|
|
2231
|
+
try {
|
|
2232
|
+
await config.adapter.set(key, result, ttl);
|
|
2233
|
+
trackByIdKey(id, key);
|
|
2234
|
+
stats.sets++;
|
|
2235
|
+
log(`Cached getById result:`, key);
|
|
2236
|
+
} catch (e) {
|
|
2237
|
+
log(`Failed to cache getById:`, e);
|
|
1655
2238
|
}
|
|
1656
|
-
}
|
|
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 = [];
|
|
2239
|
+
});
|
|
1754
2240
|
/**
|
|
1755
|
-
*
|
|
2241
|
+
* after:getByQuery - Cache the result
|
|
1756
2242
|
*/
|
|
1757
|
-
repo.
|
|
1758
|
-
|
|
1759
|
-
if (
|
|
1760
|
-
if (
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2243
|
+
repo.on("after:getByQuery", async (payload) => {
|
|
2244
|
+
const { context, result } = payload;
|
|
2245
|
+
if (context._cacheHit) return;
|
|
2246
|
+
if (context.skipCache) return;
|
|
2247
|
+
if (result === null) return;
|
|
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
|
|
1766
2253
|
});
|
|
2254
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2255
|
+
try {
|
|
2256
|
+
await config.adapter.set(key, result, ttl);
|
|
2257
|
+
stats.sets++;
|
|
2258
|
+
log(`Cached getByQuery result:`, key);
|
|
2259
|
+
} catch (e) {
|
|
2260
|
+
log(`Failed to cache getByQuery:`, e);
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
/**
|
|
2264
|
+
* after:getAll - Cache the result
|
|
2265
|
+
*/
|
|
2266
|
+
repo.on("after:getAll", async (payload) => {
|
|
2267
|
+
const { context, result } = payload;
|
|
2268
|
+
if (context._cacheHit) return;
|
|
2269
|
+
if (context.skipCache) return;
|
|
2270
|
+
const limit = context.limit;
|
|
2271
|
+
if (limit && limit > config.skipIfLargeLimit) return;
|
|
2272
|
+
const collectionVersion = await getVersion();
|
|
2273
|
+
const params = {
|
|
2274
|
+
filters: context.filters,
|
|
2275
|
+
sort: context.sort,
|
|
2276
|
+
page: context.page,
|
|
2277
|
+
limit,
|
|
2278
|
+
after: context.after,
|
|
2279
|
+
select: context.select,
|
|
2280
|
+
populate: context.populate,
|
|
2281
|
+
search: context.search,
|
|
2282
|
+
mode: context.mode,
|
|
2283
|
+
lean: context.lean,
|
|
2284
|
+
readPreference: context.readPreference,
|
|
2285
|
+
hint: context.hint,
|
|
2286
|
+
maxTimeMS: context.maxTimeMS,
|
|
2287
|
+
countStrategy: context.countStrategy
|
|
2288
|
+
};
|
|
2289
|
+
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2290
|
+
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2291
|
+
try {
|
|
2292
|
+
await config.adapter.set(key, result, ttl);
|
|
2293
|
+
stats.sets++;
|
|
2294
|
+
log(`Cached getAll result:`, key);
|
|
2295
|
+
} catch (e) {
|
|
2296
|
+
log(`Failed to cache getAll:`, e);
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
/**
|
|
2300
|
+
* after:create - Bump version to invalidate list caches
|
|
2301
|
+
*/
|
|
2302
|
+
repo.on("after:create", async () => {
|
|
2303
|
+
await bumpVersion();
|
|
2304
|
+
});
|
|
2305
|
+
/**
|
|
2306
|
+
* after:createMany - Bump version to invalidate list caches
|
|
2307
|
+
*/
|
|
2308
|
+
repo.on("after:createMany", async () => {
|
|
2309
|
+
await bumpVersion();
|
|
2310
|
+
});
|
|
2311
|
+
/**
|
|
2312
|
+
* after:update - Invalidate by ID and bump version
|
|
2313
|
+
*/
|
|
2314
|
+
repo.on("after:update", async (payload) => {
|
|
2315
|
+
const { context } = payload;
|
|
2316
|
+
const id = String(context.id);
|
|
2317
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
2318
|
+
});
|
|
2319
|
+
/**
|
|
2320
|
+
* after:updateMany - Bump version (can't track individual IDs efficiently)
|
|
2321
|
+
*/
|
|
2322
|
+
repo.on("after:updateMany", async () => {
|
|
2323
|
+
await bumpVersion();
|
|
2324
|
+
});
|
|
2325
|
+
/**
|
|
2326
|
+
* after:delete - Invalidate by ID and bump version
|
|
2327
|
+
*/
|
|
2328
|
+
repo.on("after:delete", async (payload) => {
|
|
2329
|
+
const { context } = payload;
|
|
2330
|
+
const id = String(context.id);
|
|
2331
|
+
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
2332
|
+
});
|
|
2333
|
+
/**
|
|
2334
|
+
* after:deleteMany - Bump version
|
|
2335
|
+
*/
|
|
2336
|
+
repo.on("after:deleteMany", async () => {
|
|
2337
|
+
await bumpVersion();
|
|
2338
|
+
});
|
|
2339
|
+
/**
|
|
2340
|
+
* after:bulkWrite - Bump version (bulk ops may insert/update/delete)
|
|
2341
|
+
*/
|
|
2342
|
+
repo.on("after:bulkWrite", async () => {
|
|
2343
|
+
await bumpVersion();
|
|
2344
|
+
});
|
|
2345
|
+
/**
|
|
2346
|
+
* Invalidate cache for a specific document
|
|
2347
|
+
* Use when document was updated outside this service
|
|
2348
|
+
*
|
|
2349
|
+
* @example
|
|
2350
|
+
* await userRepo.invalidateCache('507f1f77bcf86cd799439011');
|
|
2351
|
+
*/
|
|
2352
|
+
repo.invalidateCache = async (id) => {
|
|
2353
|
+
await invalidateById(id);
|
|
2354
|
+
log(`Manual invalidation for ID:`, id);
|
|
1767
2355
|
};
|
|
1768
2356
|
/**
|
|
1769
|
-
*
|
|
2357
|
+
* Invalidate all list caches for this model
|
|
2358
|
+
* Use when bulk changes happened outside this service
|
|
2359
|
+
*
|
|
2360
|
+
* @example
|
|
2361
|
+
* await userRepo.invalidateListCache();
|
|
1770
2362
|
*/
|
|
1771
|
-
repo.
|
|
1772
|
-
|
|
2363
|
+
repo.invalidateListCache = async () => {
|
|
2364
|
+
await bumpVersion();
|
|
2365
|
+
log(`Manual list cache invalidation for ${model}`);
|
|
1773
2366
|
};
|
|
1774
2367
|
/**
|
|
1775
|
-
*
|
|
2368
|
+
* Invalidate ALL cache entries for this model
|
|
2369
|
+
* Nuclear option - use sparingly
|
|
2370
|
+
*
|
|
2371
|
+
* @example
|
|
2372
|
+
* await userRepo.invalidateAllCache();
|
|
1776
2373
|
*/
|
|
1777
|
-
repo.
|
|
1778
|
-
|
|
2374
|
+
repo.invalidateAllCache = async () => {
|
|
2375
|
+
if (config.adapter.clear) try {
|
|
2376
|
+
await config.adapter.clear(modelPattern(config.prefix, model));
|
|
2377
|
+
stats.invalidations++;
|
|
2378
|
+
log(`Full cache invalidation for ${model}`);
|
|
2379
|
+
} catch (e) {
|
|
2380
|
+
log(`Failed full cache invalidation for ${model}:`, e);
|
|
2381
|
+
}
|
|
2382
|
+
else {
|
|
2383
|
+
await bumpVersion();
|
|
2384
|
+
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
2385
|
+
}
|
|
1779
2386
|
};
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
*
|
|
1790
|
-
*/
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
-
});
|
|
1840
|
-
}
|
|
1841
|
-
if (errors.length > 0) {
|
|
1842
|
-
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
1843
|
-
err.validationErrors = errors;
|
|
1844
|
-
throw err;
|
|
1845
|
-
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Get cache statistics for monitoring
|
|
2389
|
+
*
|
|
2390
|
+
* @example
|
|
2391
|
+
* const stats = userRepo.getCacheStats();
|
|
2392
|
+
* console.log(`Hit rate: ${stats.hits / (stats.hits + stats.misses) * 100}%`);
|
|
2393
|
+
*/
|
|
2394
|
+
repo.getCacheStats = () => ({ ...stats });
|
|
2395
|
+
/**
|
|
2396
|
+
* Reset cache statistics
|
|
2397
|
+
*/
|
|
2398
|
+
repo.resetCacheStats = () => {
|
|
2399
|
+
stats.hits = 0;
|
|
2400
|
+
stats.misses = 0;
|
|
2401
|
+
stats.sets = 0;
|
|
2402
|
+
stats.invalidations = 0;
|
|
2403
|
+
stats.errors = 0;
|
|
1846
2404
|
};
|
|
1847
|
-
repo.on("before:create", async (context) => runValidators("create", context));
|
|
1848
|
-
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
1849
|
-
repo.on("before:update", async (context) => runValidators("update", context));
|
|
1850
|
-
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
1851
|
-
}
|
|
1852
|
-
};
|
|
1853
|
-
}
|
|
1854
|
-
/**
|
|
1855
|
-
* Block operation if condition is true
|
|
1856
|
-
*
|
|
1857
|
-
* @example
|
|
1858
|
-
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
1859
|
-
*/
|
|
1860
|
-
function blockIf(name, operations, condition, errorMessage) {
|
|
1861
|
-
return {
|
|
1862
|
-
name,
|
|
1863
|
-
operations,
|
|
1864
|
-
validate: (context) => {
|
|
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`);
|
|
1933
2405
|
}
|
|
1934
2406
|
};
|
|
1935
2407
|
}
|
|
1936
|
-
|
|
1937
2408
|
//#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
|
-
*/
|
|
2409
|
+
//#region src/plugins/cascade.plugin.ts
|
|
1945
2410
|
/**
|
|
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
|
|
2411
|
+
* Cascade Delete Plugin
|
|
2412
|
+
* Automatically deletes related documents when a parent document is deleted
|
|
1955
2413
|
*
|
|
1956
|
-
* @example
|
|
2414
|
+
* @example
|
|
1957
2415
|
* ```typescript
|
|
1958
|
-
*
|
|
2416
|
+
* import mongoose from 'mongoose';
|
|
2417
|
+
* import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
2418
|
+
*
|
|
2419
|
+
* const productRepo = new Repository(Product, [
|
|
1959
2420
|
* methodRegistryPlugin(),
|
|
1960
|
-
*
|
|
2421
|
+
* cascadePlugin({
|
|
2422
|
+
* relations: [
|
|
2423
|
+
* { model: 'StockEntry', foreignKey: 'product' },
|
|
2424
|
+
* { model: 'StockMovement', foreignKey: 'product' },
|
|
2425
|
+
* ]
|
|
2426
|
+
* })
|
|
1961
2427
|
* ]);
|
|
1962
2428
|
*
|
|
1963
|
-
* //
|
|
1964
|
-
* await
|
|
1965
|
-
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
2429
|
+
* // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
|
|
2430
|
+
* await productRepo.delete(productId);
|
|
1966
2431
|
* ```
|
|
2432
|
+
*/
|
|
2433
|
+
/**
|
|
2434
|
+
* Cascade delete plugin
|
|
1967
2435
|
*
|
|
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;
|
|
2436
|
+
* Deletes related documents after the parent document is deleted.
|
|
2437
|
+
* Works with both hard delete and soft delete scenarios.
|
|
1984
2438
|
*
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
1987
|
-
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
1988
|
-
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
1989
|
-
* ```
|
|
2439
|
+
* @param options - Cascade configuration
|
|
2440
|
+
* @returns Plugin
|
|
1990
2441
|
*/
|
|
1991
|
-
function
|
|
2442
|
+
function cascadePlugin(options) {
|
|
2443
|
+
const { relations, parallel = true, logger } = options;
|
|
2444
|
+
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
1992
2445
|
return {
|
|
1993
|
-
name: "
|
|
2446
|
+
name: "cascade",
|
|
1994
2447
|
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);
|
|
2448
|
+
repo.on("after:delete", async (payload) => {
|
|
2449
|
+
const { context } = payload;
|
|
2450
|
+
const deletedId = context.id;
|
|
2451
|
+
if (!deletedId) {
|
|
2452
|
+
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const isSoftDelete = context.softDeleted === true;
|
|
2456
|
+
const cascadeDelete = async (relation) => {
|
|
2457
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2458
|
+
if (!RelatedModel) {
|
|
2459
|
+
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2460
|
+
parentModel: context.model,
|
|
2461
|
+
parentId: String(deletedId)
|
|
2462
|
+
});
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
const query = { [relation.foreignKey]: deletedId };
|
|
2466
|
+
try {
|
|
2467
|
+
if (relation.softDelete ?? isSoftDelete) {
|
|
2468
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
2469
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
2470
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2471
|
+
}, { session: context.session });
|
|
2472
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
|
|
2473
|
+
parentModel: context.model,
|
|
2474
|
+
parentId: String(deletedId),
|
|
2475
|
+
relatedModel: relation.model,
|
|
2476
|
+
foreignKey: relation.foreignKey,
|
|
2477
|
+
count: updateResult.modifiedCount
|
|
2478
|
+
});
|
|
2479
|
+
} else {
|
|
2480
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2481
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
|
|
2482
|
+
parentModel: context.model,
|
|
2483
|
+
parentId: String(deletedId),
|
|
2484
|
+
relatedModel: relation.model,
|
|
2485
|
+
foreignKey: relation.foreignKey,
|
|
2486
|
+
count: deleteResult.deletedCount
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
} catch (error) {
|
|
2490
|
+
logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
|
|
2491
|
+
parentModel: context.model,
|
|
2492
|
+
parentId: String(deletedId),
|
|
2493
|
+
relatedModel: relation.model,
|
|
2494
|
+
foreignKey: relation.foreignKey,
|
|
2495
|
+
error: error.message
|
|
2496
|
+
});
|
|
2497
|
+
throw error;
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
if (parallel) {
|
|
2501
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
|
|
2502
|
+
if (failures.length) {
|
|
2503
|
+
const err = failures[0].reason;
|
|
2504
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2505
|
+
throw err;
|
|
2506
|
+
}
|
|
2507
|
+
} else for (const relation of relations) await cascadeDelete(relation);
|
|
2072
2508
|
});
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
return applyOperator.call(this, id, field, value, "$max", options);
|
|
2509
|
+
repo.on("before:deleteMany", async (context) => {
|
|
2510
|
+
const query = context.query;
|
|
2511
|
+
if (!query || Object.keys(query).length === 0) return;
|
|
2512
|
+
context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
|
|
2078
2513
|
});
|
|
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
|
-
|
|
2514
|
+
repo.on("after:deleteMany", async (payload) => {
|
|
2515
|
+
const { context } = payload;
|
|
2516
|
+
const ids = context._cascadeIds;
|
|
2517
|
+
if (!ids || ids.length === 0) return;
|
|
2518
|
+
const isSoftDelete = context.softDeleted === true;
|
|
2519
|
+
const cascadeDeleteMany = async (relation) => {
|
|
2520
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2521
|
+
if (!RelatedModel) {
|
|
2522
|
+
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const query = { [relation.foreignKey]: { $in: ids } };
|
|
2526
|
+
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
2527
|
+
try {
|
|
2528
|
+
if (shouldSoftDelete) {
|
|
2529
|
+
const updateResult = await RelatedModel.updateMany(query, {
|
|
2530
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
2531
|
+
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2532
|
+
}, { session: context.session });
|
|
2533
|
+
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
|
|
2534
|
+
parentModel: context.model,
|
|
2535
|
+
parentCount: ids.length,
|
|
2536
|
+
relatedModel: relation.model,
|
|
2537
|
+
foreignKey: relation.foreignKey,
|
|
2538
|
+
count: updateResult.modifiedCount
|
|
2539
|
+
});
|
|
2540
|
+
} else {
|
|
2541
|
+
const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
|
|
2542
|
+
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
|
|
2543
|
+
parentModel: context.model,
|
|
2544
|
+
parentCount: ids.length,
|
|
2545
|
+
relatedModel: relation.model,
|
|
2546
|
+
foreignKey: relation.foreignKey,
|
|
2547
|
+
count: deleteResult.deletedCount
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
} catch (error) {
|
|
2551
|
+
logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
|
|
2552
|
+
parentModel: context.model,
|
|
2553
|
+
relatedModel: relation.model,
|
|
2554
|
+
foreignKey: relation.foreignKey,
|
|
2555
|
+
error: error.message
|
|
2556
|
+
});
|
|
2557
|
+
throw error;
|
|
2558
|
+
}
|
|
2559
|
+
};
|
|
2560
|
+
if (parallel) {
|
|
2561
|
+
const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
|
|
2562
|
+
if (failures.length) {
|
|
2563
|
+
const err = failures[0].reason;
|
|
2564
|
+
if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
2565
|
+
throw err;
|
|
2566
|
+
}
|
|
2567
|
+
} else for (const relation of relations) await cascadeDeleteMany(relation);
|
|
2131
2568
|
});
|
|
2132
2569
|
}
|
|
2133
2570
|
};
|
|
2134
2571
|
}
|
|
2135
|
-
|
|
2136
2572
|
//#endregion
|
|
2137
|
-
//#region src/plugins/
|
|
2573
|
+
//#region src/plugins/custom-id.plugin.ts
|
|
2138
2574
|
/**
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2575
|
+
* Custom ID Plugin
|
|
2576
|
+
*
|
|
2577
|
+
* Generates custom document IDs using pluggable generators.
|
|
2578
|
+
* Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
|
|
2579
|
+
* date-partitioned sequences, and fully custom generators.
|
|
2580
|
+
*
|
|
2581
|
+
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
2582
|
+
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
2583
|
+
*
|
|
2584
|
+
* @example Basic sequential counter
|
|
2585
|
+
* ```typescript
|
|
2586
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
2587
|
+
* customIdPlugin({
|
|
2588
|
+
* field: 'invoiceNumber',
|
|
2589
|
+
* generator: sequentialId({
|
|
2590
|
+
* prefix: 'INV',
|
|
2591
|
+
* model: InvoiceModel,
|
|
2592
|
+
* }),
|
|
2593
|
+
* }),
|
|
2145
2594
|
* ]);
|
|
2146
|
-
*
|
|
2147
|
-
* await
|
|
2148
|
-
*
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
};
|
|
2595
|
+
*
|
|
2596
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
2597
|
+
* // inv.invoiceNumber → "INV-0001"
|
|
2598
|
+
* ```
|
|
2599
|
+
*
|
|
2600
|
+
* @example Date-partitioned counter (resets monthly)
|
|
2601
|
+
* ```typescript
|
|
2602
|
+
* const billRepo = new Repository(BillModel, [
|
|
2603
|
+
* customIdPlugin({
|
|
2604
|
+
* field: 'billNumber',
|
|
2605
|
+
* generator: dateSequentialId({
|
|
2606
|
+
* prefix: 'BILL',
|
|
2607
|
+
* model: BillModel,
|
|
2608
|
+
* partition: 'monthly',
|
|
2609
|
+
* separator: '-',
|
|
2610
|
+
* padding: 4,
|
|
2611
|
+
* }),
|
|
2612
|
+
* }),
|
|
2613
|
+
* ]);
|
|
2614
|
+
*
|
|
2615
|
+
* const bill = await billRepo.create({ total: 250 });
|
|
2616
|
+
* // bill.billNumber → "BILL-2026-02-0001"
|
|
2617
|
+
* ```
|
|
2618
|
+
*
|
|
2619
|
+
* @example Custom generator function
|
|
2620
|
+
* ```typescript
|
|
2621
|
+
* const orderRepo = new Repository(OrderModel, [
|
|
2622
|
+
* customIdPlugin({
|
|
2623
|
+
* field: 'orderRef',
|
|
2624
|
+
* generator: async (context) => {
|
|
2625
|
+
* const region = context.data?.region || 'US';
|
|
2626
|
+
* const seq = await getNextSequence('orders');
|
|
2627
|
+
* return `ORD-${region}-${seq}`;
|
|
2628
|
+
* },
|
|
2629
|
+
* }),
|
|
2630
|
+
* ]);
|
|
2631
|
+
* ```
|
|
2632
|
+
*/
|
|
2633
|
+
/** Schema for the internal counters collection */
|
|
2634
|
+
const counterSchema = new mongoose.Schema({
|
|
2635
|
+
_id: {
|
|
2636
|
+
type: String,
|
|
2637
|
+
required: true
|
|
2638
|
+
},
|
|
2639
|
+
seq: {
|
|
2640
|
+
type: Number,
|
|
2641
|
+
default: 0
|
|
2642
|
+
}
|
|
2643
|
+
}, {
|
|
2644
|
+
collection: "_mongokit_counters",
|
|
2645
|
+
versionKey: false
|
|
2646
|
+
});
|
|
2647
|
+
/**
|
|
2648
|
+
* Get or create the Counter model on the given connection.
|
|
2649
|
+
* Falls back to the default mongoose connection if none is provided.
|
|
2650
|
+
* Lazy-init to avoid model registration errors if mongoose isn't connected yet.
|
|
2651
|
+
*/
|
|
2652
|
+
function getCounterModel(connection) {
|
|
2653
|
+
const conn = connection ?? mongoose.connection;
|
|
2654
|
+
if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
|
|
2655
|
+
return conn.model("_MongoKitCounter", counterSchema);
|
|
2262
2656
|
}
|
|
2263
|
-
|
|
2264
|
-
//#endregion
|
|
2265
|
-
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
2266
2657
|
/**
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2658
|
+
* Atomically increment and return the next sequence value for a given key.
|
|
2659
|
+
* Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
|
|
2660
|
+
* heavy concurrency.
|
|
2661
|
+
*
|
|
2662
|
+
* @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
|
|
2663
|
+
* @param increment - Value to increment by (default: 1)
|
|
2664
|
+
* @returns The next sequence number (after increment)
|
|
2665
|
+
*
|
|
2269
2666
|
* @example
|
|
2270
|
-
* const
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
2273
|
-
*
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
* const total = await repo.sum('amount', { status: 'completed' });
|
|
2667
|
+
* const seq = await getNextSequence('invoices');
|
|
2668
|
+
* // First call → 1, second → 2, ...
|
|
2669
|
+
*
|
|
2670
|
+
* @example Batch increment for createMany
|
|
2671
|
+
* const startSeq = await getNextSequence('invoices', 5);
|
|
2672
|
+
* // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
|
|
2277
2673
|
*/
|
|
2278
|
-
function
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
*/
|
|
2286
|
-
repo.registerMethod("groupBy", async function(field, options = {}) {
|
|
2287
|
-
const pipeline = [{ $group: {
|
|
2288
|
-
_id: `$${field}`,
|
|
2289
|
-
count: { $sum: 1 }
|
|
2290
|
-
} }, { $sort: { count: -1 } }];
|
|
2291
|
-
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
2292
|
-
return this.aggregate.call(this, pipeline, options);
|
|
2293
|
-
});
|
|
2294
|
-
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
2295
|
-
const pipeline = [{ $match: query }, { $group: {
|
|
2296
|
-
_id: null,
|
|
2297
|
-
[resultKey]: { [operator]: `$${field}` }
|
|
2298
|
-
} }];
|
|
2299
|
-
return (await this.aggregate.call(this, pipeline, options))[0]?.[resultKey] || 0;
|
|
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
|
-
};
|
|
2674
|
+
async function getNextSequence(counterKey, increment = 1, connection) {
|
|
2675
|
+
const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
|
|
2676
|
+
upsert: true,
|
|
2677
|
+
returnDocument: "after"
|
|
2678
|
+
});
|
|
2679
|
+
if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
|
|
2680
|
+
return result.seq;
|
|
2327
2681
|
}
|
|
2328
|
-
|
|
2329
|
-
//#endregion
|
|
2330
|
-
//#region src/plugins/subdocument.plugin.ts
|
|
2331
2682
|
/**
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2683
|
+
* Generator: Simple sequential counter.
|
|
2684
|
+
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
2685
|
+
*
|
|
2686
|
+
* Uses atomic MongoDB counters — safe under concurrency.
|
|
2687
|
+
*
|
|
2334
2688
|
* @example
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2689
|
+
* ```typescript
|
|
2690
|
+
* customIdPlugin({
|
|
2691
|
+
* field: 'invoiceNumber',
|
|
2692
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
2693
|
+
* })
|
|
2694
|
+
* ```
|
|
2695
|
+
*/
|
|
2696
|
+
function sequentialId(options) {
|
|
2697
|
+
const { prefix, model, padding = 4, separator = "-", counterKey } = options;
|
|
2698
|
+
const key = counterKey || model.modelName;
|
|
2699
|
+
return async (context) => {
|
|
2700
|
+
const seq = await getNextSequence(key, 1, context._counterConnection);
|
|
2701
|
+
return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Generator: Date-partitioned sequential counter.
|
|
2706
|
+
* Counter resets per period — great for invoice/bill numbering.
|
|
2707
|
+
*
|
|
2708
|
+
* Produces IDs like:
|
|
2709
|
+
* - yearly: `BILL-2026-0001`
|
|
2710
|
+
* - monthly: `BILL-2026-02-0001`
|
|
2711
|
+
* - daily: `BILL-2026-02-20-0001`
|
|
2712
|
+
*
|
|
2713
|
+
* @example
|
|
2714
|
+
* ```typescript
|
|
2715
|
+
* customIdPlugin({
|
|
2716
|
+
* field: 'billNumber',
|
|
2717
|
+
* generator: dateSequentialId({
|
|
2718
|
+
* prefix: 'BILL',
|
|
2719
|
+
* model: BillModel,
|
|
2720
|
+
* partition: 'monthly',
|
|
2721
|
+
* }),
|
|
2722
|
+
* })
|
|
2723
|
+
* ```
|
|
2724
|
+
*/
|
|
2725
|
+
function dateSequentialId(options) {
|
|
2726
|
+
const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
|
|
2727
|
+
return async (context) => {
|
|
2728
|
+
const now = /* @__PURE__ */ new Date();
|
|
2729
|
+
const year = String(now.getFullYear());
|
|
2730
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2731
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2732
|
+
let datePart;
|
|
2733
|
+
let counterKey;
|
|
2734
|
+
switch (partition) {
|
|
2735
|
+
case "yearly":
|
|
2736
|
+
datePart = year;
|
|
2737
|
+
counterKey = `${model.modelName}:${year}`;
|
|
2738
|
+
break;
|
|
2739
|
+
case "daily":
|
|
2740
|
+
datePart = `${year}${separator}${month}${separator}${day}`;
|
|
2741
|
+
counterKey = `${model.modelName}:${year}-${month}-${day}`;
|
|
2742
|
+
break;
|
|
2743
|
+
default:
|
|
2744
|
+
datePart = `${year}${separator}${month}`;
|
|
2745
|
+
counterKey = `${model.modelName}:${year}-${month}`;
|
|
2746
|
+
break;
|
|
2747
|
+
}
|
|
2748
|
+
const seq = await getNextSequence(counterKey, 1, context._counterConnection);
|
|
2749
|
+
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Generator: Prefix + random alphanumeric suffix.
|
|
2754
|
+
* Does NOT require a database round-trip — purely in-memory.
|
|
2755
|
+
*
|
|
2756
|
+
* Produces IDs like: `USR_a7b3xk9m2p1q`
|
|
2757
|
+
*
|
|
2758
|
+
* Good for: user-facing IDs where ordering doesn't matter.
|
|
2759
|
+
* Not suitable for sequential numbering.
|
|
2760
|
+
*
|
|
2761
|
+
* @example
|
|
2762
|
+
* ```typescript
|
|
2763
|
+
* customIdPlugin({
|
|
2764
|
+
* field: 'publicId',
|
|
2765
|
+
* generator: prefixedId({ prefix: 'USR', length: 10 }),
|
|
2766
|
+
* })
|
|
2767
|
+
* ```
|
|
2768
|
+
*/
|
|
2769
|
+
function prefixedId(options) {
|
|
2770
|
+
const { prefix, separator = "_", length = 12 } = options;
|
|
2771
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2772
|
+
return (_context) => {
|
|
2773
|
+
let result = "";
|
|
2774
|
+
const bytes = new Uint8Array(length);
|
|
2775
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
2776
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
2777
|
+
for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
|
|
2778
|
+
} else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
|
|
2779
|
+
return `${prefix}${separator}${result}`;
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Custom ID plugin — injects generated IDs into documents before creation.
|
|
2784
|
+
*
|
|
2785
|
+
* @param options - Configuration for ID generation
|
|
2786
|
+
* @returns Plugin instance
|
|
2787
|
+
*
|
|
2788
|
+
* @example
|
|
2789
|
+
* ```typescript
|
|
2790
|
+
* import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
|
|
2791
|
+
*
|
|
2792
|
+
* const invoiceRepo = new Repository(InvoiceModel, [
|
|
2793
|
+
* customIdPlugin({
|
|
2794
|
+
* field: 'invoiceNumber',
|
|
2795
|
+
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
2796
|
+
* }),
|
|
2797
|
+
* ]);
|
|
2798
|
+
*
|
|
2799
|
+
* const inv = await invoiceRepo.create({ amount: 100 });
|
|
2800
|
+
* console.log(inv.invoiceNumber); // "INV-0001"
|
|
2801
|
+
* ```
|
|
2802
|
+
*/
|
|
2803
|
+
function customIdPlugin(options) {
|
|
2804
|
+
const fieldName = options.field || "customId";
|
|
2805
|
+
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
2806
|
+
return {
|
|
2807
|
+
name: "custom-id",
|
|
2346
2808
|
apply(repo) {
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2809
|
+
const repoConnection = repo.Model.db;
|
|
2810
|
+
repo.on("before:create", async (context) => {
|
|
2811
|
+
if (!context.data) return;
|
|
2812
|
+
if (generateOnlyIfEmpty && context.data[fieldName]) return;
|
|
2813
|
+
context._counterConnection = repoConnection;
|
|
2814
|
+
context.data[fieldName] = await options.generator(context);
|
|
2353
2815
|
});
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2816
|
+
repo.on("before:createMany", async (context) => {
|
|
2817
|
+
if (!context.dataArray) return;
|
|
2818
|
+
context._counterConnection = repoConnection;
|
|
2819
|
+
const docsNeedingIds = [];
|
|
2820
|
+
for (const doc of context.dataArray) {
|
|
2821
|
+
if (generateOnlyIfEmpty && doc[fieldName]) continue;
|
|
2822
|
+
docsNeedingIds.push(doc);
|
|
2823
|
+
}
|
|
2824
|
+
if (docsNeedingIds.length === 0) return;
|
|
2825
|
+
for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
|
|
2826
|
+
...context,
|
|
2827
|
+
data: doc
|
|
2366
2828
|
});
|
|
2367
2829
|
});
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
//#endregion
|
|
2834
|
+
//#region src/plugins/elastic.plugin.ts
|
|
2835
|
+
function elasticSearchPlugin(options) {
|
|
2836
|
+
return {
|
|
2837
|
+
name: "elastic-search",
|
|
2838
|
+
apply(repo) {
|
|
2839
|
+
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
2840
|
+
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
2841
|
+
const { client, index, idField = "_id" } = options;
|
|
2842
|
+
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
2843
|
+
const from = Math.max(searchOptions.from || 0, 0);
|
|
2844
|
+
const esResponse = await client.search({
|
|
2845
|
+
index,
|
|
2846
|
+
body: {
|
|
2847
|
+
query: searchQuery,
|
|
2848
|
+
size: limit,
|
|
2849
|
+
from
|
|
2850
|
+
}
|
|
2388
2851
|
});
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2852
|
+
const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
|
|
2853
|
+
if (hits.length === 0) return {
|
|
2854
|
+
docs: [],
|
|
2855
|
+
total: 0,
|
|
2856
|
+
limit,
|
|
2857
|
+
from
|
|
2858
|
+
};
|
|
2859
|
+
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
2860
|
+
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
2861
|
+
const docsOrder = /* @__PURE__ */ new Map();
|
|
2862
|
+
const scores = /* @__PURE__ */ new Map();
|
|
2863
|
+
const ids = [];
|
|
2864
|
+
hits.forEach((hit, idx) => {
|
|
2865
|
+
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
2866
|
+
if (docId) {
|
|
2867
|
+
const strId = String(docId);
|
|
2868
|
+
docsOrder.set(strId, idx);
|
|
2869
|
+
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
2870
|
+
ids.push(strId);
|
|
2871
|
+
}
|
|
2872
|
+
});
|
|
2873
|
+
if (ids.length === 0) return {
|
|
2874
|
+
docs: [],
|
|
2875
|
+
total,
|
|
2876
|
+
limit,
|
|
2877
|
+
from
|
|
2878
|
+
};
|
|
2879
|
+
const mongoQuery = this.Model.find({ _id: { $in: ids } });
|
|
2880
|
+
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
2881
|
+
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
2882
|
+
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
2883
|
+
return {
|
|
2884
|
+
docs: (await mongoQuery.exec()).sort((a, b) => {
|
|
2885
|
+
const aId = String(a._id);
|
|
2886
|
+
const bId = String(b._id);
|
|
2887
|
+
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
2888
|
+
}).map((doc) => {
|
|
2889
|
+
const strId = String(doc._id);
|
|
2890
|
+
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
2891
|
+
...doc,
|
|
2892
|
+
_score: scores.get(strId)
|
|
2893
|
+
};
|
|
2894
|
+
return doc;
|
|
2895
|
+
}),
|
|
2896
|
+
total,
|
|
2897
|
+
limit,
|
|
2898
|
+
from
|
|
2899
|
+
};
|
|
2395
2900
|
});
|
|
2396
2901
|
}
|
|
2397
2902
|
};
|
|
2398
2903
|
}
|
|
2399
|
-
|
|
2400
2904
|
//#endregion
|
|
2401
|
-
//#region src/plugins/
|
|
2905
|
+
//#region src/plugins/field-filter.plugin.ts
|
|
2402
2906
|
/**
|
|
2403
|
-
*
|
|
2907
|
+
* Field filter plugin that restricts fields based on user context
|
|
2404
2908
|
*
|
|
2405
|
-
* @
|
|
2406
|
-
*
|
|
2909
|
+
* @example
|
|
2910
|
+
* const fieldPreset = {
|
|
2911
|
+
* public: ['id', 'name'],
|
|
2912
|
+
* authenticated: ['email'],
|
|
2913
|
+
* admin: ['createdAt', 'internalNotes']
|
|
2914
|
+
* };
|
|
2915
|
+
*
|
|
2916
|
+
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
2407
2917
|
*/
|
|
2408
|
-
function
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
invalidations: 0,
|
|
2423
|
-
errors: 0
|
|
2424
|
-
};
|
|
2425
|
-
const log = (msg, data) => {
|
|
2426
|
-
if (config.debug) debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
2918
|
+
function fieldFilterPlugin(fieldPreset) {
|
|
2919
|
+
return {
|
|
2920
|
+
name: "fieldFilter",
|
|
2921
|
+
apply(repo) {
|
|
2922
|
+
const applyFieldFiltering = (context) => {
|
|
2923
|
+
if (!fieldPreset) return;
|
|
2924
|
+
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
2925
|
+
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
2926
|
+
else context.select = presetSelect;
|
|
2927
|
+
};
|
|
2928
|
+
repo.on("before:getAll", applyFieldFiltering);
|
|
2929
|
+
repo.on("before:getById", applyFieldFiltering);
|
|
2930
|
+
repo.on("before:getByQuery", applyFieldFiltering);
|
|
2931
|
+
}
|
|
2427
2932
|
};
|
|
2933
|
+
}
|
|
2934
|
+
//#endregion
|
|
2935
|
+
//#region src/plugins/method-registry.plugin.ts
|
|
2936
|
+
/**
|
|
2937
|
+
* Method registry plugin that enables dynamic method registration
|
|
2938
|
+
*/
|
|
2939
|
+
function methodRegistryPlugin() {
|
|
2428
2940
|
return {
|
|
2429
|
-
name: "
|
|
2941
|
+
name: "method-registry",
|
|
2430
2942
|
apply(repo) {
|
|
2431
|
-
const
|
|
2432
|
-
const byIdKeyRegistry = /* @__PURE__ */ new Map();
|
|
2433
|
-
function trackByIdKey(docId, cacheKey) {
|
|
2434
|
-
let keys = byIdKeyRegistry.get(docId);
|
|
2435
|
-
if (!keys) {
|
|
2436
|
-
keys = /* @__PURE__ */ new Set();
|
|
2437
|
-
byIdKeyRegistry.set(docId, keys);
|
|
2438
|
-
}
|
|
2439
|
-
keys.add(cacheKey);
|
|
2440
|
-
}
|
|
2441
|
-
async function getVersion() {
|
|
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
|
-
}
|
|
2943
|
+
const registeredMethods = [];
|
|
2449
2944
|
/**
|
|
2450
|
-
*
|
|
2451
|
-
* Uses Date.now() so version always moves forward — safe after eviction or deploy.
|
|
2945
|
+
* Register a new method on the repository instance
|
|
2452
2946
|
*/
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2947
|
+
repo.registerMethod = (name, fn) => {
|
|
2948
|
+
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.`);
|
|
2949
|
+
if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
|
|
2950
|
+
if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
|
|
2951
|
+
repo[name] = fn.bind(repo);
|
|
2952
|
+
registeredMethods.push(name);
|
|
2953
|
+
repo.emit("method:registered", {
|
|
2954
|
+
name,
|
|
2955
|
+
fn
|
|
2956
|
+
});
|
|
2957
|
+
};
|
|
2463
2958
|
/**
|
|
2464
|
-
*
|
|
2465
|
-
* Deletes every tracked shape-variant key individually via del(),
|
|
2466
|
-
* so adapters without pattern-based clear() still get full invalidation.
|
|
2959
|
+
* Check if a method is registered
|
|
2467
2960
|
*/
|
|
2468
|
-
|
|
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);
|
|
2476
|
-
}
|
|
2477
|
-
stats.invalidations++;
|
|
2478
|
-
log(`Invalidated byId cache for:`, id);
|
|
2479
|
-
} catch (e) {
|
|
2480
|
-
log(`Failed to invalidate byId cache:`, e);
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2961
|
+
repo.hasMethod = (name) => typeof repo[name] === "function";
|
|
2483
2962
|
/**
|
|
2484
|
-
*
|
|
2485
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
2963
|
+
* Get list of all dynamically registered methods
|
|
2486
2964
|
*/
|
|
2487
|
-
repo.
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2965
|
+
repo.getRegisteredMethods = () => [...registeredMethods];
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
//#endregion
|
|
2970
|
+
//#region src/plugins/mongo-operations.plugin.ts
|
|
2971
|
+
/**
|
|
2972
|
+
* MongoDB Operations Plugin
|
|
2973
|
+
*
|
|
2974
|
+
* Adds MongoDB-specific operations to repositories.
|
|
2975
|
+
* Requires method-registry.plugin.js to be loaded first.
|
|
2976
|
+
*/
|
|
2977
|
+
/**
|
|
2978
|
+
* MongoDB operations plugin
|
|
2979
|
+
*
|
|
2980
|
+
* Adds MongoDB-specific atomic operations to repositories:
|
|
2981
|
+
* - upsert: Create or update document
|
|
2982
|
+
* - increment/decrement: Atomic numeric operations
|
|
2983
|
+
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
2984
|
+
* - setField/unsetField/renameField: Field operations
|
|
2985
|
+
* - multiplyField: Multiply numeric field
|
|
2986
|
+
* - setMin/setMax: Conditional min/max updates
|
|
2987
|
+
*
|
|
2988
|
+
* @example Basic usage (no TypeScript autocomplete)
|
|
2989
|
+
* ```typescript
|
|
2990
|
+
* const repo = new Repository(ProductModel, [
|
|
2991
|
+
* methodRegistryPlugin(),
|
|
2992
|
+
* mongoOperationsPlugin(),
|
|
2993
|
+
* ]);
|
|
2994
|
+
*
|
|
2995
|
+
* // Works at runtime but TypeScript doesn't know about these methods
|
|
2996
|
+
* await (repo as any).increment(productId, 'views', 1);
|
|
2997
|
+
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
2998
|
+
* ```
|
|
2999
|
+
*
|
|
3000
|
+
* @example With TypeScript type safety (recommended)
|
|
3001
|
+
* ```typescript
|
|
3002
|
+
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
3003
|
+
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
3004
|
+
*
|
|
3005
|
+
* class ProductRepo extends Repository<IProduct> {
|
|
3006
|
+
* // Add your custom methods here
|
|
3007
|
+
* }
|
|
3008
|
+
*
|
|
3009
|
+
* // Create with type assertion to get autocomplete for plugin methods
|
|
3010
|
+
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
3011
|
+
*
|
|
3012
|
+
* const repo = new ProductRepo(ProductModel, [
|
|
3013
|
+
* methodRegistryPlugin(),
|
|
3014
|
+
* mongoOperationsPlugin(),
|
|
3015
|
+
* ]) as ProductRepoWithPlugins;
|
|
3016
|
+
*
|
|
3017
|
+
* // Now TypeScript provides autocomplete and type checking!
|
|
3018
|
+
* await repo.increment(productId, 'views', 1);
|
|
3019
|
+
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
3020
|
+
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
3021
|
+
* ```
|
|
3022
|
+
*/
|
|
3023
|
+
function mongoOperationsPlugin() {
|
|
3024
|
+
return {
|
|
3025
|
+
name: "mongo-operations",
|
|
3026
|
+
apply(repo) {
|
|
3027
|
+
if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
|
|
2514
3028
|
/**
|
|
2515
|
-
*
|
|
2516
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
3029
|
+
* Update existing document or insert new one
|
|
2517
3030
|
*/
|
|
2518
|
-
repo.
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2526
|
-
select: context.select,
|
|
2527
|
-
populate: context.populate
|
|
2528
|
-
});
|
|
2529
|
-
try {
|
|
2530
|
-
const cached = await config.adapter.get(key);
|
|
2531
|
-
if (cached !== null) {
|
|
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 });
|
|
3031
|
+
repo.registerMethod("upsert", async function(query, data, options = {}) {
|
|
3032
|
+
return upsert(this.Model, query, data, options);
|
|
3033
|
+
});
|
|
3034
|
+
const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
|
|
3035
|
+
if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
|
|
3036
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
3037
|
+
};
|
|
2545
3038
|
/**
|
|
2546
|
-
*
|
|
2547
|
-
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
3039
|
+
* Atomically increment numeric field
|
|
2548
3040
|
*/
|
|
2549
|
-
repo.
|
|
2550
|
-
|
|
2551
|
-
|
|
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,
|
|
2564
|
-
limit,
|
|
2565
|
-
after: context.after,
|
|
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
|
|
2575
|
-
};
|
|
2576
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2577
|
-
try {
|
|
2578
|
-
const cached = await config.adapter.get(key);
|
|
2579
|
-
if (cached !== null) {
|
|
2580
|
-
stats.hits++;
|
|
2581
|
-
log(`Cache HIT for getAll:`, key);
|
|
2582
|
-
context._cacheHit = true;
|
|
2583
|
-
context._cachedResult = cached;
|
|
2584
|
-
} else {
|
|
2585
|
-
stats.misses++;
|
|
2586
|
-
log(`Cache MISS for getAll:`, key);
|
|
2587
|
-
}
|
|
2588
|
-
} catch (e) {
|
|
2589
|
-
log(`Cache error for getAll:`, e);
|
|
2590
|
-
stats.errors++;
|
|
2591
|
-
}
|
|
2592
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
3041
|
+
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
3042
|
+
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
3043
|
+
});
|
|
2593
3044
|
/**
|
|
2594
|
-
*
|
|
3045
|
+
* Atomically decrement numeric field
|
|
2595
3046
|
*/
|
|
2596
|
-
repo.
|
|
2597
|
-
|
|
2598
|
-
if (context._cacheHit) return;
|
|
2599
|
-
if (context.skipCache) return;
|
|
2600
|
-
if (result === null) return;
|
|
2601
|
-
const id = String(context.id);
|
|
2602
|
-
const key = byIdKey(config.prefix, model, id, {
|
|
2603
|
-
select: context.select,
|
|
2604
|
-
populate: context.populate,
|
|
2605
|
-
lean: context.lean
|
|
2606
|
-
});
|
|
2607
|
-
const ttl = context.cacheTtl ?? config.byIdTtl;
|
|
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
|
-
}
|
|
3047
|
+
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
3048
|
+
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
2616
3049
|
});
|
|
3050
|
+
const applyOperator = function(id, field, value, operator, options) {
|
|
3051
|
+
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
3052
|
+
};
|
|
2617
3053
|
/**
|
|
2618
|
-
*
|
|
3054
|
+
* Push value to array field
|
|
2619
3055
|
*/
|
|
2620
|
-
repo.
|
|
2621
|
-
|
|
2622
|
-
if (context._cacheHit) return;
|
|
2623
|
-
if (context.skipCache) return;
|
|
2624
|
-
if (result === null) return;
|
|
2625
|
-
const collectionVersion = await getVersion();
|
|
2626
|
-
const query = context.query || {};
|
|
2627
|
-
const key = byQueryKey(config.prefix, model, collectionVersion, query, {
|
|
2628
|
-
select: context.select,
|
|
2629
|
-
populate: context.populate
|
|
2630
|
-
});
|
|
2631
|
-
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2632
|
-
try {
|
|
2633
|
-
await config.adapter.set(key, result, ttl);
|
|
2634
|
-
stats.sets++;
|
|
2635
|
-
log(`Cached getByQuery result:`, key);
|
|
2636
|
-
} catch (e) {
|
|
2637
|
-
log(`Failed to cache getByQuery:`, e);
|
|
2638
|
-
}
|
|
3056
|
+
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
3057
|
+
return applyOperator.call(this, id, field, value, "$push", options);
|
|
2639
3058
|
});
|
|
2640
3059
|
/**
|
|
2641
|
-
*
|
|
3060
|
+
* Remove value from array field
|
|
2642
3061
|
*/
|
|
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
|
-
}
|
|
3062
|
+
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
3063
|
+
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
2675
3064
|
});
|
|
2676
3065
|
/**
|
|
2677
|
-
*
|
|
3066
|
+
* Add value to array only if not already present (unique)
|
|
2678
3067
|
*/
|
|
2679
|
-
repo.
|
|
2680
|
-
|
|
3068
|
+
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
3069
|
+
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
2681
3070
|
});
|
|
2682
3071
|
/**
|
|
2683
|
-
*
|
|
3072
|
+
* Set field value (alias for update with $set)
|
|
2684
3073
|
*/
|
|
2685
|
-
repo.
|
|
2686
|
-
|
|
3074
|
+
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
3075
|
+
return applyOperator.call(this, id, field, value, "$set", options);
|
|
2687
3076
|
});
|
|
2688
3077
|
/**
|
|
2689
|
-
*
|
|
3078
|
+
* Unset (remove) field from document
|
|
2690
3079
|
*/
|
|
2691
|
-
repo.
|
|
2692
|
-
const
|
|
2693
|
-
|
|
2694
|
-
|
|
3080
|
+
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
3081
|
+
const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
|
|
3082
|
+
acc[field] = "";
|
|
3083
|
+
return acc;
|
|
3084
|
+
}, {});
|
|
3085
|
+
return this.update(id, { $unset: unsetObj }, options);
|
|
2695
3086
|
});
|
|
2696
3087
|
/**
|
|
2697
|
-
*
|
|
3088
|
+
* Rename field in document
|
|
2698
3089
|
*/
|
|
2699
|
-
repo.
|
|
2700
|
-
|
|
3090
|
+
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
3091
|
+
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
2701
3092
|
});
|
|
2702
3093
|
/**
|
|
2703
|
-
*
|
|
3094
|
+
* Multiply numeric field by value
|
|
2704
3095
|
*/
|
|
2705
|
-
repo.
|
|
2706
|
-
|
|
2707
|
-
const id = String(context.id);
|
|
2708
|
-
await Promise.all([invalidateById(id), bumpVersion()]);
|
|
3096
|
+
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
3097
|
+
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
2709
3098
|
});
|
|
2710
3099
|
/**
|
|
2711
|
-
*
|
|
3100
|
+
* Set field to minimum value (only if current value is greater)
|
|
2712
3101
|
*/
|
|
2713
|
-
repo.
|
|
2714
|
-
|
|
3102
|
+
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
3103
|
+
return applyOperator.call(this, id, field, value, "$min", options);
|
|
2715
3104
|
});
|
|
2716
3105
|
/**
|
|
2717
|
-
*
|
|
3106
|
+
* Set field to maximum value (only if current value is less)
|
|
2718
3107
|
*/
|
|
2719
|
-
repo.
|
|
2720
|
-
|
|
3108
|
+
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
3109
|
+
return applyOperator.call(this, id, field, value, "$max", options);
|
|
2721
3110
|
});
|
|
2722
3111
|
/**
|
|
2723
|
-
*
|
|
2724
|
-
* Use when document was updated outside this service
|
|
3112
|
+
* Atomic update with multiple MongoDB operators in a single call
|
|
2725
3113
|
*
|
|
2726
|
-
*
|
|
2727
|
-
*
|
|
2728
|
-
*/
|
|
2729
|
-
repo.invalidateCache = async (id) => {
|
|
2730
|
-
await invalidateById(id);
|
|
2731
|
-
log(`Manual invalidation for ID:`, id);
|
|
2732
|
-
};
|
|
2733
|
-
/**
|
|
2734
|
-
* Invalidate all list caches for this model
|
|
2735
|
-
* Use when bulk changes happened outside this service
|
|
3114
|
+
* Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
|
|
3115
|
+
* into one atomic database operation.
|
|
2736
3116
|
*
|
|
2737
3117
|
* @example
|
|
2738
|
-
*
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
};
|
|
2744
|
-
/**
|
|
2745
|
-
* Invalidate ALL cache entries for this model
|
|
2746
|
-
* Nuclear option - use sparingly
|
|
3118
|
+
* // Combine $inc + $set in one atomic call
|
|
3119
|
+
* await repo.atomicUpdate(id, {
|
|
3120
|
+
* $inc: { views: 1, commentCount: 1 },
|
|
3121
|
+
* $set: { lastActiveAt: new Date() }
|
|
3122
|
+
* });
|
|
2747
3123
|
*
|
|
2748
|
-
*
|
|
2749
|
-
* await
|
|
2750
|
-
|
|
2751
|
-
|
|
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
|
-
};
|
|
2764
|
-
/**
|
|
2765
|
-
* Get cache statistics for monitoring
|
|
3124
|
+
* // Multiple operators: $inc + $set + $push
|
|
3125
|
+
* await repo.atomicUpdate(id, {
|
|
3126
|
+
* $inc: { 'metrics.total': 1 },
|
|
3127
|
+
* $set: { updatedAt: new Date() },
|
|
3128
|
+
* $push: { history: { action: 'update', at: new Date() } }
|
|
3129
|
+
* });
|
|
2766
3130
|
*
|
|
2767
|
-
*
|
|
2768
|
-
*
|
|
2769
|
-
*
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
*
|
|
3131
|
+
* // $push with $each modifier
|
|
3132
|
+
* await repo.atomicUpdate(id, {
|
|
3133
|
+
* $push: { tags: { $each: ['featured', 'popular'] } },
|
|
3134
|
+
* $inc: { tagCount: 2 }
|
|
3135
|
+
* });
|
|
3136
|
+
*
|
|
3137
|
+
* // With arrayFilters for positional updates
|
|
3138
|
+
* await repo.atomicUpdate(id, {
|
|
3139
|
+
* $set: { 'items.$[elem].quantity': 5 }
|
|
3140
|
+
* }, { arrayFilters: [{ 'elem._id': itemId }] });
|
|
2774
3141
|
*/
|
|
2775
|
-
repo.
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
*
|
|
2797
|
-
* const productRepo = new Repository(Product, [
|
|
2798
|
-
* methodRegistryPlugin(),
|
|
2799
|
-
* cascadePlugin({
|
|
2800
|
-
* relations: [
|
|
2801
|
-
* { model: 'StockEntry', foreignKey: 'product' },
|
|
2802
|
-
* { model: 'StockMovement', foreignKey: 'product' },
|
|
2803
|
-
* ]
|
|
2804
|
-
* })
|
|
2805
|
-
* ]);
|
|
2806
|
-
*
|
|
2807
|
-
* // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
|
|
2808
|
-
* await productRepo.delete(productId);
|
|
2809
|
-
* ```
|
|
2810
|
-
*/
|
|
2811
|
-
/**
|
|
2812
|
-
* Cascade delete plugin
|
|
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);
|
|
3142
|
+
repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
|
|
3143
|
+
const validOperators = new Set([
|
|
3144
|
+
"$inc",
|
|
3145
|
+
"$set",
|
|
3146
|
+
"$unset",
|
|
3147
|
+
"$push",
|
|
3148
|
+
"$pull",
|
|
3149
|
+
"$addToSet",
|
|
3150
|
+
"$pop",
|
|
3151
|
+
"$rename",
|
|
3152
|
+
"$min",
|
|
3153
|
+
"$max",
|
|
3154
|
+
"$mul",
|
|
3155
|
+
"$setOnInsert",
|
|
3156
|
+
"$bit",
|
|
3157
|
+
"$currentDate"
|
|
3158
|
+
]);
|
|
3159
|
+
const keys = Object.keys(operators);
|
|
3160
|
+
if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
|
|
3161
|
+
for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
|
|
3162
|
+
return this.update(id, operators, options);
|
|
2946
3163
|
});
|
|
2947
3164
|
}
|
|
2948
3165
|
};
|
|
2949
3166
|
}
|
|
2950
|
-
|
|
2951
3167
|
//#endregion
|
|
2952
3168
|
//#region src/plugins/multi-tenant.plugin.ts
|
|
2953
3169
|
/**
|
|
@@ -3086,7 +3302,6 @@ function multiTenantPlugin(options = {}) {
|
|
|
3086
3302
|
}
|
|
3087
3303
|
};
|
|
3088
3304
|
}
|
|
3089
|
-
|
|
3090
3305
|
//#endregion
|
|
3091
3306
|
//#region src/plugins/observability.plugin.ts
|
|
3092
3307
|
const DEFAULT_OPS = [
|
|
@@ -3147,691 +3362,538 @@ function observabilityPlugin(options) {
|
|
|
3147
3362
|
}
|
|
3148
3363
|
};
|
|
3149
3364
|
}
|
|
3150
|
-
|
|
3151
3365
|
//#endregion
|
|
3152
|
-
//#region src/plugins/
|
|
3366
|
+
//#region src/plugins/soft-delete.plugin.ts
|
|
3153
3367
|
/**
|
|
3154
|
-
*
|
|
3368
|
+
* Build filter condition based on filter mode
|
|
3369
|
+
*/
|
|
3370
|
+
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
3371
|
+
if (includeDeleted) return {};
|
|
3372
|
+
if (filterMode === "exists") return { [deletedField]: { $exists: false } };
|
|
3373
|
+
return { [deletedField]: null };
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Build filter condition for finding deleted documents
|
|
3377
|
+
*/
|
|
3378
|
+
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
3379
|
+
if (filterMode === "exists") return { [deletedField]: {
|
|
3380
|
+
$exists: true,
|
|
3381
|
+
$ne: null
|
|
3382
|
+
} };
|
|
3383
|
+
return { [deletedField]: { $ne: null } };
|
|
3384
|
+
}
|
|
3385
|
+
/**
|
|
3386
|
+
* Soft delete plugin
|
|
3155
3387
|
*
|
|
3156
|
-
*
|
|
3157
|
-
*
|
|
3388
|
+
* @example Basic usage
|
|
3389
|
+
* ```typescript
|
|
3390
|
+
* const repo = new Repository(Model, [
|
|
3391
|
+
* softDeletePlugin({ deletedField: 'deletedAt' })
|
|
3392
|
+
* ]);
|
|
3158
3393
|
*
|
|
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
|
|
3394
|
+
* // Delete (soft)
|
|
3395
|
+
* await repo.delete(id);
|
|
3165
3396
|
*
|
|
3166
|
-
*
|
|
3397
|
+
* // Restore
|
|
3398
|
+
* await repo.restore(id);
|
|
3399
|
+
*
|
|
3400
|
+
* // Get deleted documents
|
|
3401
|
+
* await repo.getDeleted({ page: 1, limit: 20 });
|
|
3402
|
+
* ```
|
|
3403
|
+
*
|
|
3404
|
+
* @example With null filter mode (for schemas with default: null)
|
|
3167
3405
|
* ```typescript
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
3175
|
-
*
|
|
3176
|
-
*
|
|
3406
|
+
* // Schema: { deletedAt: { type: Date, default: null } }
|
|
3407
|
+
* const repo = new Repository(Model, [
|
|
3408
|
+
* softDeletePlugin({
|
|
3409
|
+
* deletedField: 'deletedAt',
|
|
3410
|
+
* filterMode: 'null', // default - works with default: null
|
|
3411
|
+
* })
|
|
3412
|
+
* ]);
|
|
3413
|
+
* ```
|
|
3414
|
+
*
|
|
3415
|
+
* @example With TTL for auto-cleanup
|
|
3416
|
+
* ```typescript
|
|
3417
|
+
* const repo = new Repository(Model, [
|
|
3418
|
+
* softDeletePlugin({
|
|
3419
|
+
* deletedField: 'deletedAt',
|
|
3420
|
+
* ttlDays: 30, // Auto-delete after 30 days
|
|
3421
|
+
* })
|
|
3177
3422
|
* ]);
|
|
3178
3423
|
* ```
|
|
3179
3424
|
*/
|
|
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);
|
|
3425
|
+
function softDeletePlugin(options = {}) {
|
|
3426
|
+
const deletedField = options.deletedField || "deletedAt";
|
|
3427
|
+
const deletedByField = options.deletedByField || "deletedBy";
|
|
3428
|
+
const filterMode = options.filterMode || "null";
|
|
3429
|
+
const addRestoreMethod = options.addRestoreMethod !== false;
|
|
3430
|
+
const addGetDeletedMethod = options.addGetDeletedMethod !== false;
|
|
3431
|
+
const ttlDays = options.ttlDays;
|
|
3299
3432
|
return {
|
|
3300
|
-
name: "
|
|
3433
|
+
name: "softDelete",
|
|
3301
3434
|
apply(repo) {
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3435
|
+
try {
|
|
3436
|
+
const schemaPaths = repo.Model.schema.paths;
|
|
3437
|
+
for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
|
|
3438
|
+
if (pathName === "_id" || pathName === deletedField) continue;
|
|
3439
|
+
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 } }`);
|
|
3440
|
+
}
|
|
3441
|
+
} catch (err) {
|
|
3442
|
+
warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3443
|
+
}
|
|
3444
|
+
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
3445
|
+
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
3446
|
+
repo.Model.collection.createIndex({ [deletedField]: 1 }, {
|
|
3447
|
+
expireAfterSeconds: ttlSeconds,
|
|
3448
|
+
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
3449
|
+
}).catch((err) => {
|
|
3450
|
+
if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
3313
3451
|
});
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
if (
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3452
|
+
}
|
|
3453
|
+
repo.on("before:delete", async (context) => {
|
|
3454
|
+
if (options.soft !== false) {
|
|
3455
|
+
const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
|
|
3456
|
+
if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
|
|
3457
|
+
const deleteQuery = {
|
|
3458
|
+
_id: context.id,
|
|
3459
|
+
...context.query || {}
|
|
3460
|
+
};
|
|
3461
|
+
if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
|
|
3462
|
+
const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
|
|
3463
|
+
error.status = 404;
|
|
3464
|
+
throw error;
|
|
3323
3465
|
}
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3466
|
+
context.softDeleted = true;
|
|
3467
|
+
}
|
|
3468
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3469
|
+
repo.on("before:getAll", (context) => {
|
|
3470
|
+
if (options.soft !== false) {
|
|
3471
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3472
|
+
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
3473
|
+
...context.filters || {},
|
|
3474
|
+
...deleteFilter
|
|
3475
|
+
};
|
|
3476
|
+
}
|
|
3477
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3478
|
+
repo.on("before:getById", (context) => {
|
|
3479
|
+
if (options.soft !== false) {
|
|
3480
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3481
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3482
|
+
...context.query || {},
|
|
3483
|
+
...deleteFilter
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3487
|
+
repo.on("before:getByQuery", (context) => {
|
|
3488
|
+
if (options.soft !== false) {
|
|
3489
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3490
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3491
|
+
...context.query || {},
|
|
3492
|
+
...deleteFilter
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3496
|
+
repo.on("before:count", (context) => {
|
|
3497
|
+
if (options.soft !== false) {
|
|
3498
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3499
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3500
|
+
...context.query || {},
|
|
3501
|
+
...deleteFilter
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3505
|
+
repo.on("before:exists", (context) => {
|
|
3506
|
+
if (options.soft !== false) {
|
|
3507
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3508
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3509
|
+
...context.query || {},
|
|
3510
|
+
...deleteFilter
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3514
|
+
repo.on("before:getOrCreate", (context) => {
|
|
3515
|
+
if (options.soft !== false) {
|
|
3516
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3517
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3518
|
+
...context.query || {},
|
|
3519
|
+
...deleteFilter
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3522
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3523
|
+
repo.on("before:distinct", (context) => {
|
|
3524
|
+
if (options.soft !== false) {
|
|
3525
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3526
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3527
|
+
...context.query || {},
|
|
3528
|
+
...deleteFilter
|
|
3529
|
+
};
|
|
3530
|
+
}
|
|
3531
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3532
|
+
repo.on("before:updateMany", (context) => {
|
|
3533
|
+
if (options.soft !== false) {
|
|
3534
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3535
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3536
|
+
...context.query || {},
|
|
3537
|
+
...deleteFilter
|
|
3538
|
+
};
|
|
3539
|
+
}
|
|
3540
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3541
|
+
repo.on("before:deleteMany", async (context) => {
|
|
3542
|
+
if (options.soft !== false) {
|
|
3543
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
|
|
3544
|
+
const finalQuery = {
|
|
3545
|
+
...context.query || {},
|
|
3546
|
+
...deleteFilter
|
|
3547
|
+
};
|
|
3548
|
+
await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
|
|
3549
|
+
context.softDeleted = true;
|
|
3550
|
+
}
|
|
3551
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3552
|
+
repo.on("before:aggregate", (context) => {
|
|
3553
|
+
if (options.soft !== false) {
|
|
3554
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3555
|
+
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
3556
|
+
...context.query || {},
|
|
3557
|
+
...deleteFilter
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3561
|
+
repo.on("before:aggregatePaginate", (context) => {
|
|
3562
|
+
if (options.soft !== false) {
|
|
3563
|
+
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
3564
|
+
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
3565
|
+
...context.filters || {},
|
|
3566
|
+
...deleteFilter
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
}, { priority: HOOK_PRIORITY.POLICY });
|
|
3570
|
+
if (addRestoreMethod) {
|
|
3571
|
+
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
3572
|
+
const context = await this._buildContext.call(this, "restore", {
|
|
3573
|
+
id,
|
|
3574
|
+
...restoreOptions
|
|
3575
|
+
});
|
|
3576
|
+
const updateData = {
|
|
3577
|
+
[deletedField]: null,
|
|
3578
|
+
[deletedByField]: null
|
|
3579
|
+
};
|
|
3580
|
+
const restoreQuery = {
|
|
3581
|
+
_id: id,
|
|
3582
|
+
...context.query || {}
|
|
3583
|
+
};
|
|
3584
|
+
const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
|
|
3585
|
+
returnDocument: "after",
|
|
3586
|
+
session: restoreOptions.session
|
|
3587
|
+
});
|
|
3588
|
+
if (!result) {
|
|
3589
|
+
const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
|
|
3590
|
+
error.status = 404;
|
|
3591
|
+
throw error;
|
|
3332
3592
|
}
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
userId: getUserId(context),
|
|
3338
|
-
orgId: context.organizationId,
|
|
3339
|
-
changes,
|
|
3340
|
-
metadata: metadata?.(context)
|
|
3593
|
+
await this.emitAsync("after:restore", {
|
|
3594
|
+
id,
|
|
3595
|
+
result,
|
|
3596
|
+
context
|
|
3341
3597
|
});
|
|
3342
|
-
|
|
3343
|
-
}
|
|
3344
|
-
if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
|
|
3345
|
-
writeAudit(AuditModel, {
|
|
3346
|
-
model: context.model || repo.model,
|
|
3347
|
-
operation: "delete",
|
|
3348
|
-
documentId: context.id,
|
|
3349
|
-
userId: getUserId(context),
|
|
3350
|
-
orgId: context.organizationId,
|
|
3351
|
-
metadata: metadata?.(context)
|
|
3352
|
-
});
|
|
3353
|
-
});
|
|
3354
|
-
if (typeof repo.registerMethod === "function")
|
|
3355
|
-
/**
|
|
3356
|
-
* Get audit trail for a specific document
|
|
3357
|
-
*/
|
|
3358
|
-
repo.registerMethod("getAuditTrail", async function(documentId, queryOptions = {}) {
|
|
3359
|
-
const { page = 1, limit = 20, operation } = queryOptions;
|
|
3360
|
-
const skip = (page - 1) * limit;
|
|
3361
|
-
const filter = {
|
|
3362
|
-
model: this.model,
|
|
3363
|
-
documentId
|
|
3364
|
-
};
|
|
3365
|
-
if (operation) filter.operation = operation;
|
|
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
|
|
3598
|
+
return result;
|
|
3375
3599
|
};
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
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
|
|
3600
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
|
|
3601
|
+
else repo.restore = restoreMethod.bind(repo);
|
|
3602
|
+
}
|
|
3603
|
+
if (addGetDeletedMethod) {
|
|
3604
|
+
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
3605
|
+
const context = await this._buildContext.call(this, "getDeleted", {
|
|
3606
|
+
...params,
|
|
3607
|
+
...getDeletedOptions
|
|
3608
|
+
});
|
|
3609
|
+
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
3610
|
+
const combinedFilters = {
|
|
3611
|
+
...params.filters || {},
|
|
3612
|
+
...deletedFilter,
|
|
3613
|
+
...context.filters || {},
|
|
3614
|
+
...context.query || {}
|
|
3615
|
+
};
|
|
3616
|
+
const page = params.page || 1;
|
|
3617
|
+
const limit = params.limit || 20;
|
|
3618
|
+
const skip = (page - 1) * limit;
|
|
3619
|
+
let sortSpec = { [deletedField]: -1 };
|
|
3620
|
+
if (params.sort) if (typeof params.sort === "string") {
|
|
3621
|
+
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
3622
|
+
sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
|
|
3623
|
+
} else sortSpec = params.sort;
|
|
3624
|
+
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
3625
|
+
if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
|
|
3626
|
+
if (getDeletedOptions.select) {
|
|
3627
|
+
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
3628
|
+
query = query.select(selectValue);
|
|
3519
3629
|
}
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
limit,
|
|
3526
|
-
from
|
|
3527
|
-
};
|
|
3528
|
-
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
3529
|
-
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
3530
|
-
const docsOrder = /* @__PURE__ */ new Map();
|
|
3531
|
-
const scores = /* @__PURE__ */ new Map();
|
|
3532
|
-
const ids = [];
|
|
3533
|
-
hits.forEach((hit, idx) => {
|
|
3534
|
-
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
3535
|
-
if (docId) {
|
|
3536
|
-
const strId = String(docId);
|
|
3537
|
-
docsOrder.set(strId, idx);
|
|
3538
|
-
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
3539
|
-
ids.push(strId);
|
|
3630
|
+
if (getDeletedOptions.populate) {
|
|
3631
|
+
const populateSpec = getDeletedOptions.populate;
|
|
3632
|
+
if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
3633
|
+
else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
|
|
3634
|
+
else query = query.populate(populateSpec);
|
|
3540
3635
|
}
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
const aId = String(a._id);
|
|
3555
|
-
const bId = String(b._id);
|
|
3556
|
-
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
3557
|
-
}).map((doc) => {
|
|
3558
|
-
const strId = String(doc._id);
|
|
3559
|
-
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
3560
|
-
...doc,
|
|
3561
|
-
_score: scores.get(strId)
|
|
3562
|
-
};
|
|
3563
|
-
return doc;
|
|
3564
|
-
}),
|
|
3565
|
-
total,
|
|
3566
|
-
limit,
|
|
3567
|
-
from
|
|
3636
|
+
if (getDeletedOptions.lean !== false) query = query.lean();
|
|
3637
|
+
const [docs, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
|
|
3638
|
+
const pages = Math.ceil(total / limit);
|
|
3639
|
+
return {
|
|
3640
|
+
method: "offset",
|
|
3641
|
+
docs,
|
|
3642
|
+
page,
|
|
3643
|
+
limit,
|
|
3644
|
+
total,
|
|
3645
|
+
pages,
|
|
3646
|
+
hasNext: page < pages,
|
|
3647
|
+
hasPrev: page > 1
|
|
3648
|
+
};
|
|
3568
3649
|
};
|
|
3569
|
-
|
|
3650
|
+
if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
|
|
3651
|
+
else repo.getDeleted = getDeletedMethod.bind(repo);
|
|
3652
|
+
}
|
|
3570
3653
|
}
|
|
3571
3654
|
};
|
|
3572
3655
|
}
|
|
3573
|
-
|
|
3574
3656
|
//#endregion
|
|
3575
|
-
//#region src/plugins/
|
|
3657
|
+
//#region src/plugins/subdocument.plugin.ts
|
|
3576
3658
|
/**
|
|
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
|
-
* ```
|
|
3659
|
+
* Subdocument plugin for managing nested arrays
|
|
3601
3660
|
*
|
|
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
|
-
* }),
|
|
3661
|
+
* @example
|
|
3662
|
+
* const repo = new Repository(Model, [
|
|
3663
|
+
* methodRegistryPlugin(),
|
|
3664
|
+
* subdocumentPlugin(),
|
|
3615
3665
|
* ]);
|
|
3616
3666
|
*
|
|
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
|
-
* ```
|
|
3667
|
+
* await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
|
|
3668
|
+
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
3634
3669
|
*/
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
}
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3670
|
+
function subdocumentPlugin() {
|
|
3671
|
+
return {
|
|
3672
|
+
name: "subdocument",
|
|
3673
|
+
apply(repo) {
|
|
3674
|
+
if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
3675
|
+
/**
|
|
3676
|
+
* Add subdocument to array
|
|
3677
|
+
*/
|
|
3678
|
+
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
3679
|
+
return this.update.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
|
|
3680
|
+
});
|
|
3681
|
+
/**
|
|
3682
|
+
* Get subdocument from array
|
|
3683
|
+
*/
|
|
3684
|
+
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
3685
|
+
return this._executeQuery.call(this, async (Model) => {
|
|
3686
|
+
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
3687
|
+
if (!parent) throw createError(404, "Parent not found");
|
|
3688
|
+
const arrayField = parent[arrayPath];
|
|
3689
|
+
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
3690
|
+
const sub = arrayField.id(subId);
|
|
3691
|
+
if (!sub) throw createError(404, "Subdocument not found");
|
|
3692
|
+
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
3693
|
+
});
|
|
3694
|
+
});
|
|
3695
|
+
/**
|
|
3696
|
+
* Update subdocument in array
|
|
3697
|
+
*/
|
|
3698
|
+
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
3699
|
+
return this._executeQuery.call(this, async (Model) => {
|
|
3700
|
+
const query = {
|
|
3701
|
+
_id: parentId,
|
|
3702
|
+
[`${arrayPath}._id`]: subId
|
|
3703
|
+
};
|
|
3704
|
+
const update = { $set: { [`${arrayPath}.$`]: {
|
|
3705
|
+
...updateData,
|
|
3706
|
+
_id: subId
|
|
3707
|
+
} } };
|
|
3708
|
+
const result = await Model.findOneAndUpdate(query, update, {
|
|
3709
|
+
returnDocument: "after",
|
|
3710
|
+
runValidators: true,
|
|
3711
|
+
session: options.session
|
|
3712
|
+
}).exec();
|
|
3713
|
+
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
3714
|
+
return result;
|
|
3715
|
+
});
|
|
3716
|
+
});
|
|
3717
|
+
/**
|
|
3718
|
+
* Delete subdocument from array
|
|
3719
|
+
*/
|
|
3720
|
+
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
3721
|
+
return this.update.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
//#endregion
|
|
3727
|
+
//#region src/plugins/timestamp.plugin.ts
|
|
3649
3728
|
/**
|
|
3650
|
-
*
|
|
3651
|
-
*
|
|
3652
|
-
*
|
|
3729
|
+
* Timestamp plugin that auto-injects timestamps
|
|
3730
|
+
*
|
|
3731
|
+
* @example
|
|
3732
|
+
* const repo = new Repository(Model, [timestampPlugin()]);
|
|
3653
3733
|
*/
|
|
3654
|
-
function
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3734
|
+
function timestampPlugin() {
|
|
3735
|
+
return {
|
|
3736
|
+
name: "timestamp",
|
|
3737
|
+
apply(repo) {
|
|
3738
|
+
repo.on("before:create", (context) => {
|
|
3739
|
+
if (!context.data) return;
|
|
3740
|
+
const now = /* @__PURE__ */ new Date();
|
|
3741
|
+
if (!context.data.createdAt) context.data.createdAt = now;
|
|
3742
|
+
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
3743
|
+
});
|
|
3744
|
+
repo.on("before:update", (context) => {
|
|
3745
|
+
if (!context.data) return;
|
|
3746
|
+
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
};
|
|
3658
3750
|
}
|
|
3751
|
+
//#endregion
|
|
3752
|
+
//#region src/plugins/validation-chain.plugin.ts
|
|
3659
3753
|
/**
|
|
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)
|
|
3754
|
+
* Validation chain plugin
|
|
3667
3755
|
*
|
|
3668
3756
|
* @example
|
|
3669
|
-
* const
|
|
3670
|
-
*
|
|
3671
|
-
*
|
|
3672
|
-
*
|
|
3673
|
-
*
|
|
3674
|
-
*
|
|
3757
|
+
* const repo = new Repository(Model, [
|
|
3758
|
+
* validationChainPlugin([
|
|
3759
|
+
* requireField('email'),
|
|
3760
|
+
* uniqueField('email', 'Email already exists'),
|
|
3761
|
+
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
3762
|
+
* ])
|
|
3763
|
+
* ]);
|
|
3675
3764
|
*/
|
|
3676
|
-
|
|
3677
|
-
const
|
|
3678
|
-
|
|
3679
|
-
|
|
3765
|
+
function validationChainPlugin(validators = [], options = {}) {
|
|
3766
|
+
const { stopOnFirstError = true } = options;
|
|
3767
|
+
validators.forEach((v, idx) => {
|
|
3768
|
+
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
3769
|
+
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
3680
3770
|
});
|
|
3681
|
-
|
|
3682
|
-
|
|
3771
|
+
const validatorsByOperation = {
|
|
3772
|
+
create: [],
|
|
3773
|
+
update: [],
|
|
3774
|
+
delete: [],
|
|
3775
|
+
createMany: []
|
|
3776
|
+
};
|
|
3777
|
+
const allOperationsValidators = [];
|
|
3778
|
+
validators.forEach((v) => {
|
|
3779
|
+
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
3780
|
+
else v.operations.forEach((op) => {
|
|
3781
|
+
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
3782
|
+
});
|
|
3783
|
+
});
|
|
3784
|
+
return {
|
|
3785
|
+
name: "validation-chain",
|
|
3786
|
+
apply(repo) {
|
|
3787
|
+
const getValidatorsForOperation = (operation) => {
|
|
3788
|
+
const specific = validatorsByOperation[operation] || [];
|
|
3789
|
+
return [...allOperationsValidators, ...specific];
|
|
3790
|
+
};
|
|
3791
|
+
const runValidators = async (operation, context) => {
|
|
3792
|
+
const operationValidators = getValidatorsForOperation(operation);
|
|
3793
|
+
const errors = [];
|
|
3794
|
+
for (const validator of operationValidators) try {
|
|
3795
|
+
await validator.validate(context, repo);
|
|
3796
|
+
} catch (error) {
|
|
3797
|
+
if (stopOnFirstError) throw error;
|
|
3798
|
+
errors.push({
|
|
3799
|
+
validator: validator.name,
|
|
3800
|
+
error: error.message || String(error)
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
if (errors.length > 0) {
|
|
3804
|
+
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
3805
|
+
err.validationErrors = errors;
|
|
3806
|
+
throw err;
|
|
3807
|
+
}
|
|
3808
|
+
};
|
|
3809
|
+
repo.on("before:create", async (context) => runValidators("create", context));
|
|
3810
|
+
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
3811
|
+
repo.on("before:update", async (context) => runValidators("update", context));
|
|
3812
|
+
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
3813
|
+
}
|
|
3814
|
+
};
|
|
3683
3815
|
}
|
|
3684
3816
|
/**
|
|
3685
|
-
*
|
|
3686
|
-
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
3687
|
-
*
|
|
3688
|
-
* Uses atomic MongoDB counters — safe under concurrency.
|
|
3817
|
+
* Block operation if condition is true
|
|
3689
3818
|
*
|
|
3690
3819
|
* @example
|
|
3691
|
-
*
|
|
3692
|
-
* customIdPlugin({
|
|
3693
|
-
* field: 'invoiceNumber',
|
|
3694
|
-
* generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
|
|
3695
|
-
* })
|
|
3696
|
-
* ```
|
|
3820
|
+
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
3697
3821
|
*/
|
|
3698
|
-
function
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3822
|
+
function blockIf(name, operations, condition, errorMessage) {
|
|
3823
|
+
return {
|
|
3824
|
+
name,
|
|
3825
|
+
operations,
|
|
3826
|
+
validate: (context) => {
|
|
3827
|
+
if (condition(context)) throw createError(403, errorMessage);
|
|
3828
|
+
}
|
|
3704
3829
|
};
|
|
3705
3830
|
}
|
|
3706
3831
|
/**
|
|
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
|
-
* ```
|
|
3832
|
+
* Require a field to be present
|
|
3726
3833
|
*/
|
|
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;
|
|
3834
|
+
function requireField(field, operations = ["create"]) {
|
|
3835
|
+
return {
|
|
3836
|
+
name: `require-${field}`,
|
|
3837
|
+
operations,
|
|
3838
|
+
validate: (context) => {
|
|
3839
|
+
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
3749
3840
|
}
|
|
3750
|
-
const seq = await getNextSequence(counterKey, 1, context._counterConnection);
|
|
3751
|
-
return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
|
|
3752
3841
|
};
|
|
3753
3842
|
}
|
|
3754
3843
|
/**
|
|
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
|
-
* ```
|
|
3844
|
+
* Auto-inject a value if not present
|
|
3770
3845
|
*/
|
|
3771
|
-
function
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
}
|
|
3781
|
-
return `${prefix}${separator}${result}`;
|
|
3846
|
+
function autoInject(field, getter, operations = ["create"]) {
|
|
3847
|
+
return {
|
|
3848
|
+
name: `auto-inject-${field}`,
|
|
3849
|
+
operations,
|
|
3850
|
+
validate: (context) => {
|
|
3851
|
+
if (context.data && !(field in context.data)) {
|
|
3852
|
+
const value = getter(context);
|
|
3853
|
+
if (value !== null && value !== void 0) context.data[field] = value;
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3782
3856
|
};
|
|
3783
3857
|
}
|
|
3784
3858
|
/**
|
|
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
|
-
* ```
|
|
3859
|
+
* Make a field immutable (cannot be updated)
|
|
3804
3860
|
*/
|
|
3805
|
-
function
|
|
3806
|
-
const fieldName = options.field || "customId";
|
|
3807
|
-
const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
|
|
3861
|
+
function immutableField(field) {
|
|
3808
3862
|
return {
|
|
3809
|
-
name:
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3863
|
+
name: `immutable-${field}`,
|
|
3864
|
+
operations: ["update"],
|
|
3865
|
+
validate: (context) => {
|
|
3866
|
+
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
3867
|
+
}
|
|
3868
|
+
};
|
|
3869
|
+
}
|
|
3870
|
+
/**
|
|
3871
|
+
* Ensure field value is unique
|
|
3872
|
+
*/
|
|
3873
|
+
function uniqueField(field, errorMessage) {
|
|
3874
|
+
return {
|
|
3875
|
+
name: `unique-${field}`,
|
|
3876
|
+
operations: ["create", "update"],
|
|
3877
|
+
validate: async (context, repo) => {
|
|
3878
|
+
if (!context.data?.[field]) return;
|
|
3879
|
+
if (!repo) {
|
|
3880
|
+
warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
const query = { [field]: context.data[field] };
|
|
3884
|
+
const getByQuery = repo.getByQuery;
|
|
3885
|
+
if (typeof getByQuery !== "function") {
|
|
3886
|
+
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
const existing = await getByQuery.call(repo, query, {
|
|
3890
|
+
select: "_id",
|
|
3891
|
+
lean: true,
|
|
3892
|
+
throwOnNotFound: false
|
|
3831
3893
|
});
|
|
3894
|
+
if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
3832
3895
|
}
|
|
3833
3896
|
};
|
|
3834
3897
|
}
|
|
3835
|
-
|
|
3836
3898
|
//#endregion
|
|
3837
|
-
export {
|
|
3899
|
+
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 };
|