@classytic/mongokit 3.1.5 → 3.2.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 +89 -4
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/index.js +5 -3
- package/dist/ai/index.d.ts +175 -0
- package/dist/ai/index.js +206 -0
- package/dist/chunks/{chunk-M2XHQGZB.js → chunk-44KXLGPO.js} +28 -1
- package/dist/chunks/{chunk-CSLJ2PL2.js → chunk-DEVXDBRL.js} +143 -9
- package/dist/chunks/{chunk-CF6FLC2G.js → chunk-I7CWNAJB.js} +1 -1
- package/dist/chunks/chunk-JWUAVZ3L.js +8 -0
- package/dist/chunks/{chunk-IT7DCOKR.js → chunk-UE2IEXZJ.js} +15 -8
- package/dist/chunks/chunk-URLJFIR7.js +22 -0
- package/dist/chunks/{chunk-SAKSLT47.js → chunk-VWKIKZYF.js} +274 -7
- package/dist/chunks/chunk-WSFCRVEQ.js +7 -0
- package/dist/{index-BXSSv1pW.d.ts → index-BDn5fSTE.d.ts} +13 -1
- package/dist/index.d.ts +151 -42
- package/dist/index.js +299 -299
- package/dist/{mongooseToJsonSchema-Cc5AwuDu.d.ts → mongooseToJsonSchema-CaRF_bCN.d.ts} +33 -2
- package/dist/pagination/PaginationEngine.d.ts +1 -1
- package/dist/pagination/PaginationEngine.js +3 -2
- package/dist/plugins/index.d.ts +125 -2
- package/dist/plugins/index.js +5 -3
- package/dist/{types-B5Uv6Ak7.d.ts → types-Jni1KgkP.d.ts} +18 -11
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +4 -2
- package/package.json +10 -2
- package/dist/chunks/chunk-VJXDGP3C.js +0 -14
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { upsert } from './chunk-
|
|
1
|
+
import { upsert } from './chunk-I7CWNAJB.js';
|
|
2
2
|
import { versionKey, byIdKey, byQueryKey, listQueryKey, modelPattern, getFieldsForUser } from './chunk-2ZN65ZOP.js';
|
|
3
|
-
import {
|
|
3
|
+
import { warn, debug } from './chunk-URLJFIR7.js';
|
|
4
|
+
import { createError } from './chunk-JWUAVZ3L.js';
|
|
4
5
|
import mongoose from 'mongoose';
|
|
5
6
|
|
|
6
7
|
// src/plugins/field-filter.plugin.ts
|
|
@@ -137,7 +138,7 @@ function softDeletePlugin(options = {}) {
|
|
|
137
138
|
}
|
|
138
139
|
).catch((err) => {
|
|
139
140
|
if (!err.message.includes("already exists")) {
|
|
140
|
-
|
|
141
|
+
warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
141
142
|
}
|
|
142
143
|
});
|
|
143
144
|
}
|
|
@@ -675,7 +676,7 @@ function cachePlugin(options) {
|
|
|
675
676
|
let collectionVersion = 0;
|
|
676
677
|
const log = (msg, data) => {
|
|
677
678
|
if (config.debug) {
|
|
678
|
-
|
|
679
|
+
debug(`[mongokit:cache] ${msg}`, data ?? "");
|
|
679
680
|
}
|
|
680
681
|
};
|
|
681
682
|
return {
|
|
@@ -779,7 +780,8 @@ function cachePlugin(options) {
|
|
|
779
780
|
limit,
|
|
780
781
|
after: context.after,
|
|
781
782
|
select: context.select,
|
|
782
|
-
populate: context.populate
|
|
783
|
+
populate: context.populate,
|
|
784
|
+
search: context.search
|
|
783
785
|
};
|
|
784
786
|
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
785
787
|
try {
|
|
@@ -846,7 +848,8 @@ function cachePlugin(options) {
|
|
|
846
848
|
limit,
|
|
847
849
|
after: context.after,
|
|
848
850
|
select: context.select,
|
|
849
|
-
populate: context.populate
|
|
851
|
+
populate: context.populate,
|
|
852
|
+
search: context.search
|
|
850
853
|
};
|
|
851
854
|
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
852
855
|
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
@@ -988,7 +991,15 @@ function cascadePlugin(options) {
|
|
|
988
991
|
}
|
|
989
992
|
};
|
|
990
993
|
if (parallel) {
|
|
991
|
-
await Promise.
|
|
994
|
+
const results = await Promise.allSettled(relations.map(cascadeDelete));
|
|
995
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
996
|
+
if (failures.length) {
|
|
997
|
+
const err = failures[0].reason;
|
|
998
|
+
if (failures.length > 1) {
|
|
999
|
+
err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
1000
|
+
}
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
992
1003
|
} else {
|
|
993
1004
|
for (const relation of relations) {
|
|
994
1005
|
await cascadeDelete(relation);
|
|
@@ -1077,7 +1088,15 @@ function cascadePlugin(options) {
|
|
|
1077
1088
|
}
|
|
1078
1089
|
};
|
|
1079
1090
|
if (parallel) {
|
|
1080
|
-
await Promise.
|
|
1091
|
+
const results = await Promise.allSettled(relations.map(cascadeDeleteMany));
|
|
1092
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
1093
|
+
if (failures.length) {
|
|
1094
|
+
const err = failures[0].reason;
|
|
1095
|
+
if (failures.length > 1) {
|
|
1096
|
+
err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
|
|
1097
|
+
}
|
|
1098
|
+
throw err;
|
|
1099
|
+
}
|
|
1081
1100
|
} else {
|
|
1082
1101
|
for (const relation of relations) {
|
|
1083
1102
|
await cascadeDeleteMany(relation);
|
|
@@ -1089,4 +1108,119 @@ function cascadePlugin(options) {
|
|
|
1089
1108
|
};
|
|
1090
1109
|
}
|
|
1091
1110
|
|
|
1092
|
-
|
|
1111
|
+
// src/plugins/multi-tenant.plugin.ts
|
|
1112
|
+
function multiTenantPlugin(options = {}) {
|
|
1113
|
+
const {
|
|
1114
|
+
tenantField = "organizationId",
|
|
1115
|
+
contextKey = "organizationId",
|
|
1116
|
+
required = true,
|
|
1117
|
+
skipOperations = [],
|
|
1118
|
+
skipWhen,
|
|
1119
|
+
resolveContext
|
|
1120
|
+
} = options;
|
|
1121
|
+
const readOps = ["getById", "getByQuery", "getAll", "aggregatePaginate", "lookupPopulate"];
|
|
1122
|
+
const writeOps = ["create", "createMany", "update", "delete"];
|
|
1123
|
+
const allOps = [...readOps, ...writeOps];
|
|
1124
|
+
return {
|
|
1125
|
+
name: "multi-tenant",
|
|
1126
|
+
apply(repo) {
|
|
1127
|
+
for (const op of allOps) {
|
|
1128
|
+
if (skipOperations.includes(op)) continue;
|
|
1129
|
+
repo.on(`before:${op}`, (context) => {
|
|
1130
|
+
if (skipWhen?.(context, op)) return;
|
|
1131
|
+
let tenantId = context[contextKey];
|
|
1132
|
+
if (!tenantId && resolveContext) {
|
|
1133
|
+
tenantId = resolveContext();
|
|
1134
|
+
if (tenantId) context[contextKey] = tenantId;
|
|
1135
|
+
}
|
|
1136
|
+
if (!tenantId && required) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (!tenantId) return;
|
|
1142
|
+
if (readOps.includes(op)) {
|
|
1143
|
+
if (op === "getAll" || op === "aggregatePaginate" || op === "lookupPopulate") {
|
|
1144
|
+
context.filters = { ...context.filters, [tenantField]: tenantId };
|
|
1145
|
+
} else {
|
|
1146
|
+
context.query = { ...context.query, [tenantField]: tenantId };
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (op === "create" && context.data) {
|
|
1150
|
+
context.data[tenantField] = tenantId;
|
|
1151
|
+
}
|
|
1152
|
+
if (op === "createMany" && context.dataArray) {
|
|
1153
|
+
for (const doc of context.dataArray) {
|
|
1154
|
+
doc[tenantField] = tenantId;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (op === "update" || op === "delete") {
|
|
1158
|
+
context.query = { ...context.query, [tenantField]: tenantId };
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// src/plugins/observability.plugin.ts
|
|
1167
|
+
var DEFAULT_OPS = [
|
|
1168
|
+
"create",
|
|
1169
|
+
"createMany",
|
|
1170
|
+
"update",
|
|
1171
|
+
"delete",
|
|
1172
|
+
"getById",
|
|
1173
|
+
"getByQuery",
|
|
1174
|
+
"getAll",
|
|
1175
|
+
"aggregatePaginate",
|
|
1176
|
+
"lookupPopulate"
|
|
1177
|
+
];
|
|
1178
|
+
var timers = /* @__PURE__ */ new WeakMap();
|
|
1179
|
+
function observabilityPlugin(options) {
|
|
1180
|
+
const { onMetric, slowThresholdMs } = options;
|
|
1181
|
+
const ops = options.operations ?? DEFAULT_OPS;
|
|
1182
|
+
return {
|
|
1183
|
+
name: "observability",
|
|
1184
|
+
apply(repo) {
|
|
1185
|
+
for (const op of ops) {
|
|
1186
|
+
repo.on(`before:${op}`, (context) => {
|
|
1187
|
+
timers.set(context, performance.now());
|
|
1188
|
+
});
|
|
1189
|
+
repo.on(`after:${op}`, ({ context }) => {
|
|
1190
|
+
const start = timers.get(context);
|
|
1191
|
+
if (start == null) return;
|
|
1192
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
1193
|
+
timers.delete(context);
|
|
1194
|
+
if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
|
|
1195
|
+
onMetric({
|
|
1196
|
+
operation: op,
|
|
1197
|
+
model: context.model || repo.model,
|
|
1198
|
+
durationMs,
|
|
1199
|
+
success: true,
|
|
1200
|
+
startedAt: new Date(Date.now() - durationMs),
|
|
1201
|
+
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
1202
|
+
organizationId: context.organizationId?.toString()
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
repo.on(`error:${op}`, ({ context, error }) => {
|
|
1206
|
+
const start = timers.get(context);
|
|
1207
|
+
if (start == null) return;
|
|
1208
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
1209
|
+
timers.delete(context);
|
|
1210
|
+
onMetric({
|
|
1211
|
+
operation: op,
|
|
1212
|
+
model: context.model || repo.model,
|
|
1213
|
+
durationMs,
|
|
1214
|
+
success: false,
|
|
1215
|
+
error: error.message,
|
|
1216
|
+
startedAt: new Date(Date.now() - durationMs),
|
|
1217
|
+
userId: context.user?._id?.toString() || context.user?.id?.toString(),
|
|
1218
|
+
organizationId: context.organizationId?.toString()
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export { aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, fieldFilterPlugin, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
|
|
@@ -3,34 +3,41 @@ import 'mongoose';
|
|
|
3
3
|
// src/utils/memory-cache.ts
|
|
4
4
|
function createMemoryCache(maxEntries = 1e3) {
|
|
5
5
|
const cache = /* @__PURE__ */ new Map();
|
|
6
|
-
|
|
6
|
+
let lastCleanup = Date.now();
|
|
7
|
+
const CLEANUP_INTERVAL_MS = 6e4;
|
|
8
|
+
function cleanupIfNeeded() {
|
|
7
9
|
const now = Date.now();
|
|
10
|
+
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
|
11
|
+
lastCleanup = now;
|
|
8
12
|
for (const [key, entry] of cache) {
|
|
9
|
-
if (entry.expiresAt < now)
|
|
10
|
-
cache.delete(key);
|
|
11
|
-
}
|
|
13
|
+
if (entry.expiresAt < now) cache.delete(key);
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
function evictOldest() {
|
|
15
|
-
|
|
17
|
+
while (cache.size >= maxEntries) {
|
|
16
18
|
const firstKey = cache.keys().next().value;
|
|
17
19
|
if (firstKey) cache.delete(firstKey);
|
|
20
|
+
else break;
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
return {
|
|
21
24
|
async get(key) {
|
|
22
|
-
cleanup();
|
|
23
25
|
const entry = cache.get(key);
|
|
24
26
|
if (!entry) return null;
|
|
25
27
|
if (entry.expiresAt < Date.now()) {
|
|
26
28
|
cache.delete(key);
|
|
27
29
|
return null;
|
|
28
30
|
}
|
|
31
|
+
cache.delete(key);
|
|
32
|
+
cache.set(key, entry);
|
|
29
33
|
return entry.value;
|
|
30
34
|
},
|
|
31
35
|
async set(key, value, ttl) {
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
cache.delete(key);
|
|
37
|
+
if (cache.size >= maxEntries) {
|
|
38
|
+
cleanupIfNeeded();
|
|
39
|
+
evictOldest();
|
|
40
|
+
}
|
|
34
41
|
cache.set(key, {
|
|
35
42
|
value,
|
|
36
43
|
expiresAt: Date.now() + ttl * 1e3
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/utils/logger.ts
|
|
2
|
+
var noop = () => {
|
|
3
|
+
};
|
|
4
|
+
var current = {
|
|
5
|
+
warn: console.warn.bind(console),
|
|
6
|
+
debug: noop
|
|
7
|
+
};
|
|
8
|
+
function configureLogger(config) {
|
|
9
|
+
if (config === false) {
|
|
10
|
+
current = { warn: noop, debug: noop };
|
|
11
|
+
} else {
|
|
12
|
+
current = { ...current, ...config };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function warn(message, ...args) {
|
|
16
|
+
current.warn(message, ...args);
|
|
17
|
+
}
|
|
18
|
+
function debug(message, ...args) {
|
|
19
|
+
current.debug(message, ...args);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { configureLogger, debug, warn };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { create_exports } from './chunk-
|
|
2
|
-
import {
|
|
1
|
+
import { create_exports } from './chunk-I7CWNAJB.js';
|
|
2
|
+
import { warn } from './chunk-URLJFIR7.js';
|
|
3
|
+
import { createError } from './chunk-JWUAVZ3L.js';
|
|
4
|
+
import { __export } from './chunk-WSFCRVEQ.js';
|
|
3
5
|
|
|
4
6
|
// src/actions/index.ts
|
|
5
7
|
var actions_exports = {};
|
|
@@ -122,7 +124,8 @@ function parsePopulate2(populate) {
|
|
|
122
124
|
}
|
|
123
125
|
async function update(Model, id, data, options = {}) {
|
|
124
126
|
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
125
|
-
const
|
|
127
|
+
const query = { _id: id, ...options.query };
|
|
128
|
+
const document = await Model.findOneAndUpdate(query, data, {
|
|
126
129
|
new: true,
|
|
127
130
|
runValidators: true,
|
|
128
131
|
session: options.session,
|
|
@@ -225,7 +228,8 @@ __export(delete_exports, {
|
|
|
225
228
|
softDelete: () => softDelete
|
|
226
229
|
});
|
|
227
230
|
async function deleteById(Model, id, options = {}) {
|
|
228
|
-
const
|
|
231
|
+
const query = { _id: id, ...options.query };
|
|
232
|
+
const document = await Model.findOneAndDelete(query).session(options.session ?? null);
|
|
229
233
|
if (!document) {
|
|
230
234
|
throw createError(404, "Document not found");
|
|
231
235
|
}
|
|
@@ -292,6 +296,268 @@ __export(aggregate_exports, {
|
|
|
292
296
|
sum: () => sum,
|
|
293
297
|
unwind: () => unwind
|
|
294
298
|
});
|
|
299
|
+
|
|
300
|
+
// src/query/LookupBuilder.ts
|
|
301
|
+
var BLOCKED_PIPELINE_STAGES = ["$out", "$merge", "$unionWith", "$collStats", "$currentOp", "$listSessions"];
|
|
302
|
+
var DANGEROUS_OPERATORS = ["$where", "$function", "$accumulator", "$expr"];
|
|
303
|
+
var LookupBuilder = class _LookupBuilder {
|
|
304
|
+
options = {};
|
|
305
|
+
constructor(from) {
|
|
306
|
+
if (from) this.options.from = from;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Set the collection to join with
|
|
310
|
+
*/
|
|
311
|
+
from(collection) {
|
|
312
|
+
this.options.from = collection;
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Set the local field (source collection)
|
|
317
|
+
* IMPORTANT: This field should be indexed for optimal performance
|
|
318
|
+
*/
|
|
319
|
+
localField(field) {
|
|
320
|
+
this.options.localField = field;
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Set the foreign field (target collection)
|
|
325
|
+
* IMPORTANT: This field should be indexed (preferably unique) for optimal performance
|
|
326
|
+
*/
|
|
327
|
+
foreignField(field) {
|
|
328
|
+
this.options.foreignField = field;
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Set the output field name
|
|
333
|
+
* Defaults to the collection name if not specified
|
|
334
|
+
*/
|
|
335
|
+
as(fieldName) {
|
|
336
|
+
this.options.as = fieldName;
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Mark this lookup as returning a single document
|
|
341
|
+
* Automatically unwraps the array result to a single object or null
|
|
342
|
+
*/
|
|
343
|
+
single(isSingle = true) {
|
|
344
|
+
this.options.single = isSingle;
|
|
345
|
+
return this;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Add a pipeline to filter/transform joined documents
|
|
349
|
+
* Useful for filtering, sorting, or limiting joined results
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```typescript
|
|
353
|
+
* lookup.pipeline([
|
|
354
|
+
* { $match: { status: 'active' } },
|
|
355
|
+
* { $sort: { priority: -1 } },
|
|
356
|
+
* { $limit: 5 }
|
|
357
|
+
* ]);
|
|
358
|
+
* ```
|
|
359
|
+
*/
|
|
360
|
+
pipeline(stages) {
|
|
361
|
+
this.options.pipeline = stages;
|
|
362
|
+
return this;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Set let variables for use in pipeline
|
|
366
|
+
* Allows referencing local document fields in the pipeline
|
|
367
|
+
*/
|
|
368
|
+
let(variables) {
|
|
369
|
+
this.options.let = variables;
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Build the $lookup aggregation stage(s)
|
|
374
|
+
* Returns an array of pipeline stages including $lookup and optional $unwind
|
|
375
|
+
*
|
|
376
|
+
* IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
|
|
377
|
+
* 1. Simple form: { from, localField, foreignField, as }
|
|
378
|
+
* 2. Pipeline form: { from, let, pipeline, as }
|
|
379
|
+
*
|
|
380
|
+
* When pipeline or let is specified, we use the pipeline form.
|
|
381
|
+
* Otherwise, we use the simpler localField/foreignField form.
|
|
382
|
+
*/
|
|
383
|
+
build() {
|
|
384
|
+
const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
|
|
385
|
+
if (!from) {
|
|
386
|
+
throw new Error('LookupBuilder: "from" collection is required');
|
|
387
|
+
}
|
|
388
|
+
const outputField = as || from;
|
|
389
|
+
const stages = [];
|
|
390
|
+
const usePipelineForm = pipeline || letVars;
|
|
391
|
+
let lookupStage;
|
|
392
|
+
if (usePipelineForm) {
|
|
393
|
+
if (!pipeline || pipeline.length === 0) {
|
|
394
|
+
if (!localField || !foreignField) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
"LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline"
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const autoPipeline = [
|
|
400
|
+
{
|
|
401
|
+
$match: {
|
|
402
|
+
$expr: {
|
|
403
|
+
$eq: [`$${foreignField}`, `$$${localField}`]
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
];
|
|
408
|
+
lookupStage = {
|
|
409
|
+
$lookup: {
|
|
410
|
+
from,
|
|
411
|
+
let: { [localField]: `$${localField}`, ...letVars || {} },
|
|
412
|
+
pipeline: autoPipeline,
|
|
413
|
+
as: outputField
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
} else {
|
|
417
|
+
const safePipeline = this.options.sanitize !== false ? _LookupBuilder.sanitizePipeline(pipeline) : pipeline;
|
|
418
|
+
lookupStage = {
|
|
419
|
+
$lookup: {
|
|
420
|
+
from,
|
|
421
|
+
...letVars && { let: letVars },
|
|
422
|
+
pipeline: safePipeline,
|
|
423
|
+
as: outputField
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
if (!localField || !foreignField) {
|
|
429
|
+
throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
|
|
430
|
+
}
|
|
431
|
+
lookupStage = {
|
|
432
|
+
$lookup: {
|
|
433
|
+
from,
|
|
434
|
+
localField,
|
|
435
|
+
foreignField,
|
|
436
|
+
as: outputField
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
stages.push(lookupStage);
|
|
441
|
+
if (single) {
|
|
442
|
+
stages.push({
|
|
443
|
+
$unwind: {
|
|
444
|
+
path: `$${outputField}`,
|
|
445
|
+
preserveNullAndEmptyArrays: true
|
|
446
|
+
// Keep documents even if no match found
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return stages;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Build and return only the $lookup stage (without $unwind)
|
|
454
|
+
* Useful when you want to handle unwrapping yourself
|
|
455
|
+
*/
|
|
456
|
+
buildLookupOnly() {
|
|
457
|
+
const stages = this.build();
|
|
458
|
+
return stages[0];
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Static helper: Create a simple lookup in one line
|
|
462
|
+
*/
|
|
463
|
+
static simple(from, localField, foreignField, options = {}) {
|
|
464
|
+
return new _LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Static helper: Create multiple lookups at once
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```typescript
|
|
471
|
+
* const pipeline = LookupBuilder.multiple([
|
|
472
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
473
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
474
|
+
* ]);
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
static multiple(lookups) {
|
|
478
|
+
return lookups.flatMap((lookup2) => {
|
|
479
|
+
const builder = new _LookupBuilder(lookup2.from).localField(lookup2.localField).foreignField(lookup2.foreignField);
|
|
480
|
+
if (lookup2.as) builder.as(lookup2.as);
|
|
481
|
+
if (lookup2.single) builder.single(lookup2.single);
|
|
482
|
+
if (lookup2.pipeline) builder.pipeline(lookup2.pipeline);
|
|
483
|
+
if (lookup2.let) builder.let(lookup2.let);
|
|
484
|
+
return builder.build();
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Static helper: Create a nested lookup (lookup within lookup)
|
|
489
|
+
* Useful for multi-level joins like Order -> Product -> Category
|
|
490
|
+
*
|
|
491
|
+
* @example
|
|
492
|
+
* ```typescript
|
|
493
|
+
* // Join orders with products, then products with categories
|
|
494
|
+
* const pipeline = LookupBuilder.nested([
|
|
495
|
+
* { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
|
|
496
|
+
* { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
|
|
497
|
+
* ]);
|
|
498
|
+
* ```
|
|
499
|
+
*/
|
|
500
|
+
static nested(lookups) {
|
|
501
|
+
return lookups.flatMap((lookup2, index) => {
|
|
502
|
+
const builder = new _LookupBuilder(lookup2.from).localField(lookup2.localField).foreignField(lookup2.foreignField);
|
|
503
|
+
if (lookup2.as) builder.as(lookup2.as);
|
|
504
|
+
if (lookup2.single !== void 0) builder.single(lookup2.single);
|
|
505
|
+
if (lookup2.pipeline) builder.pipeline(lookup2.pipeline);
|
|
506
|
+
if (lookup2.let) builder.let(lookup2.let);
|
|
507
|
+
return builder.build();
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Sanitize pipeline stages by blocking dangerous stages and operators.
|
|
512
|
+
* Used internally by build() and available for external use (e.g., aggregate.ts).
|
|
513
|
+
*/
|
|
514
|
+
static sanitizePipeline(stages) {
|
|
515
|
+
const sanitized = [];
|
|
516
|
+
for (const stage of stages) {
|
|
517
|
+
if (!stage || typeof stage !== "object") continue;
|
|
518
|
+
const entries = Object.entries(stage);
|
|
519
|
+
if (entries.length !== 1) continue;
|
|
520
|
+
const [op, config] = entries[0];
|
|
521
|
+
if (BLOCKED_PIPELINE_STAGES.includes(op)) {
|
|
522
|
+
warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if ((op === "$match" || op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) {
|
|
526
|
+
sanitized.push({ [op]: _LookupBuilder._sanitizeDeep(config) });
|
|
527
|
+
} else {
|
|
528
|
+
sanitized.push(stage);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return sanitized;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Recursively remove dangerous operators from an expression object.
|
|
535
|
+
*/
|
|
536
|
+
static _sanitizeDeep(config) {
|
|
537
|
+
const sanitized = {};
|
|
538
|
+
for (const [key, value] of Object.entries(config)) {
|
|
539
|
+
if (DANGEROUS_OPERATORS.includes(key)) {
|
|
540
|
+
warn(`[mongokit] Blocked dangerous operator in lookup pipeline: ${key}`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
544
|
+
sanitized[key] = _LookupBuilder._sanitizeDeep(value);
|
|
545
|
+
} else if (Array.isArray(value)) {
|
|
546
|
+
sanitized[key] = value.map((item) => {
|
|
547
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
548
|
+
return _LookupBuilder._sanitizeDeep(item);
|
|
549
|
+
}
|
|
550
|
+
return item;
|
|
551
|
+
});
|
|
552
|
+
} else {
|
|
553
|
+
sanitized[key] = value;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return sanitized;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/actions/aggregate.ts
|
|
295
561
|
async function aggregate(Model, pipeline, options = {}) {
|
|
296
562
|
const aggregation = Model.aggregate(pipeline);
|
|
297
563
|
if (options.session) {
|
|
@@ -305,7 +571,7 @@ async function aggregatePaginate(Model, pipeline, options = {}) {
|
|
|
305
571
|
const skip = (page - 1) * limit;
|
|
306
572
|
const SAFE_LIMIT = 1e3;
|
|
307
573
|
if (limit > SAFE_LIMIT) {
|
|
308
|
-
|
|
574
|
+
warn(
|
|
309
575
|
`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`
|
|
310
576
|
);
|
|
311
577
|
}
|
|
@@ -384,11 +650,12 @@ async function lookup(Model, lookupOptions) {
|
|
|
384
650
|
}
|
|
385
651
|
});
|
|
386
652
|
} else {
|
|
653
|
+
const safePipeline = lookupOptions.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
|
|
387
654
|
aggPipeline.push({
|
|
388
655
|
$lookup: {
|
|
389
656
|
from,
|
|
390
657
|
...letVars && { let: letVars },
|
|
391
|
-
pipeline,
|
|
658
|
+
pipeline: safePipeline,
|
|
392
659
|
as
|
|
393
660
|
}
|
|
394
661
|
});
|
|
@@ -467,4 +734,4 @@ async function minMax(Model, field, query = {}, options = {}) {
|
|
|
467
734
|
return result[0] || { min: null, max: null };
|
|
468
735
|
}
|
|
469
736
|
|
|
470
|
-
export { actions_exports, aggregate, aggregate_exports, count, deleteById, delete_exports, distinct, exists, getById, getByQuery, getOrCreate, read_exports, update, update_exports };
|
|
737
|
+
export { LookupBuilder, actions_exports, aggregate, aggregate_exports, count, deleteById, delete_exports, distinct, exists, getById, getByQuery, getOrCreate, read_exports, update, update_exports };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PipelineStage, ClientSession, Model } from 'mongoose';
|
|
2
|
-
import { A as AnyDocument,
|
|
2
|
+
import { A as AnyDocument, v as CreateOptions, j as ObjectId, u as OperationOptions, S as SelectSpec, e as PopulateSpec, f as SortSpec, U as UpdateOptions, y as UpdateWithValidationResult, x as UpdateManyResult, w as DeleteResult, a5 as GroupResult, a6 as MinMaxResult } from './types-Jni1KgkP.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* LookupBuilder - MongoDB $lookup Utility
|
|
@@ -59,6 +59,8 @@ interface LookupOptions {
|
|
|
59
59
|
options?: {
|
|
60
60
|
session?: ClientSession;
|
|
61
61
|
};
|
|
62
|
+
/** Sanitize pipeline stages (default: true). Set false only for trusted server-side pipelines */
|
|
63
|
+
sanitize?: boolean;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* Fluent builder for MongoDB $lookup aggregation stage
|
|
@@ -160,6 +162,15 @@ declare class LookupBuilder {
|
|
|
160
162
|
* ```
|
|
161
163
|
*/
|
|
162
164
|
static nested(lookups: LookupOptions[]): PipelineStage[];
|
|
165
|
+
/**
|
|
166
|
+
* Sanitize pipeline stages by blocking dangerous stages and operators.
|
|
167
|
+
* Used internally by build() and available for external use (e.g., aggregate.ts).
|
|
168
|
+
*/
|
|
169
|
+
static sanitizePipeline(stages: PipelineStage[]): PipelineStage[];
|
|
170
|
+
/**
|
|
171
|
+
* Recursively remove dangerous operators from an expression object.
|
|
172
|
+
*/
|
|
173
|
+
private static _sanitizeDeep;
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
/**
|
|
@@ -349,6 +360,7 @@ declare namespace update$1 {
|
|
|
349
360
|
*/
|
|
350
361
|
declare function deleteById(Model: Model<any>, id: string | ObjectId, options?: {
|
|
351
362
|
session?: ClientSession;
|
|
363
|
+
query?: Record<string, unknown>;
|
|
352
364
|
}): Promise<DeleteResult>;
|
|
353
365
|
/**
|
|
354
366
|
* Delete many documents
|