@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.
@@ -1,327 +1,488 @@
1
- import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
2
- import { i as createError, r as warn } from "./logger-D8ily-PP.mjs";
3
-
4
- //#region src/actions/create.ts
5
- var create_exports = /* @__PURE__ */ __exportAll({
6
- create: () => create,
7
- createDefault: () => createDefault,
8
- createMany: () => createMany,
9
- upsert: () => upsert
10
- });
11
- /**
12
- * Create single document
13
- */
14
- async function create(Model, data, options = {}) {
15
- const document = new Model(data);
16
- await document.save({ session: options.session });
17
- return document;
18
- }
19
- /**
20
- * Create multiple documents
21
- */
22
- async function createMany(Model, dataArray, options = {}) {
23
- return Model.insertMany(dataArray, {
24
- session: options.session,
25
- ordered: options.ordered !== false
26
- });
27
- }
28
- /**
29
- * Create with defaults (useful for initialization)
30
- */
31
- async function createDefault(Model, overrides = {}, options = {}) {
32
- const defaults = {};
33
- Model.schema.eachPath((path, schemaType) => {
34
- const schemaOptions = schemaType.options;
35
- if (schemaOptions.default !== void 0 && path !== "_id") defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
36
- });
37
- return create(Model, {
38
- ...defaults,
39
- ...overrides
40
- }, options);
41
- }
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { a as warn, t as createError } from "./error-Bpbi_NKo.mjs";
3
+ //#region src/query/LookupBuilder.ts
4
+ /** Stages that are never valid inside a $lookup pipeline */
5
+ const BLOCKED_PIPELINE_STAGES = [
6
+ "$out",
7
+ "$merge",
8
+ "$unionWith",
9
+ "$collStats",
10
+ "$currentOp",
11
+ "$listSessions"
12
+ ];
13
+ /** Operators that can enable arbitrary code execution */
14
+ const DANGEROUS_OPERATORS = [
15
+ "$where",
16
+ "$function",
17
+ "$accumulator",
18
+ "$expr"
19
+ ];
42
20
  /**
43
- * Upsert (create or update)
21
+ * Fluent builder for MongoDB $lookup aggregation stage
22
+ * Optimized for custom field joins at scale
44
23
  */
45
- async function upsert(Model, query, data, options = {}) {
46
- return Model.findOneAndUpdate(query, { $setOnInsert: data }, {
47
- upsert: true,
48
- returnDocument: "after",
49
- runValidators: true,
50
- session: options.session,
51
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
52
- });
53
- }
54
-
55
- //#endregion
56
- //#region src/actions/read.ts
57
- var read_exports = /* @__PURE__ */ __exportAll({
58
- count: () => count,
59
- exists: () => exists,
60
- getAll: () => getAll,
61
- getById: () => getById,
62
- getByQuery: () => getByQuery,
63
- getOrCreate: () => getOrCreate,
64
- tryGetByQuery: () => tryGetByQuery
24
+ var LookupBuilder = class LookupBuilder {
25
+ options = {};
26
+ constructor(from) {
27
+ if (from) this.options.from = from;
28
+ }
29
+ /**
30
+ * Set the collection to join with
31
+ */
32
+ from(collection) {
33
+ this.options.from = collection;
34
+ return this;
35
+ }
36
+ /**
37
+ * Set the local field (source collection)
38
+ * IMPORTANT: This field should be indexed for optimal performance
39
+ */
40
+ localField(field) {
41
+ this.options.localField = field;
42
+ return this;
43
+ }
44
+ /**
45
+ * Set the foreign field (target collection)
46
+ * IMPORTANT: This field should be indexed (preferably unique) for optimal performance
47
+ */
48
+ foreignField(field) {
49
+ this.options.foreignField = field;
50
+ return this;
51
+ }
52
+ /**
53
+ * Set the output field name
54
+ * Defaults to the collection name if not specified
55
+ */
56
+ as(fieldName) {
57
+ this.options.as = fieldName;
58
+ return this;
59
+ }
60
+ /**
61
+ * Mark this lookup as returning a single document
62
+ * Automatically unwraps the array result to a single object or null
63
+ */
64
+ single(isSingle = true) {
65
+ this.options.single = isSingle;
66
+ return this;
67
+ }
68
+ /**
69
+ * Add a pipeline to filter/transform joined documents
70
+ * Useful for filtering, sorting, or limiting joined results
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * lookup.pipeline([
75
+ * { $match: { status: 'active' } },
76
+ * { $sort: { priority: -1 } },
77
+ * { $limit: 5 }
78
+ * ]);
79
+ * ```
80
+ */
81
+ pipeline(stages) {
82
+ this.options.pipeline = stages;
83
+ return this;
84
+ }
85
+ /**
86
+ * Set let variables for use in pipeline
87
+ * Allows referencing local document fields in the pipeline
88
+ */
89
+ let(variables) {
90
+ this.options.let = variables;
91
+ return this;
92
+ }
93
+ /**
94
+ * Build the $lookup aggregation stage(s)
95
+ * Returns an array of pipeline stages including $lookup and optional $unwind
96
+ *
97
+ * IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
98
+ * 1. Simple form: { from, localField, foreignField, as }
99
+ * 2. Pipeline form: { from, let, pipeline, as }
100
+ *
101
+ * When pipeline or let is specified, we use the pipeline form.
102
+ * Otherwise, we use the simpler localField/foreignField form.
103
+ */
104
+ build() {
105
+ const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
106
+ if (!from) throw new Error("LookupBuilder: \"from\" collection is required");
107
+ const outputField = as || from;
108
+ const stages = [];
109
+ const usePipelineForm = pipeline || letVars;
110
+ let lookupStage;
111
+ if (usePipelineForm) if (!pipeline || pipeline.length === 0) {
112
+ if (!localField || !foreignField) throw new Error("LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline");
113
+ const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
114
+ lookupStage = { $lookup: {
115
+ from,
116
+ let: {
117
+ [localField]: `$${localField}`,
118
+ ...letVars || {}
119
+ },
120
+ pipeline: autoPipeline,
121
+ as: outputField
122
+ } };
123
+ } else {
124
+ const safePipeline = this.options.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
125
+ lookupStage = { $lookup: {
126
+ from,
127
+ ...letVars && { let: letVars },
128
+ pipeline: safePipeline,
129
+ as: outputField
130
+ } };
131
+ }
132
+ else {
133
+ if (!localField || !foreignField) throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
134
+ lookupStage = { $lookup: {
135
+ from,
136
+ localField,
137
+ foreignField,
138
+ as: outputField
139
+ } };
140
+ }
141
+ stages.push(lookupStage);
142
+ if (single) stages.push({ $unwind: {
143
+ path: `$${outputField}`,
144
+ preserveNullAndEmptyArrays: true
145
+ } });
146
+ return stages;
147
+ }
148
+ /**
149
+ * Build and return only the $lookup stage (without $unwind)
150
+ * Useful when you want to handle unwrapping yourself
151
+ */
152
+ buildLookupOnly() {
153
+ return this.build()[0];
154
+ }
155
+ /**
156
+ * Static helper: Create a simple lookup in one line
157
+ */
158
+ static simple(from, localField, foreignField, options = {}) {
159
+ return new LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
160
+ }
161
+ /**
162
+ * Static helper: Create multiple lookups at once
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const pipeline = LookupBuilder.multiple([
167
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
168
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
169
+ * ]);
170
+ * ```
171
+ */
172
+ static multiple(lookups) {
173
+ return lookups.flatMap((lookup) => {
174
+ const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
175
+ if (lookup.as) builder.as(lookup.as);
176
+ if (lookup.single) builder.single(lookup.single);
177
+ if (lookup.select) {
178
+ let projection;
179
+ if (typeof lookup.select === "string") {
180
+ projection = {};
181
+ for (const field of lookup.select.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
182
+ else projection[field] = 1;
183
+ } else projection = lookup.select;
184
+ const selectPipeline = [{ $project: projection }];
185
+ const existing = lookup.pipeline || [];
186
+ builder.pipeline([...existing, ...selectPipeline]);
187
+ } else if (lookup.pipeline) builder.pipeline(lookup.pipeline);
188
+ if (lookup.let) builder.let(lookup.let);
189
+ return builder.build();
190
+ });
191
+ }
192
+ /**
193
+ * Static helper: Create a nested lookup (lookup within lookup)
194
+ * Useful for multi-level joins like Order -> Product -> Category
195
+ *
196
+ * @example
197
+ * ```typescript
198
+ * // Join orders with products, then products with categories
199
+ * const pipeline = LookupBuilder.nested([
200
+ * { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
201
+ * { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
202
+ * ]);
203
+ * ```
204
+ */
205
+ static nested(lookups) {
206
+ return lookups.flatMap((lookup, _index) => {
207
+ const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
208
+ if (lookup.as) builder.as(lookup.as);
209
+ if (lookup.single !== void 0) builder.single(lookup.single);
210
+ if (lookup.pipeline) builder.pipeline(lookup.pipeline);
211
+ if (lookup.let) builder.let(lookup.let);
212
+ return builder.build();
213
+ });
214
+ }
215
+ /**
216
+ * Sanitize pipeline stages by blocking dangerous stages and operators.
217
+ * Used internally by build() and available for external use (e.g., aggregate.ts).
218
+ */
219
+ static sanitizePipeline(stages) {
220
+ const sanitized = [];
221
+ for (const stage of stages) {
222
+ if (!stage || typeof stage !== "object") continue;
223
+ const entries = Object.entries(stage);
224
+ if (entries.length !== 1) continue;
225
+ const [op, config] = entries[0];
226
+ if (BLOCKED_PIPELINE_STAGES.includes(op)) {
227
+ warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
228
+ continue;
229
+ }
230
+ if ((op === "$match" || op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: LookupBuilder._sanitizeDeep(config) });
231
+ else sanitized.push(stage);
232
+ }
233
+ return sanitized;
234
+ }
235
+ /**
236
+ * Recursively remove dangerous operators from an expression object.
237
+ */
238
+ static _sanitizeDeep(config) {
239
+ const sanitized = {};
240
+ for (const [key, value] of Object.entries(config)) {
241
+ if (DANGEROUS_OPERATORS.includes(key)) {
242
+ warn(`[mongokit] Blocked dangerous operator in lookup pipeline: ${key}`);
243
+ continue;
244
+ }
245
+ if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = LookupBuilder._sanitizeDeep(value);
246
+ else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
247
+ if (item && typeof item === "object" && !Array.isArray(item)) return LookupBuilder._sanitizeDeep(item);
248
+ return item;
249
+ });
250
+ else sanitized[key] = value;
251
+ }
252
+ return sanitized;
253
+ }
254
+ };
255
+ //#endregion
256
+ //#region src/actions/aggregate.ts
257
+ var aggregate_exports = /* @__PURE__ */ __exportAll({
258
+ aggregate: () => aggregate,
259
+ aggregatePaginate: () => aggregatePaginate,
260
+ average: () => average,
261
+ countBy: () => countBy,
262
+ distinct: () => distinct,
263
+ facet: () => facet,
264
+ groupBy: () => groupBy,
265
+ lookup: () => lookup,
266
+ minMax: () => minMax,
267
+ sum: () => sum,
268
+ unwind: () => unwind
65
269
  });
66
270
  /**
67
- * Parse populate specification into consistent format
68
- */
69
- function parsePopulate$1(populate) {
70
- if (!populate) return [];
71
- if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
72
- if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
73
- return [populate];
74
- }
75
- /**
76
- * Get document by ID
77
- *
78
- * @param Model - Mongoose model
79
- * @param id - Document ID
80
- * @param options - Query options
81
- * @returns Document or null
82
- * @throws Error if document not found and throwOnNotFound is true
83
- */
84
- async function getById(Model, id, options = {}) {
85
- const query = options.query ? Model.findOne({
86
- _id: id,
87
- ...options.query
88
- }) : Model.findById(id);
89
- if (options.select) query.select(options.select);
90
- if (options.populate) query.populate(parsePopulate$1(options.populate));
91
- if (options.lean) query.lean();
92
- if (options.session) query.session(options.session);
93
- if (options.readPreference) query.read(options.readPreference);
94
- const document = await query.exec();
95
- if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
96
- return document;
97
- }
98
- /**
99
- * Get document by query
100
- *
101
- * @param Model - Mongoose model
102
- * @param query - MongoDB query
103
- * @param options - Query options
104
- * @returns Document or null
105
- * @throws Error if document not found and throwOnNotFound is true
106
- */
107
- async function getByQuery(Model, query, options = {}) {
108
- const mongoQuery = Model.findOne(query);
109
- if (options.select) mongoQuery.select(options.select);
110
- if (options.populate) mongoQuery.populate(parsePopulate$1(options.populate));
111
- if (options.lean) mongoQuery.lean();
112
- if (options.session) mongoQuery.session(options.session);
113
- if (options.readPreference) mongoQuery.read(options.readPreference);
114
- const document = await mongoQuery.exec();
115
- if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
116
- return document;
117
- }
118
- /**
119
- * Get document by query without throwing (returns null if not found)
271
+ * Execute aggregation pipeline
120
272
  */
121
- async function tryGetByQuery(Model, query, options = {}) {
122
- return getByQuery(Model, query, {
123
- ...options,
124
- throwOnNotFound: false
125
- });
273
+ async function aggregate(Model, pipeline, options = {}) {
274
+ const aggregation = Model.aggregate(pipeline);
275
+ if (options.session) aggregation.session(options.session);
276
+ return aggregation.exec();
126
277
  }
127
278
  /**
128
- * Get all documents (basic query without pagination)
129
- * For pagination, use Repository.paginate() or Repository.stream()
279
+ * Aggregate with pagination using native MongoDB $facet
280
+ * WARNING: $facet results must be <16MB. For larger results (limit >1000),
281
+ * consider using Repository.aggregatePaginate() or splitting into separate queries.
130
282
  */
131
- async function getAll(Model, query = {}, options = {}) {
132
- let mongoQuery = Model.find(query);
133
- if (options.select) mongoQuery = mongoQuery.select(options.select);
134
- if (options.populate) mongoQuery = mongoQuery.populate(parsePopulate$1(options.populate));
135
- if (options.sort) mongoQuery = mongoQuery.sort(options.sort);
136
- if (options.limit) mongoQuery = mongoQuery.limit(options.limit);
137
- if (options.skip) mongoQuery = mongoQuery.skip(options.skip);
138
- mongoQuery = mongoQuery.lean(options.lean !== false);
139
- if (options.session) mongoQuery = mongoQuery.session(options.session);
140
- if (options.readPreference) mongoQuery = mongoQuery.read(options.readPreference);
141
- return mongoQuery.exec();
283
+ async function aggregatePaginate(Model, pipeline, options = {}) {
284
+ const page = parseInt(String(options.page || 1), 10);
285
+ const limit = parseInt(String(options.limit || 10), 10);
286
+ const skip = (page - 1) * limit;
287
+ if (limit > 1e3) warn(`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`);
288
+ const facetPipeline = [...pipeline, { $facet: {
289
+ docs: [{ $skip: skip }, { $limit: limit }],
290
+ total: [{ $count: "count" }]
291
+ } }];
292
+ const aggregation = Model.aggregate(facetPipeline);
293
+ if (options.session) aggregation.session(options.session);
294
+ const [result] = await aggregation.exec();
295
+ const docs = result.docs || [];
296
+ const total = result.total[0]?.count || 0;
297
+ const pages = Math.ceil(total / limit);
298
+ return {
299
+ docs,
300
+ total,
301
+ page,
302
+ limit,
303
+ pages,
304
+ hasNext: page < pages,
305
+ hasPrev: page > 1
306
+ };
142
307
  }
143
308
  /**
144
- * Get or create document (upsert)
309
+ * Group documents by field value
145
310
  */
146
- async function getOrCreate(Model, query, createData, options = {}) {
147
- return Model.findOneAndUpdate(query, { $setOnInsert: createData }, {
148
- upsert: true,
149
- returnDocument: "after",
150
- runValidators: true,
151
- session: options.session,
152
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
153
- });
311
+ async function groupBy(Model, field, options = {}) {
312
+ const pipeline = [{ $group: {
313
+ _id: `$${field}`,
314
+ count: { $sum: 1 }
315
+ } }, { $sort: { count: -1 } }];
316
+ if (options.limit) pipeline.push({ $limit: options.limit });
317
+ return aggregate(Model, pipeline, options);
154
318
  }
155
319
  /**
156
- * Count documents matching query
320
+ * Count by field values
157
321
  */
158
- async function count(Model, query = {}, options = {}) {
159
- const q = Model.countDocuments(query).session(options.session ?? null);
160
- if (options.readPreference) q.read(options.readPreference);
161
- return q;
322
+ async function countBy(Model, field, query = {}, options = {}) {
323
+ const pipeline = [];
324
+ if (Object.keys(query).length > 0) pipeline.push({ $match: query });
325
+ pipeline.push({ $group: {
326
+ _id: `$${field}`,
327
+ count: { $sum: 1 }
328
+ } }, { $sort: { count: -1 } });
329
+ return aggregate(Model, pipeline, options);
162
330
  }
163
331
  /**
164
- * Check if document exists
332
+ * Lookup (join) with another collection
333
+ *
334
+ * MongoDB $lookup has two mutually exclusive forms:
335
+ * 1. Simple form: { from, localField, foreignField, as }
336
+ * 2. Pipeline form: { from, let, pipeline, as }
337
+ *
338
+ * This function automatically selects the appropriate form based on parameters.
165
339
  */
166
- async function exists(Model, query, options = {}) {
167
- const q = Model.exists(query).session(options.session ?? null);
168
- if (options.readPreference) q.read(options.readPreference);
169
- return q;
170
- }
171
-
172
- //#endregion
173
- //#region src/actions/update.ts
174
- var update_exports = /* @__PURE__ */ __exportAll({
175
- increment: () => increment,
176
- pullFromArray: () => pullFromArray,
177
- pushToArray: () => pushToArray,
178
- update: () => update,
179
- updateByQuery: () => updateByQuery,
180
- updateMany: () => updateMany,
181
- updateWithConstraints: () => updateWithConstraints,
182
- updateWithValidation: () => updateWithValidation
183
- });
184
- function assertUpdatePipelineAllowed(update, updatePipeline) {
185
- if (Array.isArray(update) && updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
340
+ async function lookup(Model, lookupOptions) {
341
+ const { from, localField, foreignField, as, pipeline = [], let: letVars, query = {}, options = {} } = lookupOptions;
342
+ const aggPipeline = [];
343
+ if (Object.keys(query).length > 0) aggPipeline.push({ $match: query });
344
+ if (pipeline.length > 0 || letVars) if (pipeline.length === 0 && localField && foreignField) {
345
+ const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
346
+ aggPipeline.push({ $lookup: {
347
+ from,
348
+ let: {
349
+ [localField]: `$${localField}`,
350
+ ...letVars || {}
351
+ },
352
+ pipeline: autoPipeline,
353
+ as
354
+ } });
355
+ } else {
356
+ const safePipeline = lookupOptions.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
357
+ aggPipeline.push({ $lookup: {
358
+ from,
359
+ ...letVars && { let: letVars },
360
+ pipeline: safePipeline,
361
+ as
362
+ } });
363
+ }
364
+ else aggPipeline.push({ $lookup: {
365
+ from,
366
+ localField,
367
+ foreignField,
368
+ as
369
+ } });
370
+ return aggregate(Model, aggPipeline, options);
186
371
  }
187
372
  /**
188
- * Parse populate specification into consistent format
373
+ * Unwind array field
189
374
  */
190
- function parsePopulate(populate) {
191
- if (!populate) return [];
192
- if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
193
- if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
194
- return [populate];
375
+ async function unwind(Model, field, options = {}) {
376
+ return aggregate(Model, [{ $unwind: {
377
+ path: `$${field}`,
378
+ preserveNullAndEmptyArrays: options.preserveEmpty !== false
379
+ } }], { session: options.session });
195
380
  }
196
381
  /**
197
- * Update by ID
382
+ * Facet search (multiple aggregations in one query)
198
383
  */
199
- async function update(Model, id, data, options = {}) {
200
- assertUpdatePipelineAllowed(data, options.updatePipeline);
201
- const query = {
202
- _id: id,
203
- ...options.query
204
- };
205
- const document = await Model.findOneAndUpdate(query, data, {
206
- returnDocument: "after",
207
- runValidators: true,
208
- session: options.session,
209
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
210
- ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
211
- }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
212
- if (!document) throw createError(404, "Document not found");
213
- return document;
384
+ async function facet(Model, facets, options = {}) {
385
+ return aggregate(Model, [{ $facet: facets }], options);
214
386
  }
215
387
  /**
216
- * Update with query constraints (optimized)
217
- * Returns null if constraints not met (not an error)
218
- */
219
- async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
220
- assertUpdatePipelineAllowed(data, options.updatePipeline);
221
- const query = {
222
- _id: id,
223
- ...constraints
224
- };
225
- return await Model.findOneAndUpdate(query, data, {
226
- returnDocument: "after",
227
- runValidators: true,
228
- session: options.session,
229
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
230
- ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
231
- }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
388
+ * Get distinct values
389
+ */
390
+ async function distinct(Model, field, query = {}, options = {}) {
391
+ const q = Model.distinct(field, query).session(options.session ?? null);
392
+ if (options.readPreference) q.read(options.readPreference);
393
+ return q;
232
394
  }
233
395
  /**
234
- * Update with validation (smart optimization)
235
- * 1-query on success, 2-queries for detailed errors
396
+ * Calculate sum
236
397
  */
237
- async function updateWithValidation(Model, id, data, validationOptions = {}, options = {}) {
238
- const { buildConstraints, validateUpdate } = validationOptions;
239
- assertUpdatePipelineAllowed(data, options.updatePipeline);
240
- if (buildConstraints) {
241
- const document = await updateWithConstraints(Model, id, data, buildConstraints(data), options);
242
- if (document) return {
243
- success: true,
244
- data: document
245
- };
246
- }
247
- const findQuery = {
248
- _id: id,
249
- ...options.query
250
- };
251
- const existing = await Model.findOne(findQuery).select(options.select || "").session(options.session ?? null).lean();
252
- if (!existing) return {
253
- success: false,
254
- error: {
255
- code: 404,
256
- message: "Document not found"
257
- }
258
- };
259
- if (validateUpdate) {
260
- const validation = validateUpdate(existing, data);
261
- if (!validation.valid) return {
262
- success: false,
263
- error: {
264
- code: 403,
265
- message: validation.message || "Update not allowed",
266
- violations: validation.violations
267
- }
268
- };
269
- }
270
- return {
271
- success: true,
272
- data: await update(Model, id, data, options)
273
- };
398
+ async function sum(Model, field, query = {}, options = {}) {
399
+ const pipeline = [];
400
+ if (Object.keys(query).length > 0) pipeline.push({ $match: query });
401
+ pipeline.push({ $group: {
402
+ _id: null,
403
+ total: { $sum: `$${field}` }
404
+ } });
405
+ return (await aggregate(Model, pipeline, options))[0]?.total || 0;
274
406
  }
275
407
  /**
276
- * Update many documents
408
+ * Calculate average
277
409
  */
278
- async function updateMany(Model, query, data, options = {}) {
279
- assertUpdatePipelineAllowed(data, options.updatePipeline);
280
- const result = await Model.updateMany(query, data, {
281
- runValidators: true,
282
- session: options.session,
283
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
284
- ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
285
- });
286
- return {
287
- matchedCount: result.matchedCount,
288
- modifiedCount: result.modifiedCount
410
+ async function average(Model, field, query = {}, options = {}) {
411
+ const pipeline = [];
412
+ if (Object.keys(query).length > 0) pipeline.push({ $match: query });
413
+ pipeline.push({ $group: {
414
+ _id: null,
415
+ average: { $avg: `$${field}` }
416
+ } });
417
+ return (await aggregate(Model, pipeline, options))[0]?.average || 0;
418
+ }
419
+ /**
420
+ * Min/Max
421
+ */
422
+ async function minMax(Model, field, query = {}, options = {}) {
423
+ const pipeline = [];
424
+ if (Object.keys(query).length > 0) pipeline.push({ $match: query });
425
+ pipeline.push({ $group: {
426
+ _id: null,
427
+ min: { $min: `$${field}` },
428
+ max: { $max: `$${field}` }
429
+ } });
430
+ return (await aggregate(Model, pipeline, options))[0] || {
431
+ min: null,
432
+ max: null
289
433
  };
290
434
  }
435
+ //#endregion
436
+ //#region src/actions/create.ts
437
+ var create_exports = /* @__PURE__ */ __exportAll({
438
+ create: () => create,
439
+ createDefault: () => createDefault,
440
+ createMany: () => createMany,
441
+ upsert: () => upsert
442
+ });
291
443
  /**
292
- * Update by query
444
+ * Create single document
293
445
  */
294
- async function updateByQuery(Model, query, data, options = {}) {
295
- assertUpdatePipelineAllowed(data, options.updatePipeline);
296
- const document = await Model.findOneAndUpdate(query, data, {
297
- returnDocument: "after",
298
- runValidators: true,
299
- session: options.session,
300
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
301
- ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
302
- }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
303
- if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
446
+ async function create(Model, data, options = {}) {
447
+ const document = new Model(data);
448
+ await document.save({ session: options.session });
304
449
  return document;
305
450
  }
306
451
  /**
307
- * Increment field
452
+ * Create multiple documents
308
453
  */
309
- async function increment(Model, id, field, value = 1, options = {}) {
310
- return update(Model, id, { $inc: { [field]: value } }, options);
454
+ async function createMany(Model, dataArray, options = {}) {
455
+ return Model.insertMany(dataArray, {
456
+ session: options.session,
457
+ ordered: options.ordered !== false
458
+ });
311
459
  }
312
460
  /**
313
- * Push to array
461
+ * Create with defaults (useful for initialization)
314
462
  */
315
- async function pushToArray(Model, id, field, value, options = {}) {
316
- return update(Model, id, { $push: { [field]: value } }, options);
463
+ async function createDefault(Model, overrides = {}, options = {}) {
464
+ const defaults = {};
465
+ Model.schema.eachPath((path, schemaType) => {
466
+ const schemaOptions = schemaType.options;
467
+ if (schemaOptions.default !== void 0 && path !== "_id") defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
468
+ });
469
+ return create(Model, {
470
+ ...defaults,
471
+ ...overrides
472
+ }, options);
317
473
  }
318
474
  /**
319
- * Pull from array
475
+ * Upsert (create or update)
320
476
  */
321
- async function pullFromArray(Model, id, field, value, options = {}) {
322
- return update(Model, id, { $pull: { [field]: value } }, options);
477
+ async function upsert(Model, query, data, options = {}) {
478
+ return Model.findOneAndUpdate(query, { $setOnInsert: data }, {
479
+ upsert: true,
480
+ returnDocument: "after",
481
+ runValidators: true,
482
+ session: options.session,
483
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
484
+ });
323
485
  }
324
-
325
486
  //#endregion
326
487
  //#region src/actions/delete.ts
327
488
  var delete_exports = /* @__PURE__ */ __exportAll({
@@ -405,431 +566,273 @@ async function restore(Model, id, options = {}) {
405
566
  id: String(id)
406
567
  };
407
568
  }
408
-
409
569
  //#endregion
410
- //#region src/query/LookupBuilder.ts
411
- /** Stages that are never valid inside a $lookup pipeline */
412
- const BLOCKED_PIPELINE_STAGES = [
413
- "$out",
414
- "$merge",
415
- "$unionWith",
416
- "$collStats",
417
- "$currentOp",
418
- "$listSessions"
419
- ];
420
- /** Operators that can enable arbitrary code execution */
421
- const DANGEROUS_OPERATORS = [
422
- "$where",
423
- "$function",
424
- "$accumulator",
425
- "$expr"
426
- ];
570
+ //#region src/actions/read.ts
571
+ var read_exports = /* @__PURE__ */ __exportAll({
572
+ count: () => count,
573
+ exists: () => exists,
574
+ getAll: () => getAll,
575
+ getById: () => getById,
576
+ getByQuery: () => getByQuery,
577
+ getOrCreate: () => getOrCreate,
578
+ tryGetByQuery: () => tryGetByQuery
579
+ });
580
+ /**
581
+ * Parse populate specification into consistent format
582
+ */
583
+ function parsePopulate$1(populate) {
584
+ if (!populate) return [];
585
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
586
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
587
+ return [populate];
588
+ }
589
+ /**
590
+ * Get document by ID
591
+ *
592
+ * @param Model - Mongoose model
593
+ * @param id - Document ID
594
+ * @param options - Query options
595
+ * @returns Document or null
596
+ * @throws Error if document not found and throwOnNotFound is true
597
+ */
598
+ async function getById(Model, id, options = {}) {
599
+ const query = options.query ? Model.findOne({
600
+ _id: id,
601
+ ...options.query
602
+ }) : Model.findById(id);
603
+ if (options.select) query.select(options.select);
604
+ if (options.populate) query.populate(parsePopulate$1(options.populate));
605
+ if (options.lean) query.lean();
606
+ if (options.session) query.session(options.session);
607
+ if (options.readPreference) query.read(options.readPreference);
608
+ const document = await query.exec();
609
+ if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
610
+ return document;
611
+ }
612
+ /**
613
+ * Get document by query
614
+ *
615
+ * @param Model - Mongoose model
616
+ * @param query - MongoDB query
617
+ * @param options - Query options
618
+ * @returns Document or null
619
+ * @throws Error if document not found and throwOnNotFound is true
620
+ */
621
+ async function getByQuery(Model, query, options = {}) {
622
+ const mongoQuery = Model.findOne(query);
623
+ if (options.select) mongoQuery.select(options.select);
624
+ if (options.populate) mongoQuery.populate(parsePopulate$1(options.populate));
625
+ if (options.lean) mongoQuery.lean();
626
+ if (options.session) mongoQuery.session(options.session);
627
+ if (options.readPreference) mongoQuery.read(options.readPreference);
628
+ const document = await mongoQuery.exec();
629
+ if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
630
+ return document;
631
+ }
632
+ /**
633
+ * Get document by query without throwing (returns null if not found)
634
+ */
635
+ async function tryGetByQuery(Model, query, options = {}) {
636
+ return getByQuery(Model, query, {
637
+ ...options,
638
+ throwOnNotFound: false
639
+ });
640
+ }
641
+ /**
642
+ * Get all documents (basic query without pagination)
643
+ * For pagination, use Repository.paginate() or Repository.stream()
644
+ */
645
+ async function getAll(Model, query = {}, options = {}) {
646
+ let mongoQuery = Model.find(query);
647
+ if (options.select) mongoQuery = mongoQuery.select(options.select);
648
+ if (options.populate) mongoQuery = mongoQuery.populate(parsePopulate$1(options.populate));
649
+ if (options.sort) mongoQuery = mongoQuery.sort(options.sort);
650
+ if (options.limit) mongoQuery = mongoQuery.limit(options.limit);
651
+ if (options.skip) mongoQuery = mongoQuery.skip(options.skip);
652
+ if (options.lean !== false) mongoQuery = mongoQuery.lean();
653
+ if (options.session) mongoQuery = mongoQuery.session(options.session);
654
+ if (options.readPreference) mongoQuery = mongoQuery.read(options.readPreference);
655
+ return mongoQuery.exec();
656
+ }
657
+ /**
658
+ * Get or create document (upsert)
659
+ */
660
+ async function getOrCreate(Model, query, createData, options = {}) {
661
+ return Model.findOneAndUpdate(query, { $setOnInsert: createData }, {
662
+ upsert: true,
663
+ returnDocument: "after",
664
+ runValidators: true,
665
+ session: options.session,
666
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
667
+ });
668
+ }
427
669
  /**
428
- * Fluent builder for MongoDB $lookup aggregation stage
429
- * Optimized for custom field joins at scale
670
+ * Count documents matching query
430
671
  */
431
- var LookupBuilder = class LookupBuilder {
432
- options = {};
433
- constructor(from) {
434
- if (from) this.options.from = from;
435
- }
436
- /**
437
- * Set the collection to join with
438
- */
439
- from(collection) {
440
- this.options.from = collection;
441
- return this;
442
- }
443
- /**
444
- * Set the local field (source collection)
445
- * IMPORTANT: This field should be indexed for optimal performance
446
- */
447
- localField(field) {
448
- this.options.localField = field;
449
- return this;
450
- }
451
- /**
452
- * Set the foreign field (target collection)
453
- * IMPORTANT: This field should be indexed (preferably unique) for optimal performance
454
- */
455
- foreignField(field) {
456
- this.options.foreignField = field;
457
- return this;
458
- }
459
- /**
460
- * Set the output field name
461
- * Defaults to the collection name if not specified
462
- */
463
- as(fieldName) {
464
- this.options.as = fieldName;
465
- return this;
466
- }
467
- /**
468
- * Mark this lookup as returning a single document
469
- * Automatically unwraps the array result to a single object or null
470
- */
471
- single(isSingle = true) {
472
- this.options.single = isSingle;
473
- return this;
474
- }
475
- /**
476
- * Add a pipeline to filter/transform joined documents
477
- * Useful for filtering, sorting, or limiting joined results
478
- *
479
- * @example
480
- * ```typescript
481
- * lookup.pipeline([
482
- * { $match: { status: 'active' } },
483
- * { $sort: { priority: -1 } },
484
- * { $limit: 5 }
485
- * ]);
486
- * ```
487
- */
488
- pipeline(stages) {
489
- this.options.pipeline = stages;
490
- return this;
491
- }
492
- /**
493
- * Set let variables for use in pipeline
494
- * Allows referencing local document fields in the pipeline
495
- */
496
- let(variables) {
497
- this.options.let = variables;
498
- return this;
499
- }
500
- /**
501
- * Build the $lookup aggregation stage(s)
502
- * Returns an array of pipeline stages including $lookup and optional $unwind
503
- *
504
- * IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
505
- * 1. Simple form: { from, localField, foreignField, as }
506
- * 2. Pipeline form: { from, let, pipeline, as }
507
- *
508
- * When pipeline or let is specified, we use the pipeline form.
509
- * Otherwise, we use the simpler localField/foreignField form.
510
- */
511
- build() {
512
- const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
513
- if (!from) throw new Error("LookupBuilder: \"from\" collection is required");
514
- const outputField = as || from;
515
- const stages = [];
516
- const usePipelineForm = pipeline || letVars;
517
- let lookupStage;
518
- if (usePipelineForm) if (!pipeline || pipeline.length === 0) {
519
- if (!localField || !foreignField) throw new Error("LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline");
520
- const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
521
- lookupStage = { $lookup: {
522
- from,
523
- let: {
524
- [localField]: `$${localField}`,
525
- ...letVars || {}
526
- },
527
- pipeline: autoPipeline,
528
- as: outputField
529
- } };
530
- } else {
531
- const safePipeline = this.options.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
532
- lookupStage = { $lookup: {
533
- from,
534
- ...letVars && { let: letVars },
535
- pipeline: safePipeline,
536
- as: outputField
537
- } };
538
- }
539
- else {
540
- if (!localField || !foreignField) throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
541
- lookupStage = { $lookup: {
542
- from,
543
- localField,
544
- foreignField,
545
- as: outputField
546
- } };
547
- }
548
- stages.push(lookupStage);
549
- if (single) stages.push({ $unwind: {
550
- path: `$${outputField}`,
551
- preserveNullAndEmptyArrays: true
552
- } });
553
- return stages;
554
- }
555
- /**
556
- * Build and return only the $lookup stage (without $unwind)
557
- * Useful when you want to handle unwrapping yourself
558
- */
559
- buildLookupOnly() {
560
- return this.build()[0];
561
- }
562
- /**
563
- * Static helper: Create a simple lookup in one line
564
- */
565
- static simple(from, localField, foreignField, options = {}) {
566
- return new LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
567
- }
568
- /**
569
- * Static helper: Create multiple lookups at once
570
- *
571
- * @example
572
- * ```typescript
573
- * const pipeline = LookupBuilder.multiple([
574
- * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
575
- * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
576
- * ]);
577
- * ```
578
- */
579
- static multiple(lookups) {
580
- return lookups.flatMap((lookup) => {
581
- const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
582
- if (lookup.as) builder.as(lookup.as);
583
- if (lookup.single) builder.single(lookup.single);
584
- if (lookup.pipeline) builder.pipeline(lookup.pipeline);
585
- if (lookup.let) builder.let(lookup.let);
586
- return builder.build();
587
- });
588
- }
589
- /**
590
- * Static helper: Create a nested lookup (lookup within lookup)
591
- * Useful for multi-level joins like Order -> Product -> Category
592
- *
593
- * @example
594
- * ```typescript
595
- * // Join orders with products, then products with categories
596
- * const pipeline = LookupBuilder.nested([
597
- * { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
598
- * { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
599
- * ]);
600
- * ```
601
- */
602
- static nested(lookups) {
603
- return lookups.flatMap((lookup, index) => {
604
- const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
605
- if (lookup.as) builder.as(lookup.as);
606
- if (lookup.single !== void 0) builder.single(lookup.single);
607
- if (lookup.pipeline) builder.pipeline(lookup.pipeline);
608
- if (lookup.let) builder.let(lookup.let);
609
- return builder.build();
610
- });
611
- }
612
- /**
613
- * Sanitize pipeline stages by blocking dangerous stages and operators.
614
- * Used internally by build() and available for external use (e.g., aggregate.ts).
615
- */
616
- static sanitizePipeline(stages) {
617
- const sanitized = [];
618
- for (const stage of stages) {
619
- if (!stage || typeof stage !== "object") continue;
620
- const entries = Object.entries(stage);
621
- if (entries.length !== 1) continue;
622
- const [op, config] = entries[0];
623
- if (BLOCKED_PIPELINE_STAGES.includes(op)) {
624
- warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
625
- continue;
626
- }
627
- if ((op === "$match" || op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: LookupBuilder._sanitizeDeep(config) });
628
- else sanitized.push(stage);
629
- }
630
- return sanitized;
631
- }
632
- /**
633
- * Recursively remove dangerous operators from an expression object.
634
- */
635
- static _sanitizeDeep(config) {
636
- const sanitized = {};
637
- for (const [key, value] of Object.entries(config)) {
638
- if (DANGEROUS_OPERATORS.includes(key)) {
639
- warn(`[mongokit] Blocked dangerous operator in lookup pipeline: ${key}`);
640
- continue;
641
- }
642
- if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = LookupBuilder._sanitizeDeep(value);
643
- else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
644
- if (item && typeof item === "object" && !Array.isArray(item)) return LookupBuilder._sanitizeDeep(item);
645
- return item;
646
- });
647
- else sanitized[key] = value;
648
- }
649
- return sanitized;
650
- }
651
- };
652
-
672
+ async function count(Model, query = {}, options = {}) {
673
+ const q = Model.countDocuments(query).session(options.session ?? null);
674
+ if (options.readPreference) q.read(options.readPreference);
675
+ return q;
676
+ }
677
+ /**
678
+ * Check if document exists
679
+ */
680
+ async function exists(Model, query, options = {}) {
681
+ const q = Model.exists(query).session(options.session ?? null);
682
+ if (options.readPreference) q.read(options.readPreference);
683
+ return q;
684
+ }
653
685
  //#endregion
654
- //#region src/actions/aggregate.ts
655
- var aggregate_exports = /* @__PURE__ */ __exportAll({
656
- aggregate: () => aggregate,
657
- aggregatePaginate: () => aggregatePaginate,
658
- average: () => average,
659
- countBy: () => countBy,
660
- distinct: () => distinct,
661
- facet: () => facet,
662
- groupBy: () => groupBy,
663
- lookup: () => lookup,
664
- minMax: () => minMax,
665
- sum: () => sum,
666
- unwind: () => unwind
686
+ //#region src/actions/update.ts
687
+ var update_exports = /* @__PURE__ */ __exportAll({
688
+ increment: () => increment,
689
+ pullFromArray: () => pullFromArray,
690
+ pushToArray: () => pushToArray,
691
+ update: () => update,
692
+ updateByQuery: () => updateByQuery,
693
+ updateMany: () => updateMany,
694
+ updateWithConstraints: () => updateWithConstraints,
695
+ updateWithValidation: () => updateWithValidation
667
696
  });
668
- /**
669
- * Execute aggregation pipeline
670
- */
671
- async function aggregate(Model, pipeline, options = {}) {
672
- const aggregation = Model.aggregate(pipeline);
673
- if (options.session) aggregation.session(options.session);
674
- return aggregation.exec();
697
+ function assertUpdatePipelineAllowed(update, updatePipeline) {
698
+ if (Array.isArray(update) && updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
675
699
  }
676
700
  /**
677
- * Aggregate with pagination using native MongoDB $facet
678
- * WARNING: $facet results must be <16MB. For larger results (limit >1000),
679
- * consider using Repository.aggregatePaginate() or splitting into separate queries.
701
+ * Parse populate specification into consistent format
680
702
  */
681
- async function aggregatePaginate(Model, pipeline, options = {}) {
682
- const page = parseInt(String(options.page || 1), 10);
683
- const limit = parseInt(String(options.limit || 10), 10);
684
- const skip = (page - 1) * limit;
685
- if (limit > 1e3) warn(`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`);
686
- const facetPipeline = [...pipeline, { $facet: {
687
- docs: [{ $skip: skip }, { $limit: limit }],
688
- total: [{ $count: "count" }]
689
- } }];
690
- const aggregation = Model.aggregate(facetPipeline);
691
- if (options.session) aggregation.session(options.session);
692
- const [result] = await aggregation.exec();
693
- const docs = result.docs || [];
694
- const total = result.total[0]?.count || 0;
695
- const pages = Math.ceil(total / limit);
696
- return {
697
- docs,
698
- total,
699
- page,
700
- limit,
701
- pages,
702
- hasNext: page < pages,
703
- hasPrev: page > 1
704
- };
703
+ function parsePopulate(populate) {
704
+ if (!populate) return [];
705
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
706
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
707
+ return [populate];
705
708
  }
706
709
  /**
707
- * Group documents by field value
710
+ * Update by ID
708
711
  */
709
- async function groupBy(Model, field, options = {}) {
710
- const pipeline = [{ $group: {
711
- _id: `$${field}`,
712
- count: { $sum: 1 }
713
- } }, { $sort: { count: -1 } }];
714
- if (options.limit) pipeline.push({ $limit: options.limit });
715
- return aggregate(Model, pipeline, options);
712
+ async function update(Model, id, data, options = {}) {
713
+ assertUpdatePipelineAllowed(data, options.updatePipeline);
714
+ const query = {
715
+ _id: id,
716
+ ...options.query
717
+ };
718
+ const document = await Model.findOneAndUpdate(query, data, {
719
+ returnDocument: "after",
720
+ runValidators: true,
721
+ session: options.session,
722
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
723
+ ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
724
+ }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
725
+ if (!document) throw createError(404, "Document not found");
726
+ return document;
716
727
  }
717
728
  /**
718
- * Count by field values
729
+ * Update with query constraints (optimized)
730
+ * Returns null if constraints not met (not an error)
719
731
  */
720
- async function countBy(Model, field, query = {}, options = {}) {
721
- const pipeline = [];
722
- if (Object.keys(query).length > 0) pipeline.push({ $match: query });
723
- pipeline.push({ $group: {
724
- _id: `$${field}`,
725
- count: { $sum: 1 }
726
- } }, { $sort: { count: -1 } });
727
- return aggregate(Model, pipeline, options);
732
+ async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
733
+ assertUpdatePipelineAllowed(data, options.updatePipeline);
734
+ const query = {
735
+ _id: id,
736
+ ...constraints
737
+ };
738
+ return await Model.findOneAndUpdate(query, data, {
739
+ returnDocument: "after",
740
+ runValidators: true,
741
+ session: options.session,
742
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
743
+ ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
744
+ }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
728
745
  }
729
746
  /**
730
- * Lookup (join) with another collection
731
- *
732
- * MongoDB $lookup has two mutually exclusive forms:
733
- * 1. Simple form: { from, localField, foreignField, as }
734
- * 2. Pipeline form: { from, let, pipeline, as }
735
- *
736
- * This function automatically selects the appropriate form based on parameters.
747
+ * Update with validation (smart optimization)
748
+ * 1-query on success, 2-queries for detailed errors
737
749
  */
738
- async function lookup(Model, lookupOptions) {
739
- const { from, localField, foreignField, as, pipeline = [], let: letVars, query = {}, options = {} } = lookupOptions;
740
- const aggPipeline = [];
741
- if (Object.keys(query).length > 0) aggPipeline.push({ $match: query });
742
- if (pipeline.length > 0 || letVars) if (pipeline.length === 0 && localField && foreignField) {
743
- const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
744
- aggPipeline.push({ $lookup: {
745
- from,
746
- let: {
747
- [localField]: `$${localField}`,
748
- ...letVars || {}
749
- },
750
- pipeline: autoPipeline,
751
- as
752
- } });
753
- } else {
754
- const safePipeline = lookupOptions.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
755
- aggPipeline.push({ $lookup: {
756
- from,
757
- ...letVars && { let: letVars },
758
- pipeline: safePipeline,
759
- as
760
- } });
750
+ async function updateWithValidation(Model, id, data, validationOptions = {}, options = {}) {
751
+ const { buildConstraints, validateUpdate } = validationOptions;
752
+ assertUpdatePipelineAllowed(data, options.updatePipeline);
753
+ if (buildConstraints) {
754
+ const document = await updateWithConstraints(Model, id, data, buildConstraints(data), options);
755
+ if (document) return {
756
+ success: true,
757
+ data: document
758
+ };
761
759
  }
762
- else aggPipeline.push({ $lookup: {
763
- from,
764
- localField,
765
- foreignField,
766
- as
767
- } });
768
- return aggregate(Model, aggPipeline, options);
769
- }
770
- /**
771
- * Unwind array field
772
- */
773
- async function unwind(Model, field, options = {}) {
774
- return aggregate(Model, [{ $unwind: {
775
- path: `$${field}`,
776
- preserveNullAndEmptyArrays: options.preserveEmpty !== false
777
- } }], { session: options.session });
760
+ const findQuery = {
761
+ _id: id,
762
+ ...options.query
763
+ };
764
+ const existing = await Model.findOne(findQuery).select(options.select || "").session(options.session ?? null).lean();
765
+ if (!existing) return {
766
+ success: false,
767
+ error: {
768
+ code: 404,
769
+ message: "Document not found"
770
+ }
771
+ };
772
+ if (validateUpdate) {
773
+ const validation = validateUpdate(existing, data);
774
+ if (!validation.valid) return {
775
+ success: false,
776
+ error: {
777
+ code: 403,
778
+ message: validation.message || "Update not allowed",
779
+ violations: validation.violations
780
+ }
781
+ };
782
+ }
783
+ return {
784
+ success: true,
785
+ data: await update(Model, id, data, options)
786
+ };
778
787
  }
779
788
  /**
780
- * Facet search (multiple aggregations in one query)
789
+ * Update many documents
781
790
  */
782
- async function facet(Model, facets, options = {}) {
783
- return aggregate(Model, [{ $facet: facets }], options);
791
+ async function updateMany(Model, query, data, options = {}) {
792
+ assertUpdatePipelineAllowed(data, options.updatePipeline);
793
+ const result = await Model.updateMany(query, data, {
794
+ runValidators: true,
795
+ session: options.session,
796
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
797
+ ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
798
+ });
799
+ return {
800
+ matchedCount: result.matchedCount,
801
+ modifiedCount: result.modifiedCount
802
+ };
784
803
  }
785
804
  /**
786
- * Get distinct values
805
+ * Update by query
787
806
  */
788
- async function distinct(Model, field, query = {}, options = {}) {
789
- const q = Model.distinct(field, query).session(options.session ?? null);
790
- if (options.readPreference) q.read(options.readPreference);
791
- return q;
807
+ async function updateByQuery(Model, query, data, options = {}) {
808
+ assertUpdatePipelineAllowed(data, options.updatePipeline);
809
+ const document = await Model.findOneAndUpdate(query, data, {
810
+ returnDocument: "after",
811
+ runValidators: true,
812
+ session: options.session,
813
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {},
814
+ ...options.arrayFilters ? { arrayFilters: options.arrayFilters } : {}
815
+ }).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
816
+ if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
817
+ return document;
792
818
  }
793
819
  /**
794
- * Calculate sum
820
+ * Increment field
795
821
  */
796
- async function sum(Model, field, query = {}, options = {}) {
797
- const pipeline = [];
798
- if (Object.keys(query).length > 0) pipeline.push({ $match: query });
799
- pipeline.push({ $group: {
800
- _id: null,
801
- total: { $sum: `$${field}` }
802
- } });
803
- return (await aggregate(Model, pipeline, options))[0]?.total || 0;
822
+ async function increment(Model, id, field, value = 1, options = {}) {
823
+ return update(Model, id, { $inc: { [field]: value } }, options);
804
824
  }
805
825
  /**
806
- * Calculate average
826
+ * Push to array
807
827
  */
808
- async function average(Model, field, query = {}, options = {}) {
809
- const pipeline = [];
810
- if (Object.keys(query).length > 0) pipeline.push({ $match: query });
811
- pipeline.push({ $group: {
812
- _id: null,
813
- average: { $avg: `$${field}` }
814
- } });
815
- return (await aggregate(Model, pipeline, options))[0]?.average || 0;
828
+ async function pushToArray(Model, id, field, value, options = {}) {
829
+ return update(Model, id, { $push: { [field]: value } }, options);
816
830
  }
817
831
  /**
818
- * Min/Max
832
+ * Pull from array
819
833
  */
820
- async function minMax(Model, field, query = {}, options = {}) {
821
- const pipeline = [];
822
- if (Object.keys(query).length > 0) pipeline.push({ $match: query });
823
- pipeline.push({ $group: {
824
- _id: null,
825
- min: { $min: `$${field}` },
826
- max: { $max: `$${field}` }
827
- } });
828
- return (await aggregate(Model, pipeline, options))[0] || {
829
- min: null,
830
- max: null
831
- };
834
+ async function pullFromArray(Model, id, field, value, options = {}) {
835
+ return update(Model, id, { $pull: { [field]: value } }, options);
832
836
  }
833
-
834
837
  //#endregion
835
- export { upsert as _, delete_exports as a, count as c, getByQuery as d, getOrCreate as f, create_exports as g, createMany as h, deleteById as i, exists as l, create as m, distinct as n, update as o, read_exports as p, LookupBuilder as r, update_exports as s, aggregate_exports as t, getById as u };
838
+ export { LookupBuilder as _, getById as a, read_exports as c, create as d, createMany as f, distinct as g, aggregate_exports as h, exists as i, deleteById as l, upsert as m, update_exports as n, getByQuery as o, create_exports as p, count as r, getOrCreate as s, update as t, delete_exports as u };