@classytic/mongokit 3.11.1 → 3.13.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.
Files changed (38) hide show
  1. package/README.md +75 -5
  2. package/dist/{PaginationEngine-CvDFfVXT.mjs → PaginationEngine-CfQRM1yz.mjs} +16 -16
  3. package/dist/Repository-BY40uhcv.mjs +3603 -0
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/actions/index.mjs +1 -1
  6. package/dist/adapter/index.d.mts +190 -0
  7. package/dist/adapter/index.mjs +2 -0
  8. package/dist/adapter-DTDMnUOH.mjs +158 -0
  9. package/dist/ai/index.d.mts +1 -1
  10. package/dist/ai/index.mjs +3 -3
  11. package/dist/better-auth/index.d.mts +149 -0
  12. package/dist/better-auth/index.mjs +81 -0
  13. package/dist/field-selection-Br3WPOEb.mjs +99 -0
  14. package/dist/{index-dNTmpgK8.d.mts → index-BbHaKwi5.d.mts} +33 -13
  15. package/dist/index.d.mts +678 -36
  16. package/dist/index.mjs +240 -17
  17. package/dist/{logger-DJ7egMnd.mjs → logger-DprijP5h.mjs} +1 -5
  18. package/dist/{mongooseToJsonSchema-D1sdCOmj.mjs → mongooseToJsonSchema-BYbG894y.mjs} +68 -20
  19. package/dist/{mongooseToJsonSchema-DXxjFkLB.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +10 -2
  20. package/dist/pagination/PaginationEngine.d.mts +3 -2
  21. package/dist/pagination/PaginationEngine.mjs +1 -1
  22. package/dist/plugins/index.d.mts +2 -2
  23. package/dist/plugins/index.mjs +2 -2
  24. package/dist/query/primitives/coercion.d.mts +1 -1
  25. package/dist/query/primitives/coercion.mjs +1 -1
  26. package/dist/query/primitives/indexes.d.mts +1 -1
  27. package/dist/{types-Dc2aDVUi.d.mts → types-BbaPZleN.d.mts} +419 -205
  28. package/dist/{update-DEJYcgfV.mjs → update-DLcSSa5b.mjs} +184 -45
  29. package/dist/utils/index.d.mts +2 -91
  30. package/dist/utils/index.mjs +5 -5
  31. package/dist/{validation-chain.plugin-B0VSdcwa.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +228 -98
  32. package/dist/validation-chain.plugin-_-WuNml2.mjs +2399 -0
  33. package/package.json +25 -5
  34. package/dist/field-selection-dI0m4RBs.mjs +0 -221
  35. package/dist/validation-chain.plugin-Dllv-cml.mjs +0 -5122
  36. /package/dist/{coercion-oM8G4b6m.d.mts → coercion-TRMmR49h.d.mts} +0 -0
  37. /package/dist/{id-resolution-e4BT9zAV.mjs → id-resolution-B0Odvvou.mjs} +0 -0
  38. /package/dist/{indexes-C26GNZRU.d.mts → indexes-CgOF7Wjd.d.mts} +0 -0
@@ -0,0 +1,3603 @@
1
+ import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-DLcSSa5b.mjs";
2
+ import { n as warn } from "./logger-DprijP5h.mjs";
3
+ import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
4
+ import { t as PaginationEngine } from "./PaginationEngine-CfQRM1yz.mjs";
5
+ import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
6
+ import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-B0Odvvou.mjs";
7
+ import { isFilter } from "@classytic/repo-core/filter";
8
+ import mongoose from "mongoose";
9
+ import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
10
+ import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
11
+ import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
12
+ import { decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures } from "@classytic/repo-core/aggregate";
13
+ //#region src/operations.ts
14
+ /**
15
+ * The registry. Order intentionally groups by category for readability.
16
+ * Adding a new RepositoryOperation requires exactly one entry here, after
17
+ * which every bundled plugin auto-classifies it correctly.
18
+ */
19
+ const OP_REGISTRY = {
20
+ create: {
21
+ policyKey: "data",
22
+ mutates: true,
23
+ hasIdContext: false
24
+ },
25
+ update: {
26
+ policyKey: "query",
27
+ mutates: true,
28
+ hasIdContext: true
29
+ },
30
+ findOneAndUpdate: {
31
+ policyKey: "query",
32
+ mutates: true,
33
+ hasIdContext: false
34
+ },
35
+ claim: {
36
+ policyKey: "query",
37
+ mutates: true,
38
+ hasIdContext: true
39
+ },
40
+ claimVersion: {
41
+ policyKey: "query",
42
+ mutates: true,
43
+ hasIdContext: true
44
+ },
45
+ delete: {
46
+ policyKey: "query",
47
+ mutates: true,
48
+ hasIdContext: true
49
+ },
50
+ restore: {
51
+ policyKey: "query",
52
+ mutates: true,
53
+ hasIdContext: true
54
+ },
55
+ createMany: {
56
+ policyKey: "dataArray",
57
+ mutates: true,
58
+ hasIdContext: false
59
+ },
60
+ updateMany: {
61
+ policyKey: "query",
62
+ mutates: true,
63
+ hasIdContext: false
64
+ },
65
+ deleteMany: {
66
+ policyKey: "query",
67
+ mutates: true,
68
+ hasIdContext: false
69
+ },
70
+ bulkWrite: {
71
+ policyKey: "operations",
72
+ mutates: true,
73
+ hasIdContext: false
74
+ },
75
+ getById: {
76
+ policyKey: "query",
77
+ mutates: false,
78
+ hasIdContext: true
79
+ },
80
+ getByQuery: {
81
+ policyKey: "query",
82
+ mutates: false,
83
+ hasIdContext: false
84
+ },
85
+ getOne: {
86
+ policyKey: "query",
87
+ mutates: false,
88
+ hasIdContext: false
89
+ },
90
+ findAll: {
91
+ policyKey: "query",
92
+ mutates: false,
93
+ hasIdContext: false
94
+ },
95
+ getOrCreate: {
96
+ policyKey: "query",
97
+ mutates: false,
98
+ hasIdContext: false
99
+ },
100
+ count: {
101
+ policyKey: "query",
102
+ mutates: false,
103
+ hasIdContext: false
104
+ },
105
+ exists: {
106
+ policyKey: "query",
107
+ mutates: false,
108
+ hasIdContext: false
109
+ },
110
+ distinct: {
111
+ policyKey: "query",
112
+ mutates: false,
113
+ hasIdContext: false
114
+ },
115
+ aggregate: {
116
+ policyKey: "query",
117
+ mutates: false,
118
+ hasIdContext: false
119
+ },
120
+ aggregatePipeline: {
121
+ policyKey: "query",
122
+ mutates: false,
123
+ hasIdContext: false
124
+ },
125
+ cursor: {
126
+ policyKey: "query",
127
+ mutates: false,
128
+ hasIdContext: false
129
+ },
130
+ getAll: {
131
+ policyKey: "filters",
132
+ mutates: false,
133
+ hasIdContext: false
134
+ },
135
+ aggregatePaginate: {
136
+ policyKey: "filters",
137
+ mutates: false,
138
+ hasIdContext: false
139
+ },
140
+ aggregatePipelinePaginate: {
141
+ policyKey: "filters",
142
+ mutates: false,
143
+ hasIdContext: false
144
+ },
145
+ lookupPopulate: {
146
+ policyKey: "filters",
147
+ mutates: false,
148
+ hasIdContext: false
149
+ }
150
+ };
151
+ /** All known repository operations, in registry order. */
152
+ const ALL_OPERATIONS = Object.keys(OP_REGISTRY);
153
+ /** Operations that mutate the database. Drives audit + cache invalidation lists. */
154
+ const MUTATING_OPERATIONS = ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].mutates);
155
+ /** Operations that don't mutate. Drives default cacheable ops. */
156
+ const READ_OPERATIONS = ALL_OPERATIONS.filter((op) => !OP_REGISTRY[op].mutates);
157
+ /** Filter ops by their policy injection key. */
158
+ function operationsByPolicyKey(key) {
159
+ return ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].policyKey === key);
160
+ }
161
+ //#endregion
162
+ //#region src/actions/aggregate-ir/hints.ts
163
+ function applyExecutionHints(aggregation, hints) {
164
+ if (!hints) return;
165
+ if (hints.allowDiskUse) aggregation.allowDiskUse(true);
166
+ if (typeof hints.maxTimeMs === "number" && hints.maxTimeMs > 0) aggregation.option({ maxTimeMS: hints.maxTimeMs });
167
+ if (hints.indexHint !== void 0) aggregation.option({ hint: hints.indexHint });
168
+ }
169
+ //#endregion
170
+ //#region src/actions/aggregate-ir/normalize.ts
171
+ /**
172
+ * AggRequest → input normalization (mongokit binding).
173
+ *
174
+ * Re-exports the kit-neutral helpers from `@classytic/repo-core/aggregate`
175
+ * with the mongokit error-prefix pre-bound, so call sites stay free of
176
+ * boilerplate. The actual logic lives in repo-core — when the rules
177
+ * for what a valid AggRequest looks like change, update the IR
178
+ * contract there.
179
+ */
180
+ function validateMeasures$1(measures) {
181
+ validateMeasures(measures, "mongokit");
182
+ }
183
+ //#endregion
184
+ //#region src/actions/aggregate-ir/dateBucket.ts
185
+ /**
186
+ * Compile a `AggDateBucket` to the mongo expression that produces the
187
+ * bucket label string for a single document. Used in two places:
188
+ *
189
+ * 1. Inside `$group._id` so the aggregator groups by the bucket
190
+ * label (one row per distinct bucket).
191
+ * 2. Inside `$project` to flatten `_id.<alias>` into a top-level
192
+ * column on the output row — matches the cross-kit shape.
193
+ */
194
+ function compileDateBucket(bucket) {
195
+ const dateExpr = { $toDate: `$${bucket.field}` };
196
+ if (typeof bucket.interval === "object") {
197
+ const { every, unit } = bucket.interval;
198
+ if (!Number.isInteger(every) || every <= 0) throw new Error(`mongokit/aggregate: dateBucket.interval.every must be a positive integer — got ${String(every)}`);
199
+ const truncated = { $dateTrunc: {
200
+ date: dateExpr,
201
+ unit,
202
+ binSize: every,
203
+ timezone: "UTC"
204
+ } };
205
+ return { $dateToString: {
206
+ format: formatForUnit(unit),
207
+ date: truncated,
208
+ timezone: "UTC"
209
+ } };
210
+ }
211
+ switch (bucket.interval) {
212
+ case "minute": return { $dateToString: {
213
+ format: "%Y-%m-%dT%H:%M",
214
+ date: dateExpr,
215
+ timezone: "UTC"
216
+ } };
217
+ case "hour": return { $dateToString: {
218
+ format: "%Y-%m-%dT%H:00",
219
+ date: dateExpr,
220
+ timezone: "UTC"
221
+ } };
222
+ case "day": return { $dateToString: {
223
+ format: "%Y-%m-%d",
224
+ date: dateExpr,
225
+ timezone: "UTC"
226
+ } };
227
+ case "week": return { $dateToString: {
228
+ format: "%G-W%V",
229
+ date: dateExpr,
230
+ timezone: "UTC"
231
+ } };
232
+ case "month": return { $dateToString: {
233
+ format: "%Y-%m",
234
+ date: dateExpr,
235
+ timezone: "UTC"
236
+ } };
237
+ case "quarter": return { $concat: [
238
+ { $dateToString: {
239
+ format: "%Y",
240
+ date: dateExpr,
241
+ timezone: "UTC"
242
+ } },
243
+ "-Q",
244
+ { $toString: { $ceil: { $divide: [{ $month: {
245
+ date: dateExpr,
246
+ timezone: "UTC"
247
+ } }, 3] } } }
248
+ ] };
249
+ case "year": return { $dateToString: {
250
+ format: "%Y",
251
+ date: dateExpr,
252
+ timezone: "UTC"
253
+ } };
254
+ }
255
+ }
256
+ /**
257
+ * Choose the canonical label format for a custom-bin unit. Matches
258
+ * the named-bucket label when `every === 1` so the cross-kit row
259
+ * shape stays consistent regardless of which surface form the caller
260
+ * picked.
261
+ */
262
+ function formatForUnit(unit) {
263
+ switch (unit) {
264
+ case "minute": return "%Y-%m-%dT%H:%M";
265
+ case "hour": return "%Y-%m-%dT%H:00";
266
+ case "day": return "%Y-%m-%d";
267
+ case "week": return "%G-W%V";
268
+ case "month": return "%Y-%m";
269
+ case "quarter":
270
+ case "year": return "%Y";
271
+ }
272
+ }
273
+ //#endregion
274
+ //#region src/filter/compile-expr.ts
275
+ /**
276
+ * Compile a Filter IR node (or already-built expression) to a MongoDB
277
+ * aggregation expression. Returns the literal `true` for empty `and`
278
+ * / `true` nodes so callers can use the result as a `$cond`
279
+ * predicate without special-casing — `$cond: [true, X, Y]` short-
280
+ * circuits to `X` at planning time.
281
+ *
282
+ * The `input` is typed as `unknown` so callers can pass either a
283
+ * Filter IR node or an already-built expression and we dispatch
284
+ * appropriately. This mirrors `compileFilterToMongo`'s ergonomic
285
+ * dispatch.
286
+ */
287
+ function compileFilterToMongoExpr(input) {
288
+ if (input === void 0 || input === null) return true;
289
+ if (!isFilter(input)) return input;
290
+ return compile(input);
291
+ }
292
+ function compile(filter) {
293
+ switch (filter.op) {
294
+ case "true": return true;
295
+ case "false": return false;
296
+ case "eq": return { $eq: [`$${filter.field}`, filter.value] };
297
+ case "ne": return filter.value === null ? { $ne: [`$${filter.field}`, null] } : { $and: [{ $ne: [`$${filter.field}`, filter.value] }, { $ne: [`$${filter.field}`, null] }] };
298
+ case "gt": return { $gt: [`$${filter.field}`, filter.value] };
299
+ case "gte": return { $gte: [`$${filter.field}`, filter.value] };
300
+ case "lt": return { $lt: [`$${filter.field}`, filter.value] };
301
+ case "lte": return { $lte: [`$${filter.field}`, filter.value] };
302
+ case "in":
303
+ if (filter.values.length === 0) return false;
304
+ return { $in: [`$${filter.field}`, [...filter.values]] };
305
+ case "nin":
306
+ if (filter.values.length === 0) return true;
307
+ return { $not: [{ $in: [`$${filter.field}`, [...filter.values]] }] };
308
+ case "exists": return filter.exists ? { $ne: [`$${filter.field}`, null] } : { $eq: [`$${filter.field}`, null] };
309
+ case "like": return { $regexMatch: {
310
+ input: `$${filter.field}`,
311
+ regex: likeToRegexPattern(filter.pattern),
312
+ ...filter.caseSensitivity === "sensitive" ? {} : { options: "i" }
313
+ } };
314
+ case "regex": return { $regexMatch: {
315
+ input: `$${filter.field}`,
316
+ regex: filter.pattern
317
+ } };
318
+ case "and": {
319
+ if (filter.children.length === 0) return true;
320
+ const parts = filter.children.map(compile);
321
+ if (parts.length === 1) return parts[0];
322
+ return { $and: parts };
323
+ }
324
+ case "or": {
325
+ if (filter.children.length === 0) return false;
326
+ const parts = filter.children.map(compile);
327
+ if (parts.length === 1) return parts[0];
328
+ return { $or: parts };
329
+ }
330
+ case "not": return { $not: [compile(filter.child)] };
331
+ case "raw": throw new Error("mongokit/filter: `raw` is a SQL escape hatch and has no safe MongoDB expression translation. Pass an aggregation expression directly instead.");
332
+ }
333
+ }
334
+ /**
335
+ * Translate a SQL LIKE pattern to a regex pattern string (without
336
+ * the `^$` anchors and `$options` field — those layer on top per
337
+ * call site). Mirrors `likeToRegex` in the query compiler.
338
+ */
339
+ function likeToRegexPattern(pattern) {
340
+ let out = "^";
341
+ for (let i = 0; i < pattern.length; i++) {
342
+ const ch = pattern[i];
343
+ if (ch === "\\" && i + 1 < pattern.length) {
344
+ const next = pattern[i + 1];
345
+ if (next === "%" || next === "_" || next === "\\") {
346
+ out += escapeRegexChar(next);
347
+ i++;
348
+ continue;
349
+ }
350
+ }
351
+ if (ch === "%") out += ".*";
352
+ else if (ch === "_") out += ".";
353
+ else out += escapeRegexChar(ch);
354
+ }
355
+ out += "$";
356
+ return out;
357
+ }
358
+ function escapeRegexChar(ch) {
359
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
360
+ }
361
+ //#endregion
362
+ //#region src/actions/aggregate-ir/measure.ts
363
+ function compileMeasure(measure) {
364
+ const whereExpr = measure.where !== void 0 ? compileFilterToMongoExpr(measure.where) : void 0;
365
+ switch (measure.op) {
366
+ case "count": {
367
+ const fieldName = measure.field;
368
+ const baseCondition = !fieldName || fieldName === "*" ? true : { $ne: [`$${fieldName}`, null] };
369
+ const condition = whereExpr === void 0 ? baseCondition : baseCondition === true ? whereExpr : { $and: [baseCondition, whereExpr] };
370
+ if (condition === true) return { groupExpr: { $sum: 1 } };
371
+ return { groupExpr: { $sum: { $cond: [
372
+ condition,
373
+ 1,
374
+ 0
375
+ ] } } };
376
+ }
377
+ case "countDistinct": return {
378
+ groupExpr: { $addToSet: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
379
+ whereExpr,
380
+ `$${measure.field}`,
381
+ "$$REMOVE"
382
+ ] } },
383
+ projectExpr: { $size: { $ifNull: ["$$CURRENT", []] } }
384
+ };
385
+ case "sum": return { groupExpr: { $sum: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
386
+ whereExpr,
387
+ `$${measure.field}`,
388
+ 0
389
+ ] } } };
390
+ case "avg": return { groupExpr: { $avg: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
391
+ whereExpr,
392
+ `$${measure.field}`,
393
+ null
394
+ ] } } };
395
+ case "min": return { groupExpr: { $min: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
396
+ whereExpr,
397
+ `$${measure.field}`,
398
+ null
399
+ ] } } };
400
+ case "max": return { groupExpr: { $max: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
401
+ whereExpr,
402
+ `$${measure.field}`,
403
+ null
404
+ ] } } };
405
+ case "stddev": return { groupExpr: { $stdDevSamp: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
406
+ whereExpr,
407
+ `$${measure.field}`,
408
+ null
409
+ ] } } };
410
+ case "stddevPop": return { groupExpr: { $stdDevPop: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
411
+ whereExpr,
412
+ `$${measure.field}`,
413
+ null
414
+ ] } } };
415
+ case "percentile":
416
+ if (typeof measure.p !== "number" || measure.p < 0 || measure.p > 1) throw new Error(`mongokit/aggregate: 'percentile' requires p in [0, 1] — got ${String(measure.p)}`);
417
+ return {
418
+ groupExpr: { $percentile: {
419
+ input: whereExpr === void 0 ? `$${measure.field}` : { $cond: [
420
+ whereExpr,
421
+ `$${measure.field}`,
422
+ null
423
+ ] },
424
+ p: [measure.p],
425
+ method: "approximate"
426
+ } },
427
+ projectExpr: { $arrayElemAt: ["$$CURRENT", 0] }
428
+ };
429
+ }
430
+ }
431
+ //#endregion
432
+ //#region src/actions/aggregate-ir/pipeline.ts
433
+ function buildAggPipeline(req) {
434
+ validateMeasures$1(req.measures);
435
+ const groupCols = normalizeGroupBy(req.groupBy);
436
+ const bucketAliases = req.dateBuckets ? Object.keys(req.dateBuckets) : [];
437
+ validateBucketAliases(bucketAliases, groupCols, req.measures);
438
+ const stages = [];
439
+ const aliasSet = new Set((req.lookups ?? []).map((l) => l.as ?? l.from));
440
+ const { base: baseFilter, joined: joinedFilter } = splitFilterByAlias(req.filter, aliasSet);
441
+ if (baseFilter !== void 0) {
442
+ const match = compileFilterToMongo(baseFilter);
443
+ if (Object.keys(match).length > 0) stages.push({ $match: match });
444
+ }
445
+ if (req.lookups && req.lookups.length > 0) appendLookupStages(stages, req.lookups);
446
+ if (joinedFilter !== void 0) {
447
+ const match = compileFilterToMongo(joinedFilter);
448
+ if (Object.keys(match).length > 0) stages.push({ $match: match });
449
+ }
450
+ const bucketInternalKey = (alias) => `__bucket_${alias}`;
451
+ if (req.dateBuckets && bucketAliases.length > 0) {
452
+ const addFields = {};
453
+ for (const alias of bucketAliases) addFields[bucketInternalKey(alias)] = compileDateBucket(req.dateBuckets[alias]);
454
+ stages.push({ $addFields: addFields });
455
+ }
456
+ const groupStage = { _id: groupCols.length === 0 && bucketAliases.length === 0 ? null : (() => {
457
+ const id = {};
458
+ for (const field of groupCols) id[safeIdKey(field)] = `$${field}`;
459
+ for (const alias of bucketAliases) id[alias] = `$${bucketInternalKey(alias)}`;
460
+ return id;
461
+ })() };
462
+ const distinctSetAliases = [];
463
+ const percentileAliases = [];
464
+ for (const [alias, measure] of Object.entries(req.measures)) {
465
+ groupStage[alias] = compileMeasure(measure).groupExpr;
466
+ if (measure.op === "countDistinct") distinctSetAliases.push(alias);
467
+ else if (measure.op === "percentile") percentileAliases.push(alias);
468
+ }
469
+ stages.push({ $group: groupStage });
470
+ if (distinctSetAliases.length > 0) {
471
+ const addFields = {};
472
+ for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
473
+ stages.push({ $addFields: addFields });
474
+ }
475
+ if (percentileAliases.length > 0) {
476
+ const addFields = {};
477
+ for (const alias of percentileAliases) addFields[alias] = { $arrayElemAt: [{ $ifNull: [`$${alias}`, [null]] }, 0] };
478
+ stages.push({ $addFields: addFields });
479
+ }
480
+ const projection = { _id: 0 };
481
+ for (const field of groupCols) projection[field] = `$_id.${safeIdKey(field)}`;
482
+ for (const alias of bucketAliases) projection[alias] = `$_id.${alias}`;
483
+ for (const alias of Object.keys(req.measures)) projection[alias] = 1;
484
+ stages.push({ $project: projection });
485
+ if (req.having) {
486
+ const match = compileFilterToMongo(req.having);
487
+ if (Object.keys(match).length > 0) stages.push({ $match: match });
488
+ }
489
+ if (req.topN) {
490
+ validateTopN(req.topN, groupCols, bucketAliases, req.measures);
491
+ appendTopNStages(stages, req.topN);
492
+ }
493
+ const prePaginationIndex = stages.length;
494
+ if (req.sort) stages.push({ $sort: req.sort });
495
+ if (typeof req.offset === "number" && req.offset > 0) stages.push({ $skip: req.offset });
496
+ if (typeof req.limit === "number") stages.push({ $limit: req.limit });
497
+ return {
498
+ pipeline: stages,
499
+ prePaginationIndex
500
+ };
501
+ }
502
+ /**
503
+ * Validate the `topN` spec against the rest of the request:
504
+ *
505
+ * 1. `partitionBy` columns must each be a `groupBy` field, a
506
+ * `dateBuckets` alias, OR a measure alias — otherwise the
507
+ * partition resolves to the same key for every row and top-N
508
+ * becomes a global limit.
509
+ * 2. `limit` must be a positive integer.
510
+ * 3. `sortBy` must be non-empty (rank without an order is meaningless).
511
+ *
512
+ * All checks throw at request time with a message naming the bad
513
+ * field — so the failure surface is the buggy AggRequest, not a
514
+ * silent "no rows" downstream.
515
+ */
516
+ function validateTopN(topN, groupCols, bucketAliases, measures) {
517
+ if (!Number.isInteger(topN.limit) || topN.limit <= 0) throw new Error(`mongokit/aggregate: topN.limit must be a positive integer — got ${String(topN.limit)}`);
518
+ if (!topN.sortBy || Object.keys(topN.sortBy).length === 0) throw new Error("mongokit/aggregate: topN.sortBy must declare at least one ranking field");
519
+ const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
520
+ const validKeys = new Set([
521
+ ...groupCols,
522
+ ...bucketAliases,
523
+ ...Object.keys(measures)
524
+ ]);
525
+ for (const key of partitionList) if (!validKeys.has(key)) throw new Error(`mongokit/aggregate: topN.partitionBy "${key}" is not a groupBy field, dateBucket alias, or measure alias`);
526
+ }
527
+ /**
528
+ * Append the `$setWindowFields` + `$match` + `$project` stages that
529
+ * implement top-N-per-group. Runs after the post-group projection,
530
+ * so partition / sort keys reference output column names directly
531
+ * (no `_id.`-prefix juggling).
532
+ *
533
+ * Tie-breaking strategy maps:
534
+ * - `'rank'` → `$rank` (default; gaps after ties)
535
+ * - `'dense_rank'` → `$denseRank` (no gaps)
536
+ * - `'row_number'` → `$documentNumber` (each row unique)
537
+ */
538
+ function appendTopNStages(stages, topN) {
539
+ const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
540
+ const partitionExpr = partitionList.length === 1 ? `$${partitionList[0]}` : { $concat: partitionList.flatMap((field, i) => {
541
+ const ref = { $toString: { $ifNull: [`$${field}`, "__NULL__"] } };
542
+ return i === 0 ? [ref] : ["", ref];
543
+ }) };
544
+ const tiesOp = topN.ties === "dense_rank" ? "$denseRank" : topN.ties === "row_number" ? "$documentNumber" : "$rank";
545
+ const RANK_FIELD = "__topNRank";
546
+ stages.push({ $setWindowFields: {
547
+ partitionBy: partitionExpr,
548
+ sortBy: topN.sortBy,
549
+ output: { [RANK_FIELD]: { [tiesOp]: {} } }
550
+ } });
551
+ stages.push({ $match: { [RANK_FIELD]: { $lte: topN.limit } } });
552
+ stages.push({ $project: { [RANK_FIELD]: 0 } });
553
+ }
554
+ /**
555
+ * Fail loud when a date-bucket alias collides with a `groupBy` column
556
+ * or measure alias. The output row would otherwise contain ambiguous
557
+ * keys — last writer wins on the projection, silently swapping
558
+ * group-key values for measure values (or vice versa).
559
+ *
560
+ * Caught at compile time so the failure surface is the buggy
561
+ * AggRequest, not a downstream consumer reading the wrong column.
562
+ */
563
+ function validateBucketAliases(bucketAliases, groupCols, measures) {
564
+ if (bucketAliases.length === 0) return;
565
+ const groupSet = new Set(groupCols);
566
+ const measureSet = new Set(Object.keys(measures));
567
+ for (const alias of bucketAliases) {
568
+ if (groupSet.has(alias)) throw new Error(`mongokit/aggregate: dateBuckets alias "${alias}" collides with a groupBy field of the same name`);
569
+ if (measureSet.has(alias)) throw new Error(`mongokit/aggregate: dateBuckets alias "${alias}" collides with a measure of the same name`);
570
+ }
571
+ }
572
+ /**
573
+ * Compile `LookupSpec[]` into mongo `$lookup` stages and append them
574
+ * to the running pipeline. Mirrors the lookup section of
575
+ * `lookupPopulate()` so cross-operation behavior stays identical:
576
+ *
577
+ * - Each lookup contributes one `$lookup` stage (pipeline-form when
578
+ * `where` / `select` is set, simple form otherwise).
579
+ * - `single: true` lookups also emit an `$addFields` coalesce so
580
+ * the joined value becomes `null` instead of `[]` when no row
581
+ * matches (so `$group._id` keys are stable instead of falling
582
+ * into a single empty-array bucket).
583
+ */
584
+ /**
585
+ * Convert a (potentially dotted) groupBy field name into a safe key
586
+ * for use inside `$group._id`. BSON disallows `.` in field names
587
+ * anywhere — including the `_id` document — so dotted paths get the
588
+ * `.` replaced with `__`. Plain field names pass through unchanged.
589
+ *
590
+ * Pairs with the `$project` stage that rebuilds the original dotted
591
+ * key on the OUTPUT row, which Mongo then nests automatically (e.g.
592
+ * projection key `'department.code'` produces `{ department: { code }}`).
593
+ *
594
+ * Cross-kit shape contract: see repo-core's `AggRow` doc and
595
+ * `nestDottedKeys` helper.
596
+ */
597
+ function safeIdKey(field) {
598
+ return field.includes(".") ? field.replace(/\./g, "__") : field;
599
+ }
600
+ function appendLookupStages(stages, lookups) {
601
+ const asLookupOptions = lookups;
602
+ stages.push(...LookupBuilder.multiple(asLookupOptions));
603
+ for (const lookup of lookups) if (lookup.single) {
604
+ const asField = lookup.as ?? lookup.from;
605
+ stages.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
606
+ }
607
+ }
608
+ /**
609
+ * Split a filter into base-side and joined-side predicates so the
610
+ * caller can emit two `$match` stages — one BEFORE `$lookup` (for
611
+ * base columns, leveraging indexes) and one AFTER `$lookup` (for
612
+ * joined-alias references that don't exist pre-lookup).
613
+ *
614
+ * Behavior by input shape:
615
+ *
616
+ * - **`undefined`** → both sides `undefined`.
617
+ * - **Plain record** (Mongo-style query object) → keys split by
618
+ * prefix: `'<alias>.<col>'` → joined; everything else → base.
619
+ * Top-level mongo operators (`$or`, `$and`) that mix base + joined
620
+ * references can't be cleanly split — the whole record routes
621
+ * post-lookup so semantics stay correct (at the cost of losing
622
+ * pre-lookup index usage on those branches).
623
+ * - **Filter IR** — top-level `and(...)` splits children by joined-
624
+ * alias presence; non-AND nodes route entirely to one side based
625
+ * on whether any leaf references an alias.
626
+ *
627
+ * No lookups (`aliasSet` empty) — short-circuit: entire filter is
628
+ * base-side. Matches the no-join legacy behavior exactly.
629
+ */
630
+ function splitFilterByAlias(filter, aliasSet) {
631
+ if (filter === void 0 || filter === null) return {
632
+ base: void 0,
633
+ joined: void 0
634
+ };
635
+ if (aliasSet.size === 0) return {
636
+ base: filter,
637
+ joined: void 0
638
+ };
639
+ if (!isFilter(filter)) return splitRecordByAlias(filter, aliasSet);
640
+ return splitIRByAlias(filter, aliasSet);
641
+ }
642
+ function splitRecordByAlias(record, aliasSet) {
643
+ const base = {};
644
+ const joined = {};
645
+ let hasMixedTopOp = false;
646
+ for (const [key, value] of Object.entries(record)) {
647
+ if (key.startsWith("$") && Array.isArray(value)) {
648
+ if (value.some((branch) => recordReferencesAlias(branch, aliasSet))) {
649
+ hasMixedTopOp = true;
650
+ continue;
651
+ }
652
+ base[key] = value;
653
+ continue;
654
+ }
655
+ const dot = key.indexOf(".");
656
+ const prefix = dot > 0 ? key.slice(0, dot) : null;
657
+ if (prefix && aliasSet.has(prefix)) joined[key] = value;
658
+ else base[key] = value;
659
+ }
660
+ if (hasMixedTopOp) return {
661
+ base: void 0,
662
+ joined: record
663
+ };
664
+ return {
665
+ base: Object.keys(base).length > 0 ? base : void 0,
666
+ joined: Object.keys(joined).length > 0 ? joined : void 0
667
+ };
668
+ }
669
+ function recordReferencesAlias(record, aliasSet) {
670
+ for (const key of Object.keys(record)) {
671
+ const dot = key.indexOf(".");
672
+ if (dot > 0 && aliasSet.has(key.slice(0, dot))) return true;
673
+ }
674
+ return false;
675
+ }
676
+ function splitIRByAlias(filter, aliasSet) {
677
+ if (filter.op === "and") {
678
+ const baseChildren = [];
679
+ const joinedChildren = [];
680
+ for (const child of filter.children) if (irReferencesAlias(child, aliasSet)) joinedChildren.push(child);
681
+ else baseChildren.push(child);
682
+ return {
683
+ base: andOrSingle(baseChildren),
684
+ joined: andOrSingle(joinedChildren)
685
+ };
686
+ }
687
+ if (irReferencesAlias(filter, aliasSet)) return {
688
+ base: void 0,
689
+ joined: filter
690
+ };
691
+ return {
692
+ base: filter,
693
+ joined: void 0
694
+ };
695
+ }
696
+ function andOrSingle(children) {
697
+ if (children.length === 0) return void 0;
698
+ if (children.length === 1) return children[0];
699
+ return {
700
+ op: "and",
701
+ children: Object.freeze(children)
702
+ };
703
+ }
704
+ function irReferencesAlias(filter, aliasSet) {
705
+ const node = filter;
706
+ if (typeof node.field === "string") {
707
+ const dot = node.field.indexOf(".");
708
+ if (dot > 0 && aliasSet.has(node.field.slice(0, dot))) return true;
709
+ }
710
+ if (Array.isArray(node.children)) return node.children.some((c) => irReferencesAlias(c, aliasSet));
711
+ if (node.child) return irReferencesAlias(node.child, aliasSet);
712
+ return false;
713
+ }
714
+ //#endregion
715
+ //#region src/actions/aggregate-ir/count.ts
716
+ async function countAggGroups(Model, req, options = {}) {
717
+ const session = options.session;
718
+ const groupCols = normalizeGroupBy(req.groupBy);
719
+ const hasDottedGroupBy = groupCols.some((f) => f.includes("."));
720
+ const hasDateBuckets = !!req.dateBuckets && Object.keys(req.dateBuckets).length > 0;
721
+ if (req.having !== void 0 || req.lookups !== void 0 && req.lookups.length > 0 || hasDottedGroupBy || hasDateBuckets) {
722
+ const { pipeline, prePaginationIndex } = buildAggPipeline(req);
723
+ const finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
724
+ const aggregation = Model.aggregate(finalPipeline);
725
+ if (session) aggregation.session(session);
726
+ applyExecutionHints(aggregation, req.executionHints);
727
+ const [row] = await aggregation.exec();
728
+ return row?.n ?? 0;
729
+ }
730
+ const match = req.filter ? compileFilterToMongo(req.filter) : {};
731
+ if (groupCols.length === 0) {
732
+ const pipeline = [];
733
+ if (Object.keys(match).length > 0) pipeline.push({ $match: match });
734
+ pipeline.push({ $limit: 1 });
735
+ pipeline.push({ $count: "n" });
736
+ const aggregation = Model.aggregate(pipeline);
737
+ if (session) aggregation.session(session);
738
+ applyExecutionHints(aggregation, req.executionHints);
739
+ const [row] = await aggregation.exec();
740
+ return row?.n ?? 0;
741
+ }
742
+ const groupId = groupCols.reduce((acc, field) => {
743
+ acc[field] = `$${field}`;
744
+ return acc;
745
+ }, {});
746
+ const pipeline = [];
747
+ if (Object.keys(match).length > 0) pipeline.push({ $match: match });
748
+ pipeline.push({ $group: { _id: groupId } });
749
+ pipeline.push({ $count: "n" });
750
+ const aggregation = Model.aggregate(pipeline);
751
+ if (session) aggregation.session(session);
752
+ const [row] = await aggregation.exec();
753
+ return row?.n ?? 0;
754
+ }
755
+ //#endregion
756
+ //#region src/actions/aggregate-ir/execute.ts
757
+ async function executeAgg(Model, req, options = {}) {
758
+ const session = options.session;
759
+ const { pipeline } = buildAggPipeline(req);
760
+ const aggregation = Model.aggregate(pipeline);
761
+ if (session) aggregation.session(session);
762
+ applyExecutionHints(aggregation, req.executionHints);
763
+ return await aggregation.exec();
764
+ }
765
+ //#endregion
766
+ //#region src/actions/aggregate-ir/keyset.ts
767
+ /**
768
+ * Keyset (cursor) pagination helpers — mongokit binding.
769
+ *
770
+ * The kit-neutral half (cursor encode/decode + mode detection) lives
771
+ * in `@classytic/repo-core/aggregate`. This file re-exports those bits
772
+ * with the mongokit error prefix pre-bound, then layers on the
773
+ * mongo-specific `buildKeysetPredicate` (emits a `$match` PipelineStage)
774
+ * which has no SQL counterpart.
775
+ *
776
+ * **Sort keys reference OUTPUT columns** — `groupBy` field names,
777
+ * `dateBuckets` aliases, and `measures` aliases all qualify. The
778
+ * keyset predicate runs AFTER the post-group `$project` stage, which
779
+ * is when the output column names exist.
780
+ */
781
+ function decodeAggCursor$1(cursor) {
782
+ return decodeAggCursor(cursor, "mongokit");
783
+ }
784
+ /**
785
+ * Build a `$match` stage that selects rows AFTER the cursor row given
786
+ * the sort spec. Encodes the row-tuple comparison as a left-leaning
787
+ * `$or` of progressively-deeper equality + tail inequality:
788
+ *
789
+ * sort: { a: 1, b: -1, c: 1 }, after: { a, b, c }
790
+ * → a > $a
791
+ * OR (a == $a AND b < $b)
792
+ * OR (a == $a AND b == $b AND c > $c)
793
+ *
794
+ * Returns `undefined` when the sort spec is empty or every cursor key
795
+ * is missing — caller should reject those upstream rather than ship a
796
+ * no-op stage.
797
+ */
798
+ function buildKeysetPredicate(sort, cursor) {
799
+ const sortKeys = Object.keys(sort);
800
+ if (sortKeys.length === 0) return void 0;
801
+ const branches = [];
802
+ for (let i = 0; i < sortKeys.length; i++) {
803
+ const tailKey = sortKeys[i];
804
+ const tailOp = sort[tailKey] === 1 ? "$gt" : "$lt";
805
+ const branch = {};
806
+ for (let j = 0; j < i; j++) {
807
+ const eqKey = sortKeys[j];
808
+ branch[eqKey] = cursor[eqKey] ?? null;
809
+ }
810
+ branch[tailKey] = { [tailOp]: cursor[tailKey] ?? null };
811
+ branches.push(branch);
812
+ }
813
+ return { $match: branches.length === 1 ? branches[0] : { $or: branches } };
814
+ }
815
+ //#endregion
816
+ //#region src/query/AggregationBuilder.ts
817
+ /**
818
+ * Normalize SortSpec to MongoDB's strict format (1 | -1)
819
+ * Converts 'asc' -> 1, 'desc' -> -1
820
+ */
821
+ function normalizeSortSpec(sortSpec) {
822
+ const normalized = {};
823
+ for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
824
+ else if (order === "desc") normalized[field] = -1;
825
+ else normalized[field] = order;
826
+ return normalized;
827
+ }
828
+ /**
829
+ * Fluent builder for MongoDB aggregation pipelines
830
+ * Optimized for complex queries at scale
831
+ */
832
+ var AggregationBuilder = class AggregationBuilder {
833
+ pipeline = [];
834
+ _diskUse = false;
835
+ /**
836
+ * Get the current pipeline
837
+ */
838
+ get() {
839
+ return [...this.pipeline];
840
+ }
841
+ /**
842
+ * Build and return the final pipeline
843
+ */
844
+ build() {
845
+ return this.get();
846
+ }
847
+ /**
848
+ * Build pipeline with execution options (allowDiskUse, etc.)
849
+ */
850
+ plan() {
851
+ return {
852
+ pipeline: this.get(),
853
+ allowDiskUse: this._diskUse
854
+ };
855
+ }
856
+ /**
857
+ * Build and execute the pipeline against a model
858
+ *
859
+ * @example
860
+ * ```typescript
861
+ * const results = await new AggregationBuilder()
862
+ * .match({ status: 'active' })
863
+ * .allowDiskUse()
864
+ * .exec(MyModel);
865
+ * ```
866
+ */
867
+ async exec(model, session) {
868
+ const agg = model.aggregate(this.build());
869
+ if (this._diskUse) agg.allowDiskUse(true);
870
+ if (session) agg.session(session);
871
+ return agg.exec();
872
+ }
873
+ /**
874
+ * Reset the pipeline
875
+ */
876
+ reset() {
877
+ this.pipeline = [];
878
+ this._diskUse = false;
879
+ return this;
880
+ }
881
+ /**
882
+ * Add a raw pipeline stage
883
+ */
884
+ addStage(stage) {
885
+ this.pipeline.push(stage);
886
+ return this;
887
+ }
888
+ /**
889
+ * Add multiple raw pipeline stages
890
+ */
891
+ addStages(stages) {
892
+ this.pipeline.push(...stages);
893
+ return this;
894
+ }
895
+ /**
896
+ * $match - Filter documents
897
+ * IMPORTANT: Place $match as early as possible for performance
898
+ */
899
+ match(query) {
900
+ this.pipeline.push({ $match: query });
901
+ return this;
902
+ }
903
+ /**
904
+ * $project - Include/exclude fields or compute new fields
905
+ */
906
+ project(projection) {
907
+ this.pipeline.push({ $project: projection });
908
+ return this;
909
+ }
910
+ /**
911
+ * $group - Group documents and compute aggregations
912
+ *
913
+ * @example
914
+ * ```typescript
915
+ * .group({
916
+ * _id: '$department',
917
+ * count: { $sum: 1 },
918
+ * avgSalary: { $avg: '$salary' }
919
+ * })
920
+ * ```
921
+ */
922
+ group(groupSpec) {
923
+ this.pipeline.push({ $group: groupSpec });
924
+ return this;
925
+ }
926
+ /**
927
+ * $sort - Sort documents
928
+ */
929
+ sort(sortSpec) {
930
+ if (typeof sortSpec === "string") {
931
+ const order = sortSpec.startsWith("-") ? -1 : 1;
932
+ const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
933
+ this.pipeline.push({ $sort: { [field]: order } });
934
+ } else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
935
+ return this;
936
+ }
937
+ /**
938
+ * $limit - Limit number of documents
939
+ */
940
+ limit(count) {
941
+ this.pipeline.push({ $limit: count });
942
+ return this;
943
+ }
944
+ /**
945
+ * $skip - Skip documents
946
+ */
947
+ skip(count) {
948
+ this.pipeline.push({ $skip: count });
949
+ return this;
950
+ }
951
+ /**
952
+ * $unwind - Deconstruct array field
953
+ */
954
+ unwind(path, preserveNullAndEmptyArrays = false) {
955
+ this.pipeline.push({ $unwind: {
956
+ path: path.startsWith("$") ? path : `$${path}`,
957
+ preserveNullAndEmptyArrays
958
+ } });
959
+ return this;
960
+ }
961
+ /**
962
+ * $addFields - Add new fields or replace existing fields
963
+ */
964
+ addFields(fields) {
965
+ this.pipeline.push({ $addFields: fields });
966
+ return this;
967
+ }
968
+ /**
969
+ * $set - Alias for $addFields
970
+ */
971
+ set(fields) {
972
+ return this.addFields(fields);
973
+ }
974
+ /**
975
+ * $unset - Remove fields
976
+ */
977
+ unset(fields) {
978
+ this.pipeline.push({ $unset: fields });
979
+ return this;
980
+ }
981
+ /**
982
+ * $replaceRoot - Replace the root document
983
+ */
984
+ replaceRoot(newRoot) {
985
+ this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
986
+ return this;
987
+ }
988
+ /**
989
+ * $lookup - Join with another collection (simple form)
990
+ *
991
+ * @param from - Collection to join with
992
+ * @param localField - Field from source collection
993
+ * @param foreignField - Field from target collection
994
+ * @param as - Output field name
995
+ * @param single - Unwrap array to single object
996
+ *
997
+ * @example
998
+ * ```typescript
999
+ * // Join employees with departments by slug
1000
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1001
+ * ```
1002
+ */
1003
+ lookup(from, localField, foreignField, as, single) {
1004
+ const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
1005
+ this.pipeline.push(...stages);
1006
+ return this;
1007
+ }
1008
+ /**
1009
+ * $lookup - Join with another collection (advanced form with pipeline)
1010
+ *
1011
+ * @example
1012
+ * ```typescript
1013
+ * .lookupWithPipeline({
1014
+ * from: 'products',
1015
+ * localField: 'productIds',
1016
+ * foreignField: 'sku',
1017
+ * as: 'products',
1018
+ * pipeline: [
1019
+ * { $match: { status: 'active' } },
1020
+ * { $project: { name: 1, price: 1 } }
1021
+ * ]
1022
+ * })
1023
+ * ```
1024
+ */
1025
+ lookupWithPipeline(options) {
1026
+ const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
1027
+ if (options.as) builder.as(options.as);
1028
+ if (options.single) builder.single(options.single);
1029
+ if (options.pipeline) builder.pipeline(options.pipeline);
1030
+ if (options.let) builder.let(options.let);
1031
+ this.pipeline.push(...builder.build());
1032
+ return this;
1033
+ }
1034
+ /**
1035
+ * Multiple lookups at once
1036
+ *
1037
+ * @example
1038
+ * ```typescript
1039
+ * .multiLookup([
1040
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
1041
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
1042
+ * ])
1043
+ * ```
1044
+ */
1045
+ multiLookup(lookups) {
1046
+ const stages = LookupBuilder.multiple(lookups);
1047
+ this.pipeline.push(...stages);
1048
+ return this;
1049
+ }
1050
+ /**
1051
+ * $facet - Process multiple aggregation pipelines in a single stage
1052
+ * Useful for computing multiple aggregations in parallel
1053
+ *
1054
+ * @example
1055
+ * ```typescript
1056
+ * .facet({
1057
+ * totalCount: [{ $count: 'count' }],
1058
+ * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
1059
+ * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
1060
+ * })
1061
+ * ```
1062
+ */
1063
+ facet(facets) {
1064
+ this.pipeline.push({ $facet: facets });
1065
+ return this;
1066
+ }
1067
+ /**
1068
+ * $bucket - Categorize documents into buckets
1069
+ *
1070
+ * @example
1071
+ * ```typescript
1072
+ * .bucket({
1073
+ * groupBy: '$price',
1074
+ * boundaries: [0, 50, 100, 200],
1075
+ * default: 'Other',
1076
+ * output: {
1077
+ * count: { $sum: 1 },
1078
+ * products: { $push: '$name' }
1079
+ * }
1080
+ * })
1081
+ * ```
1082
+ */
1083
+ bucket(options) {
1084
+ this.pipeline.push({ $bucket: options });
1085
+ return this;
1086
+ }
1087
+ /**
1088
+ * $bucketAuto - Automatically determine bucket boundaries
1089
+ */
1090
+ bucketAuto(options) {
1091
+ this.pipeline.push({ $bucketAuto: options });
1092
+ return this;
1093
+ }
1094
+ /**
1095
+ * $setWindowFields - Perform window functions (MongoDB 5.0+)
1096
+ * Useful for rankings, running totals, moving averages
1097
+ *
1098
+ * @example
1099
+ * ```typescript
1100
+ * .setWindowFields({
1101
+ * partitionBy: '$department',
1102
+ * sortBy: { salary: -1 },
1103
+ * output: {
1104
+ * rank: { $rank: {} },
1105
+ * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
1106
+ * }
1107
+ * })
1108
+ * ```
1109
+ */
1110
+ setWindowFields(options) {
1111
+ const normalizedOptions = {
1112
+ ...options,
1113
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
1114
+ };
1115
+ this.pipeline.push({ $setWindowFields: normalizedOptions });
1116
+ return this;
1117
+ }
1118
+ /**
1119
+ * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
1120
+ *
1121
+ * @example
1122
+ * ```typescript
1123
+ * .unionWith({
1124
+ * coll: 'archivedOrders',
1125
+ * pipeline: [{ $match: { year: 2024 } }]
1126
+ * })
1127
+ * ```
1128
+ */
1129
+ unionWith(options) {
1130
+ this.pipeline.push({ $unionWith: options });
1131
+ return this;
1132
+ }
1133
+ /**
1134
+ * $densify - Fill gaps in data (MongoDB 5.1+)
1135
+ * Useful for time series data with missing points
1136
+ */
1137
+ densify(options) {
1138
+ this.pipeline.push({ $densify: options });
1139
+ return this;
1140
+ }
1141
+ /**
1142
+ * $fill - Fill null or missing field values (MongoDB 5.3+)
1143
+ */
1144
+ fill(options) {
1145
+ const normalizedOptions = {
1146
+ ...options,
1147
+ sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
1148
+ };
1149
+ this.pipeline.push({ $fill: normalizedOptions });
1150
+ return this;
1151
+ }
1152
+ /**
1153
+ * Enable allowDiskUse for large aggregations that exceed 100MB memory limit
1154
+ *
1155
+ * @example
1156
+ * ```typescript
1157
+ * const results = await new AggregationBuilder()
1158
+ * .match({ status: 'active' })
1159
+ * .group({ _id: '$category', total: { $sum: '$amount' } })
1160
+ * .allowDiskUse()
1161
+ * .exec(Model);
1162
+ * ```
1163
+ */
1164
+ allowDiskUse(enable = true) {
1165
+ this._diskUse = enable;
1166
+ return this;
1167
+ }
1168
+ /**
1169
+ * Paginate - Add skip and limit for offset-based pagination
1170
+ */
1171
+ paginate(page, limit) {
1172
+ const skip = (page - 1) * limit;
1173
+ return this.skip(skip).limit(limit);
1174
+ }
1175
+ /**
1176
+ * Count total documents (useful with $facet for pagination metadata)
1177
+ */
1178
+ count(outputField = "count") {
1179
+ this.pipeline.push({ $count: outputField });
1180
+ return this;
1181
+ }
1182
+ /**
1183
+ * Sample - Randomly select N documents
1184
+ */
1185
+ sample(size) {
1186
+ this.pipeline.push({ $sample: { size } });
1187
+ return this;
1188
+ }
1189
+ /**
1190
+ * Out - Write results to a collection
1191
+ */
1192
+ out(collection) {
1193
+ this.pipeline.push({ $out: collection });
1194
+ return this;
1195
+ }
1196
+ /**
1197
+ * Merge - Merge results into a collection
1198
+ */
1199
+ merge(options) {
1200
+ this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
1201
+ return this;
1202
+ }
1203
+ /**
1204
+ * GeoNear - Perform geospatial queries
1205
+ */
1206
+ geoNear(options) {
1207
+ this.pipeline.push({ $geoNear: options });
1208
+ return this;
1209
+ }
1210
+ /**
1211
+ * GraphLookup - Perform recursive search (graph traversal)
1212
+ */
1213
+ graphLookup(options) {
1214
+ this.pipeline.push({ $graphLookup: options });
1215
+ return this;
1216
+ }
1217
+ /**
1218
+ * $search - Atlas Search full-text search (Atlas only)
1219
+ *
1220
+ * @example
1221
+ * ```typescript
1222
+ * .search({
1223
+ * index: 'default',
1224
+ * text: {
1225
+ * query: 'laptop computer',
1226
+ * path: ['title', 'description'],
1227
+ * fuzzy: { maxEdits: 2 }
1228
+ * }
1229
+ * })
1230
+ * ```
1231
+ */
1232
+ search(options) {
1233
+ this.pipeline.push({ $search: options });
1234
+ return this;
1235
+ }
1236
+ /**
1237
+ * $searchMeta - Get Atlas Search metadata (Atlas only)
1238
+ */
1239
+ searchMeta(options) {
1240
+ this.pipeline.push({ $searchMeta: options });
1241
+ return this;
1242
+ }
1243
+ /**
1244
+ * $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
1245
+ *
1246
+ * Requires an Atlas Vector Search index on the target field.
1247
+ * Must be the first stage in the pipeline.
1248
+ *
1249
+ * @example
1250
+ * ```typescript
1251
+ * const results = await new AggregationBuilder()
1252
+ * .vectorSearch({
1253
+ * index: 'vector_index',
1254
+ * path: 'embedding',
1255
+ * queryVector: await getEmbedding('running shoes'),
1256
+ * limit: 10,
1257
+ * numCandidates: 100,
1258
+ * filter: { category: 'footwear' }
1259
+ * })
1260
+ * .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
1261
+ * .exec(ProductModel);
1262
+ * ```
1263
+ */
1264
+ vectorSearch(options) {
1265
+ if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
1266
+ const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
1267
+ const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
1268
+ this.pipeline.push({ $vectorSearch: {
1269
+ index: options.index,
1270
+ path: options.path,
1271
+ queryVector: options.queryVector,
1272
+ numCandidates,
1273
+ limit: options.limit,
1274
+ ...options.filter && { filter: options.filter },
1275
+ ...options.exact && { exact: options.exact }
1276
+ } });
1277
+ return this;
1278
+ }
1279
+ /**
1280
+ * Add vectorSearchScore as a field after $vectorSearch
1281
+ * Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
1282
+ */
1283
+ withVectorScore(fieldName = "score") {
1284
+ return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
1285
+ }
1286
+ /**
1287
+ * Create a builder from an existing pipeline
1288
+ */
1289
+ static from(pipeline) {
1290
+ const builder = new AggregationBuilder();
1291
+ builder.pipeline = [...pipeline];
1292
+ return builder;
1293
+ }
1294
+ /**
1295
+ * Create a builder with initial match stage
1296
+ */
1297
+ static startWith(query) {
1298
+ return new AggregationBuilder().match(query);
1299
+ }
1300
+ };
1301
+ /**
1302
+ * Optimized Aggregation Patterns for Scale
1303
+ *
1304
+ * 1. **Early Filtering** - Always place $match as early as possible:
1305
+ * ```typescript
1306
+ * new AggregationBuilder()
1307
+ * .match({ status: 'active' }) // ✅ Filter first
1308
+ * .lookup(...) // Then join
1309
+ * .sort(...)
1310
+ * ```
1311
+ *
1312
+ * 2. **Index Usage** - Ensure indexes on:
1313
+ * - Fields in $match
1314
+ * - Fields in $sort (especially with $limit)
1315
+ * - Fields in $lookup (both local and foreign)
1316
+ *
1317
+ * 3. **Projection** - Remove unnecessary fields early:
1318
+ * ```typescript
1319
+ * .project({ password: 0, internalNotes: 0 }) // Remove before joins
1320
+ * .lookup(...)
1321
+ * ```
1322
+ *
1323
+ * 4. **Faceted Pagination** - Get count and data in one query:
1324
+ * ```typescript
1325
+ * .facet({
1326
+ * metadata: [{ $count: 'total' }],
1327
+ * data: [{ $skip: skip }, { $limit: limit }]
1328
+ * })
1329
+ * ```
1330
+ *
1331
+ * 5. **allowDiskUse** - For large datasets:
1332
+ * ```typescript
1333
+ * new AggregationBuilder()
1334
+ * .match({ status: 'active' })
1335
+ * .allowDiskUse()
1336
+ * .exec(Model)
1337
+ * ```
1338
+ *
1339
+ * 6. **Vector Search** - Semantic similarity (Atlas only):
1340
+ * ```typescript
1341
+ * new AggregationBuilder()
1342
+ * .vectorSearch({ index: 'vec_idx', path: 'embedding', queryVector: vec, limit: 10 })
1343
+ * .withVectorScore()
1344
+ * .exec(Model)
1345
+ * ```
1346
+ */
1347
+ //#endregion
1348
+ //#region src/tx-bound.ts
1349
+ /**
1350
+ * Map of session-aware method name → positional index of the `{ session }`
1351
+ * options bag. Keep this list in sync with the method signatures in
1352
+ * `Repository.ts` and the plugin method definitions. Unknown plugin
1353
+ * methods pass through unwrapped (callers use the standalone helper).
1354
+ */
1355
+ const SESSION_OPTIONS_INDEX = Object.freeze({
1356
+ create: 1,
1357
+ update: 2,
1358
+ delete: 1,
1359
+ getById: 1,
1360
+ getAll: 1,
1361
+ createMany: 1,
1362
+ findAll: 1,
1363
+ getOrCreate: 2,
1364
+ count: 1,
1365
+ exists: 1,
1366
+ getByQuery: 1,
1367
+ getOne: 1,
1368
+ findOneAndUpdate: 2,
1369
+ distinct: 2,
1370
+ aggregate: 1,
1371
+ aggregatePaginate: 0,
1372
+ lookupPopulate: 0,
1373
+ upsert: 2,
1374
+ increment: 3,
1375
+ decrement: 3,
1376
+ multiplyField: 3,
1377
+ setMin: 3,
1378
+ setMax: 3,
1379
+ pushToArray: 3,
1380
+ pullFromArray: 3,
1381
+ addToSet: 3,
1382
+ setField: 3,
1383
+ unsetField: 2,
1384
+ renameField: 3,
1385
+ atomicUpdate: 2,
1386
+ updateMany: 2,
1387
+ deleteMany: 1,
1388
+ bulkWrite: 1,
1389
+ restore: 1,
1390
+ getDeleted: 1,
1391
+ addSubdocument: 3,
1392
+ getSubdocument: 3,
1393
+ updateSubdocument: 4,
1394
+ deleteSubdocument: 3,
1395
+ groupBy: 1,
1396
+ sum: 2,
1397
+ average: 2,
1398
+ min: 2,
1399
+ max: 2
1400
+ });
1401
+ /**
1402
+ * Build a session-threaded proxy over `outer`. The returned object has the
1403
+ * same method signatures, but every CRUD call auto-injects the supplied
1404
+ * session into the options bag. Non-CRUD properties (Model, modelName,
1405
+ * hook API, utility helpers) pass through.
1406
+ */
1407
+ function createTxBoundRepo(outer, session) {
1408
+ return new Proxy(outer, { get(target, prop, receiver) {
1409
+ if (prop === "withTransaction") return () => {
1410
+ throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
1411
+ };
1412
+ const value = Reflect.get(target, prop, receiver);
1413
+ if (typeof value !== "function") return value;
1414
+ if (prop === "Model") return value;
1415
+ if (typeof prop === "symbol") return value.bind(target);
1416
+ if (prop.startsWith("_")) return value.bind(target);
1417
+ const optionsIndex = SESSION_OPTIONS_INDEX[prop];
1418
+ if (optionsIndex === void 0) return value.bind(target);
1419
+ return function txBoundMethod(...args) {
1420
+ while (args.length <= optionsIndex) args.push(void 0);
1421
+ const current = args[optionsIndex];
1422
+ if (current === void 0) args[optionsIndex] = { session };
1423
+ else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
1424
+ ...current,
1425
+ session
1426
+ };
1427
+ else return value.apply(target, args);
1428
+ return value.apply(target, args);
1429
+ };
1430
+ } });
1431
+ }
1432
+ //#endregion
1433
+ //#region src/transaction.ts
1434
+ /**
1435
+ * Run a callback inside a MongoDB transaction on the given connection.
1436
+ *
1437
+ * - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
1438
+ * on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
1439
+ * always ends the session in `finally`.
1440
+ * - When `allowFallback` is true and the deployment doesn't support transactions
1441
+ * (e.g. standalone MongoDB in dev), the callback runs once without a
1442
+ * transaction on the same session. `onFallback` is invoked with the original
1443
+ * error so hosts can log the degradation.
1444
+ *
1445
+ * @example
1446
+ * ```ts
1447
+ * import mongoose from 'mongoose';
1448
+ * import { withTransaction } from '@classytic/mongokit';
1449
+ *
1450
+ * await withTransaction(mongoose.connection, async (session) => {
1451
+ * const order = await orderRepo.create(data, { session });
1452
+ * await inventoryRepo.decrement(order.items, { session });
1453
+ * return order;
1454
+ * });
1455
+ * ```
1456
+ */
1457
+ async function withTransaction(connection, callback, options = {}) {
1458
+ const session = await connection.startSession();
1459
+ try {
1460
+ return await session.withTransaction(() => callback(session), options.transactionOptions);
1461
+ } catch (error) {
1462
+ const err = error;
1463
+ if (options.allowFallback && isTransactionUnsupported(err)) {
1464
+ options.onFallback?.(err);
1465
+ return await callback(session);
1466
+ }
1467
+ throw err;
1468
+ } finally {
1469
+ await session.endSession();
1470
+ }
1471
+ }
1472
+ /**
1473
+ * Multi-repo transactional batch — every repo in `repos` becomes a
1474
+ * session-bound proxy inside the callback. Eliminates the per-call
1475
+ * `{ session }` threading that was repeated across ~20 call sites
1476
+ * (be-prod outbox writes, order placement, transfer source/dest pairs,
1477
+ * payrun saga steps).
1478
+ *
1479
+ * Same retry + fallback semantics as `withTransaction`: the
1480
+ * `transactionOptions` / `allowFallback` / `onFallback` knobs apply.
1481
+ *
1482
+ * Each property of the input `repos` map is rebound via the
1483
+ * `createTxBoundRepo` proxy — every CRUD method on the bound repo
1484
+ * auto-injects `session` into its options bag, including
1485
+ * `claim` / `claimVersion` / `findOneAndUpdate` and plugin-contributed
1486
+ * methods. Non-CRUD properties (Model, modelName, hook engine,
1487
+ * idField) pass through to the underlying repo. Nested
1488
+ * `boundRepo.withTransaction(...)` throws — reuse the outer bound
1489
+ * repos.
1490
+ *
1491
+ * @example Order placement across three repos in one transaction
1492
+ * ```ts
1493
+ * import { batchTransaction } from '@classytic/mongokit';
1494
+ *
1495
+ * const order = await batchTransaction(
1496
+ * mongoose.connection,
1497
+ * { orders: orderRepo, events: eventRepo, inventory: inventoryRepo },
1498
+ * async ({ orders, events, inventory }) => {
1499
+ * const created = await orders.create(orderData); // session auto-injected
1500
+ * await events.create({ type: 'order.placed', orderId: created._id });
1501
+ * await inventory.claim(skuId, { from: 'available', to: 'reserved' });
1502
+ * return created;
1503
+ * },
1504
+ * );
1505
+ * ```
1506
+ *
1507
+ * @example With fallback for standalone-mongo dev environments
1508
+ * ```ts
1509
+ * await batchTransaction(
1510
+ * mongoose.connection,
1511
+ * { orders, events },
1512
+ * async ({ orders, events }) => { ... },
1513
+ * { allowFallback: true, onFallback: (err) => log.warn(err) },
1514
+ * );
1515
+ * ```
1516
+ *
1517
+ * @param connection - Mongoose connection (or anything with
1518
+ * `startSession()`). Single source of session truth — every bound
1519
+ * repo shares this session.
1520
+ * @param repos - Map of repo instances to rebind. Keys become the
1521
+ * property names on the callback's argument.
1522
+ * @param callback - Receives the bound repo map. Return value flows
1523
+ * through to the outer `Promise`.
1524
+ */
1525
+ async function batchTransaction(connection, repos, callback, options = {}) {
1526
+ return withTransaction(connection, async (session) => {
1527
+ const bound = {};
1528
+ for (const key of Object.keys(repos)) {
1529
+ const repo = repos[key];
1530
+ bound[key] = createTxBoundRepo(repo, session);
1531
+ }
1532
+ return callback(bound);
1533
+ }, options);
1534
+ }
1535
+ /**
1536
+ * Detect whether an error indicates the MongoDB deployment does not support
1537
+ * multi-document transactions (standalone server, older topology, etc.).
1538
+ *
1539
+ * Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
1540
+ * topology) — with a message-matching fallback for edge cases surfaced by
1541
+ * driver versions that throw before the proper code lands.
1542
+ *
1543
+ * **Driver-version drift:** modern mongoose / mongodb-driver versions hit
1544
+ * the standalone case via the retryable-writes precondition rather than
1545
+ * the transaction precondition (`retryWrites=true` is on by default,
1546
+ * standalone Mongo rejects it before the transaction is even attempted).
1547
+ * So we accept that message as equivalent — the underlying topology
1548
+ * problem is the same, and the fallback semantics are correct either way.
1549
+ */
1550
+ function isTransactionUnsupported(error) {
1551
+ const code = error.code;
1552
+ if (code === 263 || code === 20) return true;
1553
+ const message = (error.message || "").toLowerCase();
1554
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported") || message.includes("does not support retryable writes");
1555
+ }
1556
+ //#endregion
1557
+ //#region src/Repository.ts
1558
+ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1559
+ if (!projection || !sort) return projection;
1560
+ if (!Object.values(projection).some((value) => value === 1)) return projection;
1561
+ const nextProjection = { ...projection };
1562
+ for (const field of [...Object.keys(sort), "_id"]) nextProjection[field] = 1;
1563
+ return nextProjection;
1564
+ }
1565
+ /**
1566
+ * Validates that an update patch is homogeneous — either all top-level
1567
+ * keys are `$`-prefixed Mongo operators OR none are. Mixed shapes (e.g.
1568
+ * `{ $set: {...}, status: 'x' }`) cause Mongo to **silently drop** the
1569
+ * flat keys, which is the kind of write-loss bug we won't ship.
1570
+ *
1571
+ * Hoisted into a named helper so the error stack trace points directly
1572
+ * at this function — debugging a "patch mixes operators" failure is now
1573
+ * trivial without needing to read the call site.
1574
+ *
1575
+ * @throws TypeError-style HttpError(400) when the patch is mixed.
1576
+ */
1577
+ function assertNoMixedPatchShape(opName, patch) {
1578
+ const patchKeys = Object.keys(patch);
1579
+ const operatorKeys = patchKeys.filter((k) => k.startsWith("$"));
1580
+ if (operatorKeys.length === 0 || operatorKeys.length === patchKeys.length) return;
1581
+ const flatKeys = patchKeys.filter((k) => !k.startsWith("$"));
1582
+ throw createError(400, `[${opName}] assertNoMixedPatchShape: patch mixes Mongo operators (${operatorKeys.join(", ")}) with raw field keys (${flatKeys.join(", ")}). Mongo would silently DROP the flat keys — that's a write-loss bug we refuse to forward. Either wrap the flat keys in $set explicitly, or remove the operator keys.`);
1583
+ }
1584
+ /**
1585
+ * Plugin phase priorities (lower = runs first).
1586
+ *
1587
+ * Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
1588
+ * the same constant whether they import from mongokit or directly from
1589
+ * repo-core — priorities are identical (POLICY=100, CACHE=200,
1590
+ * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
1591
+ */
1592
+ const HOOK_PRIORITY$1 = HOOK_PRIORITY;
1593
+ /**
1594
+ * Production-grade repository for MongoDB.
1595
+ *
1596
+ * Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
1597
+ * driver-agnostic hook + plugin plumbing (context builder, priority-sorted
1598
+ * event engine, plugin-order validator) while layering every Mongo-specific
1599
+ * concern — populate, aggregate, lookup, keyset + cursor pagination, $near
1600
+ * rewriting — on top.
1601
+ *
1602
+ * Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
1603
+ * preserved as a live read-through getter; all CRUD, event, and plugin method
1604
+ * signatures unchanged). 3.10 is an internal re-plumbing release; consumers
1605
+ * upgrading from 3.9 need no code changes.
1606
+ */
1607
+ var Repository = class extends RepositoryBase {
1608
+ Model;
1609
+ /**
1610
+ * Mongoose model name. Duplicated on the instance for BC — arc / catalog /
1611
+ * user code read `repo.model`. `RepositoryBase.modelName` points to the
1612
+ * same string; prefer `modelName` in new code.
1613
+ */
1614
+ model;
1615
+ _pagination;
1616
+ idField;
1617
+ searchMode;
1618
+ searchFields;
1619
+ _hasTextIndex = null;
1620
+ /**
1621
+ * Wrap-style middleware chain. Composed around every `_runOp`
1622
+ * invocation in registration order — earlier-registered middlewares
1623
+ * are outer (run first / unwind last). See `useMiddleware()` and
1624
+ * the `Middleware<TDoc>` type for the protocol.
1625
+ */
1626
+ _middlewares = [];
1627
+ constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
1628
+ super({
1629
+ name: Model.modelName,
1630
+ hooks: options.hooks ?? "async",
1631
+ pluginOrderChecks: "off"
1632
+ });
1633
+ validatePluginOrder(plugins, Model.modelName, options.pluginOrderChecks ?? "warn", (m) => {
1634
+ warn(m);
1635
+ });
1636
+ this.Model = Model;
1637
+ this.model = Model.modelName;
1638
+ this._pagination = new PaginationEngine(Model, paginationConfig);
1639
+ this.idField = options.idField ?? "_id";
1640
+ this.searchMode = options.searchMode ?? "text";
1641
+ this.searchFields = options.searchFields;
1642
+ this._wireStrictQueryStripDiagnostic(options.warnOnStrictQueryStrip === true);
1643
+ if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
1644
+ for (const plugin of plugins) this.use(plugin);
1645
+ if (options.requirePlugins && options.requirePlugins.length > 0) {
1646
+ const installedNames = new Set(plugins.map((p) => p?.name).filter(Boolean));
1647
+ const missing = options.requirePlugins.filter((name) => !installedNames.has(name));
1648
+ if (missing.length > 0) throw new TypeError(`[mongokit] Repository "${Model.modelName}" requires plugin(s) that are not installed: ${missing.join(", ")}. Add the missing plugin(s) to the constructor's \`plugins\` array, or remove the name from \`options.requirePlugins\`. Installed plugins: ${[...installedNames].join(", ") || "(none)"}.`);
1649
+ }
1650
+ }
1651
+ /**
1652
+ * Live read-through view of the hook engine's listener registry.
1653
+ *
1654
+ * Preserved as a public field on the Repository for back-compat with
1655
+ * mongokit ≤3.9 — existing tests and observability code pattern-match
1656
+ * `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
1657
+ * returns a fresh snapshot of the underlying HookEngine state.
1658
+ *
1659
+ * Read-only. To register listeners use `repo.on(event, listener, { priority })`.
1660
+ */
1661
+ get _hooks() {
1662
+ return this.hooks.listeners();
1663
+ }
1664
+ /**
1665
+ * Fire-and-forget emit aliased through RepositoryBase's async path so
1666
+ * error hooks converted to/from sync mode still route uniformly. Mongokit
1667
+ * ≤3.9 made this a private method on its subclass; the base class exposes
1668
+ * it publicly and wraps it in `_emitHook` below for internal use.
1669
+ */
1670
+ async _emitHook(event, data) {
1671
+ await this.hooks.emitAccordingToMode(event, data);
1672
+ }
1673
+ async _emitErrorHook(event, data) {
1674
+ try {
1675
+ await this._emitHook(event, data);
1676
+ } catch (hookError) {
1677
+ warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
1678
+ }
1679
+ }
1680
+ /**
1681
+ * Wire the `warnOnStrictQueryStrip` diagnostic. When enabled, every
1682
+ * filter-shaped op (`getById`, `getByQuery`, `getOne`, `findAll`,
1683
+ * `count`, `exists`, `update`, `delete`, `findOneAndUpdate`,
1684
+ * `updateMany`, `deleteMany`, `claim`, `claimVersion`, `cursor`)
1685
+ * checks its `context.query` keys against the cached set of schema
1686
+ * paths. Top-level keys not on the schema get logged once per
1687
+ * `(model, key)` pair — so a single bad caller surfaces in dev /
1688
+ * staging instead of returning the wrong row in prod.
1689
+ *
1690
+ * Only runs when `strictQuery !== false` is in effect — if mongoose's
1691
+ * strictQuery is off, unknown keys flow through to the driver and
1692
+ * the trap doesn't apply.
1693
+ */
1694
+ _wireStrictQueryStripDiagnostic(enabled) {
1695
+ if (!enabled) return;
1696
+ const schema = this.Model.schema;
1697
+ const declaredPaths = new Set(["_id"]);
1698
+ for (const path of Object.keys(schema.paths)) {
1699
+ declaredPaths.add(path);
1700
+ const segments = path.split(".");
1701
+ for (let i = 1; i < segments.length; i++) declaredPaths.add(segments.slice(0, i).join("."));
1702
+ }
1703
+ const warned = /* @__PURE__ */ new Set();
1704
+ const modelName = this.model;
1705
+ const checkFilter = (filter) => {
1706
+ if ((schema.options.strictQuery ?? mongoose.get?.("strictQuery")) === false) return;
1707
+ if (!filter || typeof filter !== "object") return;
1708
+ for (const key of Object.keys(filter)) {
1709
+ if (key.startsWith("$")) continue;
1710
+ const head = key.split(".")[0];
1711
+ if (declaredPaths.has(head) || declaredPaths.has(key)) continue;
1712
+ const dedupeKey = `${modelName}.${key}`;
1713
+ if (warned.has(dedupeKey)) continue;
1714
+ warned.add(dedupeKey);
1715
+ warn(`[mongokit] '${modelName}.${key}' filter key not on schema — strictQuery will silently strip it. Add the field to the schema, or use 'strict: false' / 'strictQuery: false' if you intend dynamic keys.`);
1716
+ }
1717
+ };
1718
+ const observabilityPriority = { priority: 300 };
1719
+ for (const op of operationsByPolicyKey("query")) this.on(`before:${op}`, (context) => {
1720
+ checkFilter(context.query);
1721
+ }, observabilityPriority);
1722
+ for (const op of operationsByPolicyKey("filters")) this.on(`before:${op}`, (context) => {
1723
+ checkFilter(context.filters);
1724
+ }, observabilityPriority);
1725
+ }
1726
+ /**
1727
+ * Register a wrap-style middleware. Middleware composes AROUND every
1728
+ * `_runOp` invocation — it sees the operation name, the live
1729
+ * `RepositoryContext`, and a `next()` continuation that runs the
1730
+ * remaining chain + the actual op + after/error hooks.
1731
+ *
1732
+ * **Composes with `repo.on()` and plugins, doesn't replace them.**
1733
+ * Middleware is for ergonomics (timing, short-circuit, input/output
1734
+ * mutation in a single closure) — NOT for security policy. Use the
1735
+ * `before:*` hook engine for tenant scope, soft-delete filtering,
1736
+ * cache invalidation, and audit; those plugins MUST run before
1737
+ * middleware sees the op.
1738
+ *
1739
+ * **Execution order on every call** (build/before hooks fire BEFORE
1740
+ * the middleware chain dispatches, so middleware cannot wrap a
1741
+ * before-hook failure):
1742
+ *
1743
+ * ```text
1744
+ * _buildContext + before:<op> ← repo.on('before:*') hooks
1745
+ * [outer middleware pre] ← repo.useMiddleware() registrations
1746
+ * [...inner middleware pre]
1747
+ * fn (driver call)
1748
+ * after:<op> | error:<op> ← repo.on('after:*' / 'error:*')
1749
+ * [...inner middleware post]
1750
+ * [outer middleware post]
1751
+ * ```
1752
+ *
1753
+ * Why this order matters: a `before:create` hook that injects a
1754
+ * tenant filter into `context.query` runs BEFORE the middleware chain
1755
+ * is composed. Middleware sees a context that was already scoped by
1756
+ * the policy plugins — it never has the authority to short-circuit a
1757
+ * tenant check, because the throw from a `before:*` policy hook
1758
+ * unwinds before middleware ever fires. That's by design — middleware
1759
+ * as a security boundary would be impossible to audit, since
1760
+ * registration order would determine whether scope wins.
1761
+ *
1762
+ * Practical consequence: if you want middleware to observe a policy
1763
+ * failure (e.g. metric a tenant-scope rejection), use `repo.on('error:
1764
+ * <op>', ...)` instead — that fires from inside the middleware chain
1765
+ * and is reachable.
1766
+ *
1767
+ * Registration order = composition order: the first middleware
1768
+ * registered runs outermost (wraps everything else).
1769
+ *
1770
+ * @example Time every op
1771
+ * ```ts
1772
+ * repo.useMiddleware(async ({ operation, next }) => {
1773
+ * const start = performance.now();
1774
+ * try { return await next(); }
1775
+ * finally { metrics.record(operation, performance.now() - start); }
1776
+ * });
1777
+ * ```
1778
+ *
1779
+ * @example Inject `tenantId` on every create
1780
+ * ```ts
1781
+ * repo.useMiddleware(async ({ operation, context, next }) => {
1782
+ * if (operation === 'create' && context.data) {
1783
+ * context.data.tenantId = currentTenant();
1784
+ * }
1785
+ * return next();
1786
+ * });
1787
+ * ```
1788
+ *
1789
+ * @example Short-circuit (return without `next()` to skip the actual op)
1790
+ * ```ts
1791
+ * repo.useMiddleware(async ({ operation, context, next }) => {
1792
+ * if (operation === 'getById' && readOnlyMaintenance) {
1793
+ * return cachedReadOnlyResponse(context.id);
1794
+ * }
1795
+ * return next();
1796
+ * });
1797
+ * ```
1798
+ */
1799
+ useMiddleware(middleware) {
1800
+ this._middlewares.push(middleware);
1801
+ return this;
1802
+ }
1803
+ /**
1804
+ * Compose the registered middleware chain around `exec`. Outermost
1805
+ * middleware (first registered) wraps innermost. Each middleware sees
1806
+ * the live `RepositoryContext` (already populated + run through
1807
+ * `before:*` hooks at the call site — see `useMiddleware` JSDoc for
1808
+ * the exact order). `after:` / `error:` hooks fire from inside
1809
+ * `exec`, so middleware composes WITH plugins, not instead of them.
1810
+ *
1811
+ * `_runOp` calls this with its standard try/catch envelope; inline-try
1812
+ * methods (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
1813
+ * `lookupPopulate`, plus `getOne`/`getAll` cache-hit branches) call it
1814
+ * directly so middleware sees every op — including cached reads — not
1815
+ * just the ones routed through `_runOp`.
1816
+ */
1817
+ _composeMiddleware(op, context, exec) {
1818
+ if (this._middlewares.length === 0) return exec();
1819
+ let chain = exec;
1820
+ for (let i = this._middlewares.length - 1; i >= 0; i--) {
1821
+ const mw = this._middlewares[i];
1822
+ const next = chain;
1823
+ chain = async () => await mw({
1824
+ operation: op,
1825
+ context,
1826
+ repo: this,
1827
+ next
1828
+ });
1829
+ }
1830
+ return chain();
1831
+ }
1832
+ /**
1833
+ * Standard operation envelope: compose middleware around an exec that
1834
+ * runs `fn`, emits `after:<op>` on success or `error:<op>` on failure.
1835
+ * Methods with branched in-try logic that emit `after:*` from multiple
1836
+ * paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
1837
+ * `lookupPopulate`) keep their inline try/catch and call
1838
+ * `_composeMiddleware` directly instead of being forced through this
1839
+ * single-result helper.
1840
+ */
1841
+ async _runOp(op, context, fn) {
1842
+ return this._composeMiddleware(op, context, async () => {
1843
+ try {
1844
+ const result = await fn();
1845
+ await this._emitHook(`after:${op}`, {
1846
+ context,
1847
+ result
1848
+ });
1849
+ return result;
1850
+ } catch (error) {
1851
+ await this._emitErrorHook(`error:${op}`, {
1852
+ context,
1853
+ error
1854
+ });
1855
+ throw this._handleError(error);
1856
+ }
1857
+ });
1858
+ }
1859
+ /**
1860
+ * Create single document
1861
+ */
1862
+ async create(data, options = {}) {
1863
+ const context = await this._buildContext("create", {
1864
+ data,
1865
+ ...options
1866
+ });
1867
+ return this._runOp("create", context, () => create(this.Model, context.data || data, options));
1868
+ }
1869
+ /**
1870
+ * Create multiple documents
1871
+ */
1872
+ async createMany(dataArray, options = {}) {
1873
+ const context = await this._buildContext("createMany", {
1874
+ dataArray,
1875
+ ...options
1876
+ });
1877
+ return this._runOp("createMany", context, () => createMany(this.Model, context.dataArray || dataArray, options));
1878
+ }
1879
+ /**
1880
+ * Get document by ID
1881
+ */
1882
+ async getById(id, options = {}) {
1883
+ const populateSpec = options.populateOptions || options.populate;
1884
+ const context = await this._buildContext("getById", {
1885
+ id,
1886
+ ...options,
1887
+ populate: populateSpec
1888
+ });
1889
+ return this._composeMiddleware("getById", context, async () => {
1890
+ if (context._cacheHit) {
1891
+ const cachedResult = context._cachedResult;
1892
+ await this._emitHook("after:getById", {
1893
+ context,
1894
+ result: cachedResult,
1895
+ fromCache: true
1896
+ });
1897
+ return cachedResult;
1898
+ }
1899
+ try {
1900
+ const effectiveIdField = options.idField ?? this.idField;
1901
+ if (effectiveIdField !== "_id") {
1902
+ const result = await getByQuery(this.Model, {
1903
+ [effectiveIdField]: id,
1904
+ ...context.query || {}
1905
+ }, context);
1906
+ await this._emitHook("after:getById", {
1907
+ context,
1908
+ result
1909
+ });
1910
+ return result;
1911
+ }
1912
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
1913
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
1914
+ if (wantsThrow) throw createError(404, "Document not found");
1915
+ return null;
1916
+ }
1917
+ const result = await getById(this.Model, id, context);
1918
+ if (!result && wantsThrow) throw createError(404, "Document not found");
1919
+ await this._emitHook("after:getById", {
1920
+ context,
1921
+ result
1922
+ });
1923
+ return result;
1924
+ } catch (error) {
1925
+ await this._emitErrorHook("error:getById", {
1926
+ context,
1927
+ error
1928
+ });
1929
+ throw this._handleError(error);
1930
+ }
1931
+ });
1932
+ }
1933
+ /**
1934
+ * Get single document by query
1935
+ */
1936
+ async getByQuery(query, options = {}) {
1937
+ const populateSpec = options.populateOptions || options.populate;
1938
+ const context = await this._buildContext("getByQuery", {
1939
+ query,
1940
+ ...options,
1941
+ populate: populateSpec
1942
+ });
1943
+ if (context._cacheHit) {
1944
+ const cachedResult = context._cachedResult;
1945
+ await this._emitHook("after:getByQuery", {
1946
+ context,
1947
+ result: cachedResult,
1948
+ fromCache: true
1949
+ });
1950
+ return cachedResult;
1951
+ }
1952
+ const finalQuery = context.query || query;
1953
+ return this._runOp("getByQuery", context, () => getByQuery(this.Model, finalQuery, context));
1954
+ }
1955
+ /**
1956
+ * Get single document by arbitrary filter.
1957
+ * Unlike getByQuery, this method is designed for Arc/controller use where
1958
+ * compound filters (org scope + policy + id) are pre-built.
1959
+ *
1960
+ * @example
1961
+ * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
1962
+ */
1963
+ async getOne(query, options = {}) {
1964
+ const populateSpec = options.populateOptions || options.populate;
1965
+ const context = await this._buildContext("getOne", {
1966
+ query,
1967
+ ...options,
1968
+ populate: populateSpec
1969
+ });
1970
+ return this._composeMiddleware("getOne", context, async () => {
1971
+ if (context._cacheHit) {
1972
+ const cachedResult = context._cachedResult;
1973
+ await this._emitHook("after:getOne", {
1974
+ context,
1975
+ result: cachedResult,
1976
+ fromCache: true
1977
+ });
1978
+ return cachedResult;
1979
+ }
1980
+ const finalQuery = context.query || query;
1981
+ try {
1982
+ const result = await getByQuery(this.Model, finalQuery, context);
1983
+ await this._emitHook("after:getOne", {
1984
+ context,
1985
+ result
1986
+ });
1987
+ return result;
1988
+ } catch (error) {
1989
+ await this._emitErrorHook("error:getOne", {
1990
+ context,
1991
+ error
1992
+ });
1993
+ throw this._handleError(error);
1994
+ }
1995
+ });
1996
+ }
1997
+ /**
1998
+ * Fetch ALL documents matching filters without pagination.
1999
+ * Use for background jobs, exports, batch processing where you need every doc.
2000
+ *
2001
+ * @example
2002
+ * const all = await repo.findAll({ status: 'active' });
2003
+ * const allLean = await repo.findAll({}, { select: 'name email', lean: true });
2004
+ */
2005
+ async findAll(filters = {}, options = {}) {
2006
+ const context = await this._buildContext("findAll", {
2007
+ query: filters,
2008
+ ...options
2009
+ });
2010
+ const resolvedFilters = context.query ?? filters;
2011
+ return this._runOp("findAll", context, async () => {
2012
+ const query = this.Model.find(resolvedFilters);
2013
+ const sortSpec = context.sort || options.sort;
2014
+ if (sortSpec) query.sort(this._parseSort(sortSpec));
2015
+ const selectSpec = context.select || options.select;
2016
+ if (selectSpec) query.select(selectSpec);
2017
+ if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
2018
+ if (context.lean ?? options.lean ?? true) query.lean();
2019
+ if (options.session) query.session(options.session);
2020
+ if (options.readPreference) query.read(options.readPreference);
2021
+ return await query.exec();
2022
+ });
2023
+ }
2024
+ /**
2025
+ * Streaming reads — async iterator over data, suitable for migrations,
2026
+ * backfills, schema audits, and any once-a-quarter "touch every row"
2027
+ * job. Goes through the standard `before:cursor` hook pipeline so
2028
+ * multi-tenant scope, soft-delete filter, and access-control plugins
2029
+ * inject scope BEFORE the underlying mongoose cursor is built.
2030
+ *
2031
+ * Replaces direct `Model.find().cursor()` usage which bypasses every
2032
+ * plugin — that's how cross-tenant data ends up in migrations.
2033
+ *
2034
+ * Returns an `AsyncIterableIterator<TDoc>` — drive it with `for await`.
2035
+ * The mongoose cursor is closed when iteration completes or breaks.
2036
+ *
2037
+ * @example Backfill a missing field across the whole tenant scope
2038
+ * ```ts
2039
+ * for await (const doc of repo.cursor({ filter: { migrated: { $ne: true }}}, { batchSize: 1000 })) {
2040
+ * await transformer(doc);
2041
+ * await repo.update(doc._id, { migrated: true });
2042
+ * }
2043
+ * ```
2044
+ *
2045
+ * @example Audit unscoped — opt out of multi-tenant for global sweeps
2046
+ * ```ts
2047
+ * for await (const doc of repo.cursor({}, { batchSize: 500, organizationId: undefined })) {
2048
+ * audit(doc);
2049
+ * }
2050
+ * ```
2051
+ */
2052
+ async *cursor(filter = {}, options = {}) {
2053
+ const context = await this._buildContext("cursor", {
2054
+ query: filter,
2055
+ ...options
2056
+ });
2057
+ const resolvedFilter = context.query ?? filter;
2058
+ const query = this.Model.find(resolvedFilter);
2059
+ const sortSpec = context.sort || options.sort;
2060
+ if (sortSpec) query.sort(this._parseSort(sortSpec));
2061
+ if (options.select) query.select(options.select);
2062
+ if (options.batchSize) query.batchSize(options.batchSize);
2063
+ if (context.lean ?? options.lean ?? true) query.lean();
2064
+ if (options.session) query.session(options.session);
2065
+ if (options.readPreference) query.read(options.readPreference);
2066
+ const stream = query.cursor();
2067
+ let yieldedCount = 0;
2068
+ try {
2069
+ for await (const doc of stream) {
2070
+ yieldedCount++;
2071
+ yield doc;
2072
+ }
2073
+ await this._emitHook("after:cursor", {
2074
+ context,
2075
+ result: { count: yieldedCount }
2076
+ });
2077
+ } catch (error) {
2078
+ await this._emitErrorHook("error:cursor", {
2079
+ context,
2080
+ error
2081
+ });
2082
+ throw this._handleError(error);
2083
+ } finally {
2084
+ if (typeof stream.close === "function") await stream.close().catch(() => {});
2085
+ }
2086
+ }
2087
+ /**
2088
+ * Unified pagination - auto-detects offset vs keyset based on params
2089
+ *
2090
+ * Auto-detection logic:
2091
+ * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
2092
+ * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
2093
+ * - Else → defaults to offset pagination with page=1
2094
+ *
2095
+ * @example
2096
+ * // Offset pagination (page-based)
2097
+ * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
2098
+ * await repo.getAll({ pagination: { page: 2, limit: 20 } });
2099
+ *
2100
+ * // Keyset pagination (cursor-based)
2101
+ * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
2102
+ * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
2103
+ *
2104
+ * // Simple query (defaults to page 1)
2105
+ * await repo.getAll({ filters: { status: 'active' } });
2106
+ *
2107
+ * // Skip cache for fresh data
2108
+ * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
2109
+ */
2110
+ async getAll(params = {}, options = {}) {
2111
+ const normalizedParams = {
2112
+ ...params,
2113
+ page: params.page ?? params.pagination?.page,
2114
+ limit: params.limit ?? params.pagination?.limit
2115
+ };
2116
+ const context = await this._buildContext("getAll", {
2117
+ ...normalizedParams,
2118
+ ...options
2119
+ });
2120
+ if (context._cacheHit) return this._composeMiddleware("getAll", context, async () => {
2121
+ const cachedResult = context._cachedResult;
2122
+ await this._emitHook("after:getAll", {
2123
+ context,
2124
+ result: cachedResult,
2125
+ fromCache: true
2126
+ });
2127
+ return cachedResult;
2128
+ });
2129
+ if (params.noPagination) return this.findAll(context.filters ?? params.filters ?? {}, {
2130
+ ...options,
2131
+ sort: context.sort ?? params.sort
2132
+ });
2133
+ const filters = context.filters ?? params.filters ?? {};
2134
+ const search = context.search;
2135
+ const isNearQuery = hasNearOperator(filters);
2136
+ const sort = this._resolveSort(context, params, isNearQuery);
2137
+ const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
2138
+ const page = context.page ?? params.pagination?.page ?? params.page;
2139
+ const after = context.after ?? params.cursor ?? params.after;
2140
+ const mode = context.mode ?? params.mode;
2141
+ const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
2142
+ const query = this._buildSearchQuery(filters, search);
2143
+ const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
2144
+ const paginationOptions = {
2145
+ filters: query,
2146
+ sort: isNearQuery ? void 0 : this._parseSort(sort),
2147
+ limit,
2148
+ populate: this._parsePopulate(populateSpec),
2149
+ select: context.select || options.select,
2150
+ lean: context.lean ?? options.lean ?? true,
2151
+ session: options.session,
2152
+ hint: context.hint ?? params.hint,
2153
+ maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
2154
+ readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
2155
+ collation: context.collation ?? params.collation
2156
+ };
2157
+ const lookups = context.lookups ?? params.lookups;
2158
+ if (lookups && lookups.length > 0) return this._runOp("getAll", context, async () => {
2159
+ return await this.lookupPopulate({
2160
+ filters: query,
2161
+ lookups,
2162
+ sort: paginationOptions.sort,
2163
+ page: useKeyset ? void 0 : page || 1,
2164
+ after: useKeyset ? after : void 0,
2165
+ limit,
2166
+ select: paginationOptions.select,
2167
+ session: options.session,
2168
+ readPreference: paginationOptions.readPreference,
2169
+ collation: paginationOptions.collation,
2170
+ countStrategy: context.countStrategy ?? params.countStrategy
2171
+ });
2172
+ });
2173
+ return this._runOp("getAll", context, async () => {
2174
+ let result;
2175
+ if (useKeyset) result = await this._pagination.stream({
2176
+ ...paginationOptions,
2177
+ sort: paginationOptions.sort,
2178
+ after
2179
+ });
2180
+ else {
2181
+ let countFilters;
2182
+ let forcedCountStrategy;
2183
+ if (isNearQuery) {
2184
+ const rewritten = rewriteNearForCount(query);
2185
+ if (rewritten) countFilters = rewritten;
2186
+ else forcedCountStrategy = "none";
2187
+ }
2188
+ result = await this._pagination.paginate({
2189
+ ...paginationOptions,
2190
+ page: page || 1,
2191
+ countFilters,
2192
+ countStrategy: context.countStrategy ?? params.countStrategy ?? forcedCountStrategy
2193
+ });
2194
+ }
2195
+ return result;
2196
+ });
2197
+ }
2198
+ /**
2199
+ * Atomic "look up by filter, insert `data` if missing, return the doc."
2200
+ *
2201
+ * Returns `{ doc, created }` so callers can tell whether *this* call
2202
+ * inserted (race-win) or matched an existing row. Implements
2203
+ * `StandardRepo.getOrCreate()` from `@classytic/repo-core/repository`
2204
+ * — see that interface's JSDoc for the cross-kit contract.
2205
+ *
2206
+ * Routes through the hook system for policy enforcement (multi-tenant,
2207
+ * soft-delete, audit). The hook context carries the resolved query +
2208
+ * data; the action layer adds the `created` discriminator from the
2209
+ * driver's `lastErrorObject.upserted`.
2210
+ */
2211
+ async getOrCreate(query, createData, options = {}) {
2212
+ const context = await this._buildContext("getOrCreate", {
2213
+ query,
2214
+ data: createData,
2215
+ ...options
2216
+ });
2217
+ return this._runOp("getOrCreate", context, () => {
2218
+ const finalQuery = context.query || query;
2219
+ const finalData = context.data || createData;
2220
+ return getOrCreate(this.Model, finalQuery, finalData, options);
2221
+ });
2222
+ }
2223
+ /**
2224
+ * Count documents
2225
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
2226
+ */
2227
+ async count(query = {}, options = {}) {
2228
+ const context = await this._buildContext("count", {
2229
+ query,
2230
+ ...options
2231
+ });
2232
+ return this._runOp("count", context, () => count(this.Model, context.query || query, options));
2233
+ }
2234
+ /**
2235
+ * Check if document exists
2236
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
2237
+ */
2238
+ async exists(query, options = {}) {
2239
+ const context = await this._buildContext("exists", {
2240
+ query,
2241
+ ...options
2242
+ });
2243
+ return this._runOp("exists", context, () => exists(this.Model, context.query || query, options));
2244
+ }
2245
+ /**
2246
+ * Update document by ID
2247
+ */
2248
+ async update(id, data, options = {}) {
2249
+ const context = await this._buildContext("update", {
2250
+ id,
2251
+ data,
2252
+ ...options
2253
+ });
2254
+ return this._composeMiddleware("update", context, async () => {
2255
+ try {
2256
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
2257
+ const effectiveIdField = options.idField ?? this.idField;
2258
+ if (effectiveIdField === "_id") {
2259
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
2260
+ if (wantsThrow) throw createError(404, "Document not found");
2261
+ await this._emitHook("after:update", {
2262
+ context,
2263
+ result: null
2264
+ });
2265
+ return null;
2266
+ }
2267
+ }
2268
+ let result;
2269
+ if (effectiveIdField !== "_id") result = await updateByQuery(this.Model, {
2270
+ [effectiveIdField]: id,
2271
+ ...context.query || {}
2272
+ }, context.data || data, context);
2273
+ else result = await update(this.Model, id, context.data || data, context);
2274
+ if (!result && wantsThrow) throw createError(404, "Document not found");
2275
+ await this._emitHook("after:update", {
2276
+ context,
2277
+ result
2278
+ });
2279
+ return result;
2280
+ } catch (error) {
2281
+ await this._emitErrorHook("error:update", {
2282
+ context,
2283
+ error
2284
+ });
2285
+ throw this._handleError(error);
2286
+ }
2287
+ });
2288
+ }
2289
+ /**
2290
+ * Atomic findOneAndUpdate (compare-and-set primitive).
2291
+ *
2292
+ * Single round-trip match-and-mutate for outbox relays, distributed locks,
2293
+ * and workflow semaphores. Goes through the full hook pipeline so
2294
+ * multi-tenant scope, soft-delete, audit, and cache plugins all apply.
2295
+ *
2296
+ * Returns the matched document (post-update by default) or `null` when no
2297
+ * document matches and `upsert` is false.
2298
+ *
2299
+ * @example FIFO claim-lease for an outbox worker
2300
+ * ```ts
2301
+ * const claimed = await outboxRepo.findOneAndUpdate(
2302
+ * { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
2303
+ * { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
2304
+ * { sort: { createdAt: 1 }, returnDocument: 'after' },
2305
+ * );
2306
+ * if (!claimed) return; // queue empty
2307
+ * ```
2308
+ *
2309
+ * @example CAS upsert
2310
+ * ```ts
2311
+ * const lock = await locksRepo.findOneAndUpdate(
2312
+ * { _id: lockKey, ownerId: { $in: [null, workerId] } },
2313
+ * { $set: { ownerId: workerId, expiresAt: leaseUntil } },
2314
+ * { upsert: true },
2315
+ * );
2316
+ * ```
2317
+ */
2318
+ async findOneAndUpdate(filter, update, options = {}) {
2319
+ const normalizedUpdate = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
2320
+ const context = await this._buildContext("findOneAndUpdate", {
2321
+ query: filter,
2322
+ data: normalizedUpdate,
2323
+ ...options
2324
+ });
2325
+ return this._runOp("findOneAndUpdate", context, async () => {
2326
+ const finalQuery = context.query || filter;
2327
+ const finalUpdate = context.data || normalizedUpdate;
2328
+ return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
2329
+ ...options,
2330
+ sort: options.sort,
2331
+ returnDocument: options.returnDocument ?? "after",
2332
+ upsert: options.upsert ?? false,
2333
+ session: options.session
2334
+ });
2335
+ });
2336
+ }
2337
+ /**
2338
+ * Atomic compare-and-swap state transition. Implements
2339
+ * `StandardRepo.claim()` from `@classytic/repo-core/repository` (0.4+).
2340
+ *
2341
+ * Builds a `findOneAndUpdate({ _id, [field]: from, ...where }, { $set:
2342
+ * { [field]: to, ...patch } })` in a single round-trip — race-free
2343
+ * across concurrent callers. Returns the post-update doc on success,
2344
+ * `null` when:
2345
+ * - the row exists but its current state isn't `from` (someone
2346
+ * else won the transition), OR
2347
+ * - any predicate in `transition.where` fails (paused guard,
2348
+ * retry-time guard, heartbeat-staleness, sub-document predicate),
2349
+ * OR
2350
+ * - no row matches the id at all.
2351
+ *
2352
+ * The caller can't distinguish "lost race" from "guard predicate
2353
+ * failed" — both mean "don't proceed." That's by design: the only
2354
+ * action either way is to back off.
2355
+ *
2356
+ * **Source-state — `from: T | T[]`.** Pass a literal for a single-
2357
+ * source transition (`from: 'pending'`) or an array for the
2358
+ * "transition from any of these states" pattern (`from: ['pending',
2359
+ * 'approved']`). Compiles to `[stateField]: { $in: [...] }`. Real-
2360
+ * world frequency: commission's `voidRecord` / `markClawedBack` /
2361
+ * `endAgreement` / `_transition`, media-kit's error path,
2362
+ * streamline's `cancelAt` — every state machine with more than one
2363
+ * non-terminal source state hits this. Without array support those
2364
+ * sites fall back to raw `findOneAndUpdate` and lose plugin routing.
2365
+ *
2366
+ * **`from` is value-only — no Mongo expressions.** `from` accepts a
2367
+ * literal or an array of literals (`$in`-shaped). It does NOT
2368
+ * accept `{ $ne: ... }`, `{ $lt: ... }`, `{ $exists: ... }`, or
2369
+ * arbitrary expression objects. The CAS contract is "match if the
2370
+ * state field equals one of these specific values" — admitting
2371
+ * arbitrary predicates would collapse the contract into a generic
2372
+ * filter, defeating the point of a state-machine primitive.
2373
+ *
2374
+ * For non-equality / range / existence predicates on the state
2375
+ * field, use `where`:
2376
+ *
2377
+ * ```ts
2378
+ * // ❌ from: { $ne: 'ended' } — not supported, would silently work-as-filter
2379
+ * // ✅ Use `where` for $ne / $lt / $exists / arbitrary mongo predicates:
2380
+ * await repo.claim(id, {
2381
+ * from: 'active', // exact-match state
2382
+ * to: 'closed',
2383
+ * where: { closedAt: { $exists: false } }, // additional predicates
2384
+ * });
2385
+ * ```
2386
+ *
2387
+ * **Dotted-path `field` is supported** — `field: 'scheduling.status'`
2388
+ * for nested state fields works. Mongo's filter and `$set` both
2389
+ * honor dotted paths, and the from===to optimization handles them
2390
+ * correctly (literal comparison on the path string). Use for
2391
+ * domain models that nest the state under a sub-document
2392
+ * (`lpn.state`, `package.condition.state`, `scheduling.status`).
2393
+ *
2394
+ * **`from === to` is allowed — idempotent re-claim.** Yard's
2395
+ * `reviseDeparture` writes `departed → departed` to update the
2396
+ * row's payload while asserting the row hasn't moved on. The CAS
2397
+ * still returns `null` on race-loss (row left the source state),
2398
+ * so the safety property holds. Use the same semantic as a "touch
2399
+ * with state assertion" primitive.
2400
+ *
2401
+ * When `from === to` (and `from` is a literal, not an array), the
2402
+ * implementation **skips the redundant `$set: { [stateField]: to }`
2403
+ * write** — the filter has already pinned the state field to that
2404
+ * exact value, so writing it again is a no-op disk operation.
2405
+ * Workloads doing high-replay dedup (yard's `gate-event.append`,
2406
+ * outbox replay storms, idempotent first-write CAS) save one disk
2407
+ * write + one journal flush + one replication-log entry per replay.
2408
+ * Three observable behaviours follow:
2409
+ *
2410
+ * - With a non-empty patch: only the patch fields land in `$set`;
2411
+ * the redundant state-field key is dropped.
2412
+ * - With an empty patch and `upsert: false`: the call lowers to
2413
+ * a plain `findOne` round-trip — pure assertion, zero writes.
2414
+ * Plugin pipeline (before/after `claim` hooks) still fires
2415
+ * correctly — only the internal driver shape changes.
2416
+ * - With an empty patch and `upsert: true`: the update becomes
2417
+ * `{ $setOnInsert: { [stateField]: to } }` — no-op on match,
2418
+ * populates the inserted row's state field on miss. This is
2419
+ * the "pure dedup" insert-or-confirm path.
2420
+ *
2421
+ * For pure-dedup recipes (no real state column, just an external
2422
+ * id used as both filter and `from`/`to`), see CLAUDE.md — yard's
2423
+ * `gate-event.append` is the canonical worked example.
2424
+ *
2425
+ * **`upsert: true` — insert-or-claim.** Set `options.upsert: true`
2426
+ * for the upsert-claim pattern: insert when the row doesn't exist,
2427
+ * else CAS-transition the existing row. Yard's `gate-event.append`
2428
+ * uses this for idempotent event landing. With `upsert` on, claim
2429
+ * NEVER returns null on miss — it inserts. Pair with
2430
+ * `$setOnInsert` in the operator patch for insert-only fields.
2431
+ * The default (`upsert: false`) keeps the canonical "match
2432
+ * exactly, else null" semantic.
2433
+ *
2434
+ * **Patch shape** — accepts BOTH flat (`{ field: value }`) and Mongo
2435
+ * operator (`{ $set, $inc, $unset, $setOnInsert, ... }`) forms.
2436
+ * Operator form is load-bearing for versioned docs (`$inc: {
2437
+ * version: 1 }`) and upsert-claim (`$setOnInsert: { createdBy }`);
2438
+ * flat form is the textbook ergonomic case. Mixing flat keys with
2439
+ * `$`-keys throws — Mongo would silently drop the flat keys,
2440
+ * which would mask data-loss bugs.
2441
+ *
2442
+ * Multi-tenant scope, soft-delete filter, cache invalidation, and
2443
+ * audit hooks all flow through automatically — `claim` is registered
2444
+ * in `OP_REGISTRY` (policyKey: 'query', mutates: true), so plugins
2445
+ * that iterate the registry pick it up without changes.
2446
+ *
2447
+ * **Caller responsibility for tenant context.** When
2448
+ * `multiTenantPlugin` is mounted, plugin-injected scoping reads the
2449
+ * tenant key from the **options bag** — `claim(id, transition, patch,
2450
+ * { organizationId: '...' })`. Without it, the plugin throws
2451
+ * `'Missing organizationId'` at runtime. Use
2452
+ * `repoOptionsFromCtx(ctx)` (or your own context helper) to forward
2453
+ * request-scoped tenant fields; do not assume the plugin reads them
2454
+ * from a global.
2455
+ *
2456
+ * @example Textbook transition (flat patch)
2457
+ * ```ts
2458
+ * const claimed = await runRepo.claim(runId, { from: 'waiting', to: 'running' }, {
2459
+ * lastHeartbeat: new Date(),
2460
+ * workerId: 'worker-12',
2461
+ * });
2462
+ * if (!claimed) return; // someone else got it (or no match)
2463
+ * ```
2464
+ *
2465
+ * @example Versioned doc — operator patch with `$inc`
2466
+ * ```ts
2467
+ * const claimed = await orderRepo.claim(
2468
+ * orderId,
2469
+ * { from: 'pending', to: 'shipped' },
2470
+ * {
2471
+ * $set: { shippedAt: new Date() },
2472
+ * $inc: { version: 1 },
2473
+ * },
2474
+ * );
2475
+ * ```
2476
+ *
2477
+ * @example Compound-filter claim (paused guard + retry timer)
2478
+ * ```ts
2479
+ * const claimed = await runRepo.claim(
2480
+ * runId,
2481
+ * {
2482
+ * from: 'waiting',
2483
+ * to: 'running',
2484
+ * where: {
2485
+ * paused: { $ne: true },
2486
+ * 'scheduling.retryAfter': { $lte: new Date() },
2487
+ * },
2488
+ * },
2489
+ * { lastHeartbeat: new Date() },
2490
+ * { organizationId: ctx.orgId }, // tenant fwd — see note above
2491
+ * );
2492
+ * ```
2493
+ *
2494
+ * Pairs with `defineStateMachine()` from
2495
+ * `@classytic/primitives/state-machine`:
2496
+ * - State machine validates "is from→to legal in the model?"
2497
+ * - `claim()` performs the atomic "did we win?"
2498
+ */
2499
+ async claim(id, transition, patch = {}, options = {}) {
2500
+ const stateField = transition.field ?? "status";
2501
+ const effectiveIdField = options.idField ?? this.idField;
2502
+ const fromSpec = Array.isArray(transition.from) ? { $in: transition.from } : transition.from;
2503
+ const filter = {
2504
+ ...transition.where ?? {},
2505
+ [effectiveIdField]: id,
2506
+ [stateField]: fromSpec
2507
+ };
2508
+ assertNoMixedPatchShape("claim", patch);
2509
+ const patchKeys = Object.keys(patch);
2510
+ const operatorPatchKeys = patchKeys.filter((k) => k.startsWith("$"));
2511
+ const isStateNoop = !Array.isArray(transition.from) && transition.from === transition.to;
2512
+ let update;
2513
+ if (operatorPatchKeys.length === 0) {
2514
+ const patchKeysCount = patchKeys.length;
2515
+ if (isStateNoop) update = patchKeysCount === 0 ? {} : { $set: { ...patch } };
2516
+ else update = { $set: {
2517
+ ...patch,
2518
+ [stateField]: transition.to
2519
+ } };
2520
+ } else {
2521
+ const callerSet = patch.$set ?? {};
2522
+ if (isStateNoop) if (Object.keys(callerSet).length === 0) {
2523
+ const { $set, ...rest } = patch;
2524
+ update = rest;
2525
+ } else update = {
2526
+ ...patch,
2527
+ $set: { ...callerSet }
2528
+ };
2529
+ else update = {
2530
+ ...patch,
2531
+ $set: {
2532
+ ...callerSet,
2533
+ [stateField]: transition.to
2534
+ }
2535
+ };
2536
+ }
2537
+ if (Object.keys(update).length === 0 && options.upsert) update = { $setOnInsert: { [stateField]: transition.to } };
2538
+ const context = await this._buildContext("claim", {
2539
+ id,
2540
+ query: filter,
2541
+ data: update,
2542
+ transition,
2543
+ ...options
2544
+ });
2545
+ return this._runOp("claim", context, async () => {
2546
+ const finalQuery = context.query || filter;
2547
+ const finalUpdate = context.data || update;
2548
+ if (effectiveIdField === "_id" && !options.upsert) {
2549
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) return null;
2550
+ }
2551
+ if (Object.keys(finalUpdate).length === 0) return await this.Model.findOne(finalQuery, null, { session: options.session });
2552
+ return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
2553
+ returnDocument: "after",
2554
+ upsert: options.upsert ?? false,
2555
+ session: options.session
2556
+ });
2557
+ });
2558
+ }
2559
+ /**
2560
+ * Optimistic-concurrency CAS via a version stamp.
2561
+ *
2562
+ * Sibling to `claim()` — distinct mental model:
2563
+ * - `claim()` is a state machine: "move from status A to status B, atomically"
2564
+ * - `claimVersion()` is optimistic locking: "I expect version N; if it
2565
+ * still is, apply this update and increment the version"
2566
+ *
2567
+ * Builds `findOneAndUpdate({ _id, [versionField]: from, ...where },
2568
+ * { ...update, $inc: { [versionField]: by ?? 1 } })` in one
2569
+ * round-trip. Returns the post-update doc on success, `null` when:
2570
+ * - the row doesn't exist, OR
2571
+ * - the row's version isn't `from` (someone else committed first —
2572
+ * standard race-loss signal), OR
2573
+ * - any predicate in `transition.where` fails (state guard, tenant
2574
+ * guard expressed inline, etc.)
2575
+ *
2576
+ * **`from: undefined` is tolerated.** Lean reads return `version:
2577
+ * number | undefined` because the field defaults are absent on
2578
+ * fresh-from-mongo POJOs. Passing `from: undefined` matches docs
2579
+ * whose version field is *missing entirely* — exactly what you want
2580
+ * on a first-write CAS. Callers no longer need `?? 0` at every site.
2581
+ *
2582
+ * The caller's `update` is freeform — pass either a Mongo operator
2583
+ * shape (`{ $set: { status: 'submitted', updatedAt: now } }`) or a
2584
+ * field-shape object (auto-wrapped in `$set`). The version `$inc` is
2585
+ * MERGED into the update, so callers don't have to remember to bump
2586
+ * the counter.
2587
+ *
2588
+ * **Compound CAS via `where`.** State machines that key on both
2589
+ * version AND status (yard's `transition()`, every package whose
2590
+ * "ready to commit" gate is more than a version stamp) need to
2591
+ * AND-merge additional predicates. Pass them via
2592
+ * `transition.where` — same shape and semantics as `claim`'s
2593
+ * `where` field. Without this, claimVersion was forced back to raw
2594
+ * `findOneAndUpdate` for every state-AND-version site.
2595
+ *
2596
+ * Multi-tenant scope, soft-delete filter, cache invalidation, and
2597
+ * audit hooks all fire — `claimVersion` is in `OP_REGISTRY`
2598
+ * (policyKey: 'query', mutates: true) so plugins iterate the registry
2599
+ * and pick it up automatically.
2600
+ *
2601
+ * **Caller responsibility for tenant context.** When
2602
+ * `multiTenantPlugin` is mounted, the plugin reads the tenant key
2603
+ * from the **options bag** (`{ organizationId: '...' }`). Without it,
2604
+ * the plugin throws `'Missing organizationId'` at runtime. Use
2605
+ * `repoOptionsFromCtx(ctx)` to forward — same as `claim()` and every
2606
+ * other repo op.
2607
+ *
2608
+ * @example Order submission with version check
2609
+ * ```ts
2610
+ * const submitted = await orderRepo.claimVersion(
2611
+ * orderId,
2612
+ * { from: order.version },
2613
+ * { $set: { status: 'submitted', submittedAt: new Date() } },
2614
+ * );
2615
+ * if (!submitted) throw new ConcurrentEditError();
2616
+ * ```
2617
+ *
2618
+ * @example Compound CAS — version + status
2619
+ * ```ts
2620
+ * const transitioned = await yardRepo.claimVersion(
2621
+ * loadId,
2622
+ * {
2623
+ * from: load.version, // number | undefined OK
2624
+ * where: { status: 'queued' },
2625
+ * },
2626
+ * { $set: { status: 'in-progress', startedAt: new Date() } },
2627
+ * );
2628
+ * if (!transitioned) throw new ConcurrentEditError();
2629
+ * ```
2630
+ *
2631
+ * @example Custom version field name + step
2632
+ * ```ts
2633
+ * await runRepo.claimVersion(
2634
+ * runId,
2635
+ * { field: 'rev', from: 12, by: 1 },
2636
+ * { lastHeartbeat: new Date() }, // field-shape auto-wraps in $set
2637
+ * );
2638
+ * ```
2639
+ */
2640
+ async claimVersion(id, transition, update, options = {}) {
2641
+ const versionField = transition.field ?? "version";
2642
+ const versionStep = transition.by ?? 1;
2643
+ const effectiveIdField = options.idField ?? this.idField;
2644
+ assertNoMixedPatchShape("claimVersion", update);
2645
+ const operatorUpdate = Object.keys(update).filter((k) => k.startsWith("$")).length > 0 ? { ...update } : { $set: { ...update } };
2646
+ const callerInc = operatorUpdate.$inc ?? {};
2647
+ if (transition.from === void 0) {
2648
+ const callerSet = operatorUpdate.$set ?? {};
2649
+ if (Object.hasOwn(callerSet, versionField)) throw createError(400, `[claimVersion] first-write CAS (from: undefined) initializes the version field via $set, but the caller's update also writes $set.${versionField}. The implicit init would silently clobber the caller's value. Either remove ${versionField} from your $set, or pass a numeric \`from\` to use the standard $inc path.`);
2650
+ if (Object.hasOwn(callerInc, versionField)) throw createError(400, `[claimVersion] first-write CAS (from: undefined) initializes the version field via $set, which can't coexist with $inc on the same field in one update. Remove ${versionField} from your $inc, or pass a numeric \`from\` to use the standard $inc path.`);
2651
+ if (Object.keys(callerInc).length > 0) operatorUpdate.$inc = callerInc;
2652
+ else delete operatorUpdate.$inc;
2653
+ operatorUpdate.$set = {
2654
+ ...callerSet,
2655
+ [versionField]: versionStep
2656
+ };
2657
+ } else operatorUpdate.$inc = {
2658
+ ...callerInc,
2659
+ [versionField]: versionStep
2660
+ };
2661
+ const versionMatch = transition.from === void 0 ? null : transition.from;
2662
+ const filter = {
2663
+ ...transition.where ?? {},
2664
+ [effectiveIdField]: id,
2665
+ [versionField]: versionMatch
2666
+ };
2667
+ const context = await this._buildContext("claimVersion", {
2668
+ id,
2669
+ query: filter,
2670
+ data: operatorUpdate,
2671
+ transition,
2672
+ ...options
2673
+ });
2674
+ return this._runOp("claimVersion", context, async () => {
2675
+ const finalQuery = context.query || filter;
2676
+ const finalUpdate = context.data || operatorUpdate;
2677
+ if (effectiveIdField === "_id") {
2678
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) return null;
2679
+ }
2680
+ return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
2681
+ returnDocument: "after",
2682
+ upsert: false,
2683
+ session: options.session
2684
+ });
2685
+ });
2686
+ }
2687
+ /**
2688
+ * Delete a document by ID.
2689
+ *
2690
+ * By default the behavior is decided by the plugin stack:
2691
+ * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
2692
+ * - Without the plugin → physical delete
2693
+ *
2694
+ * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
2695
+ * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
2696
+ * cascade) still fire — only the soft-delete interception is skipped. This
2697
+ * is the GDPR / admin-cleanup path.
2698
+ *
2699
+ * @example
2700
+ * ```ts
2701
+ * // Respects softDeletePlugin — soft if wired
2702
+ * await userRepo.delete(userId);
2703
+ *
2704
+ * // GDPR erasure — physical delete, audit hooks still fire
2705
+ * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
2706
+ * ```
2707
+ */
2708
+ async delete(id, options = {}) {
2709
+ const context = await this._buildContext("delete", {
2710
+ id,
2711
+ ...options,
2712
+ ...options.mode ? { deleteMode: options.mode } : {}
2713
+ });
2714
+ return this._composeMiddleware("delete", context, async () => {
2715
+ try {
2716
+ if (context.softDeleted) {
2717
+ const result = {
2718
+ message: "Soft deleted successfully",
2719
+ id: String(id),
2720
+ soft: true
2721
+ };
2722
+ await this._emitHook("after:delete", {
2723
+ context,
2724
+ result
2725
+ });
2726
+ return result;
2727
+ }
2728
+ const effectiveIdField = options.idField ?? this.idField;
2729
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
2730
+ if (effectiveIdField === "_id") {
2731
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
2732
+ if (wantsThrow) throw createError(404, "Document not found");
2733
+ await this._emitHook("after:delete", {
2734
+ context,
2735
+ result: null
2736
+ });
2737
+ return null;
2738
+ }
2739
+ }
2740
+ const deleteQuery = effectiveIdField !== "_id" ? {
2741
+ [effectiveIdField]: id,
2742
+ ...context.query || {}
2743
+ } : void 0;
2744
+ const result = deleteQuery ? await deleteByQuery(this.Model, deleteQuery, { session: options.session }) : await deleteById(this.Model, id, {
2745
+ session: options.session,
2746
+ query: context.query
2747
+ });
2748
+ if (!result && wantsThrow) throw createError(404, "Document not found");
2749
+ await this._emitHook("after:delete", {
2750
+ context,
2751
+ result
2752
+ });
2753
+ return result;
2754
+ } catch (error) {
2755
+ await this._emitErrorHook("error:delete", {
2756
+ context,
2757
+ error
2758
+ });
2759
+ throw this._handleError(error);
2760
+ }
2761
+ });
2762
+ }
2763
+ /**
2764
+ * Update every document matching the filter.
2765
+ *
2766
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
2767
+ * the surface matches sqlitekit's always-available `updateMany`. Consumers
2768
+ * who forget to wire `batchOperationsPlugin` no longer get a runtime
2769
+ * `TypeError: repo.updateMany is not a function` — the method is always
2770
+ * here.
2771
+ *
2772
+ * Accepts three `update` forms via `UpdateInput`:
2773
+ * - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
2774
+ * `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
2775
+ * - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
2776
+ * - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
2777
+ * to guard against accidental pipeline updates.
2778
+ *
2779
+ * Refuses empty filters (defense-in-depth against mass-update accidents)
2780
+ * both before and after policy hooks inject tenant scope.
2781
+ */
2782
+ async updateMany(filter, update, options = {}) {
2783
+ const normalizedData = isUpdateSpec(update) ? compileUpdateSpecToMongo(update) : update;
2784
+ const context = await this._buildContext("updateMany", {
2785
+ query: filter,
2786
+ data: normalizedData,
2787
+ ...options
2788
+ });
2789
+ return this._runOp("updateMany", context, async () => {
2790
+ const finalQuery = context.query || filter;
2791
+ 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.");
2792
+ if (Array.isArray(normalizedData) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
2793
+ const finalData = context.data || normalizedData;
2794
+ return await this.Model.updateMany(finalQuery, finalData, {
2795
+ runValidators: true,
2796
+ session: options.session,
2797
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
2798
+ }).exec();
2799
+ });
2800
+ }
2801
+ /**
2802
+ * Delete every document matching the filter.
2803
+ *
2804
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
2805
+ * the same reason as `updateMany` — sqlitekit parity and no silent "method
2806
+ * is undefined" footgun when the batch plugin isn't wired.
2807
+ *
2808
+ * Behavior mirrors `delete()`: defaults to soft delete when
2809
+ * `softDeletePlugin` is wired, physical delete otherwise. Pass
2810
+ * `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
2811
+ * and cache hooks still fire.
2812
+ *
2813
+ * Rejects empty filters up front AND after policy hooks, so a policy-plugin
2814
+ * bug that zeroes out the query can't be exploited to wipe a collection.
2815
+ */
2816
+ async deleteMany(filter, options = {}) {
2817
+ if (!filter || Object.keys(filter).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
2818
+ const mode = options.mode;
2819
+ const context = await this._buildContext("deleteMany", {
2820
+ query: filter,
2821
+ ...options,
2822
+ ...mode ? { deleteMode: mode } : {}
2823
+ });
2824
+ return this._composeMiddleware("deleteMany", context, async () => {
2825
+ try {
2826
+ if (context.softDeleted) {
2827
+ const result = {
2828
+ acknowledged: true,
2829
+ deletedCount: context.softDeletedCount ?? 0,
2830
+ soft: true
2831
+ };
2832
+ await this._emitHook("after:deleteMany", {
2833
+ context,
2834
+ result
2835
+ });
2836
+ return result;
2837
+ }
2838
+ const finalQuery = context.query || filter;
2839
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
2840
+ const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
2841
+ await this._emitHook("after:deleteMany", {
2842
+ context,
2843
+ result
2844
+ });
2845
+ return result;
2846
+ } catch (error) {
2847
+ await this._emitErrorHook("error:deleteMany", {
2848
+ context,
2849
+ error
2850
+ });
2851
+ throw this._handleError(error);
2852
+ }
2853
+ });
2854
+ }
2855
+ /**
2856
+ * Kit-native aggregation pipeline — takes a MongoDB stage array and
2857
+ * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
2858
+ * `$facet`, `$graphLookup`, and any other mongo-specific power
2859
+ * features that don't translate to SQL.
2860
+ *
2861
+ * For backend-portable aggregations (filter + group + measures +
2862
+ * sort + limit) use `aggregate(req: AggRequest)` instead — it
2863
+ * compiles the repo-core IR to the equivalent pipeline internally
2864
+ * and produces the same row shape sqlitekit emits.
2865
+ *
2866
+ * Routes through the hook system for policy enforcement
2867
+ * (multi-tenant, soft-delete).
2868
+ *
2869
+ * @param pipeline - MongoDB aggregation stage array
2870
+ * @param options - Aggregation options including governance controls
2871
+ */
2872
+ async aggregatePipeline(pipeline, options = {}) {
2873
+ const context = await this._buildContext("aggregatePipeline", {
2874
+ pipeline,
2875
+ ...options
2876
+ });
2877
+ const maxStages = options.maxPipelineStages;
2878
+ if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
2879
+ return this._runOp("aggregatePipeline", context, async () => {
2880
+ const finalPipeline = [...pipeline];
2881
+ if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
2882
+ const aggregation = this.Model.aggregate(finalPipeline);
2883
+ if (options.session) aggregation.session(options.session);
2884
+ if (options.allowDiskUse) aggregation.allowDiskUse(true);
2885
+ if (options.readPreference) aggregation.read(options.readPreference);
2886
+ if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
2887
+ if (options.comment) aggregation.option({ comment: options.comment });
2888
+ if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
2889
+ if (options.collation) aggregation.collation(options.collation);
2890
+ return await aggregation.exec();
2891
+ });
2892
+ }
2893
+ /**
2894
+ * Paginated kit-native aggregation pipeline. Same mongo-specific
2895
+ * scope as `aggregatePipeline` — reach for this when you need
2896
+ * `$lookup` / `$unwind` / `$facet` alongside pagination.
2897
+ *
2898
+ * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
2899
+ * cross-backend dashboard code; that method compiles the portable
2900
+ * IR to an equivalent pipeline.
2901
+ *
2902
+ * Policy hooks (multi-tenant, soft-delete) inject context.filters
2903
+ * which are prepended as a `$match` stage.
2904
+ */
2905
+ async aggregatePipelinePaginate(options = {}) {
2906
+ const context = await this._buildContext("aggregatePipelinePaginate", options);
2907
+ const finalPipeline = [...context.pipeline || options.pipeline || []];
2908
+ if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
2909
+ const aggOptions = {
2910
+ ...context,
2911
+ pipeline: finalPipeline
2912
+ };
2913
+ return this._runOp("aggregatePipelinePaginate", context, () => this._pagination.aggregatePaginate(aggOptions));
2914
+ }
2915
+ /**
2916
+ * Portable aggregation. Compiles the repo-core `AggRequest` IR to
2917
+ * `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
2918
+ * {$skip}, {$limit}]` against this repo's collection. Output rows
2919
+ * carry one key per `groupBy` column plus one key per measure alias
2920
+ * — identical shape to sqlitekit's `aggregate(req)`, so dashboards
2921
+ * work unchanged across backends.
2922
+ *
2923
+ * Without `groupBy`: returns a single-row result with just the
2924
+ * measures (scalar aggregation). Pass
2925
+ * `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
2926
+ * simple summary.
2927
+ *
2928
+ * For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
2929
+ * `$graphLookup`, window operators) reach for `aggregatePipeline`
2930
+ * — that's the kit-native escape hatch.
2931
+ *
2932
+ * Routes through the hook system for policy enforcement
2933
+ * (multi-tenant, soft-delete) — plugins inject their scope into
2934
+ * `context.filter`, which is merged into the pre-aggregate `$match`
2935
+ * before compilation.
2936
+ */
2937
+ async aggregate(req) {
2938
+ const context = await this._buildContext("aggregate", { aggRequest: req });
2939
+ const cached = this._cachedValue(context);
2940
+ if (cached !== void 0) {
2941
+ await this._emitHook("after:aggregate", {
2942
+ context,
2943
+ result: cached,
2944
+ fromCache: true
2945
+ });
2946
+ return cached;
2947
+ }
2948
+ return this._runOp("aggregate", context, async () => {
2949
+ const finalReq = this._injectPolicyScopeIntoAgg(req, context);
2950
+ return { rows: await executeAgg(this.Model, finalReq, { session: context.session }) };
2951
+ });
2952
+ }
2953
+ /**
2954
+ * Invalidate every aggregate-cache entry tagged with ANY of the
2955
+ * given tags. Delegates to the unified cache plugin's handle
2956
+ * (`repo.cache.invalidateByTags`) — wired by `cachePlugin({ adapter })`.
2957
+ *
2958
+ * Pass no tags to wipe the entire cache namespace (requires the
2959
+ * adapter to implement `clear`).
2960
+ *
2961
+ * Returns the count of distinct entries cleared (`-1` when the call
2962
+ * routed through `adapter.clear()` and the adapter doesn't report a
2963
+ * count). No-op (returns `0`) when no cache plugin is wired.
2964
+ */
2965
+ async invalidateAggregateCache(tags) {
2966
+ const handle = this.cache;
2967
+ if (!handle) return 0;
2968
+ if (!tags || tags.length === 0) {
2969
+ await handle.clear();
2970
+ return -1;
2971
+ }
2972
+ return handle.invalidateByTags(tags);
2973
+ }
2974
+ /**
2975
+ * Paginated portable aggregation. Two pagination modes, picked by
2976
+ * the request shape:
2977
+ *
2978
+ * - **Offset (default)** — `page` + `limit`. Returns the standard
2979
+ * `{ method: 'offset', data, total, pages, hasNext, hasPrev, ... }`
2980
+ * envelope. `countStrategy: 'none'` skips the second round-trip
2981
+ * that computes `total`; the envelope reports `total: 0`,
2982
+ * `pages: 0`, and derives `hasNext` from a `LIMIT N+1` peek on
2983
+ * the data pipeline.
2984
+ * - **Keyset** — `pagination: 'keyset'` (or `after` set). Returns
2985
+ * `{ method: 'keyset', data, hasMore, next, limit }`. `sort` is
2986
+ * required — the cursor encodes the sort-key tuple of the last
2987
+ * row. Each subsequent page passes the previous `next` back as
2988
+ * `after`. Scales to arbitrary group counts because the planner
2989
+ * uses `(sort_keys) > (cursor)` instead of `OFFSET N`.
2990
+ */
2991
+ async aggregatePaginate(req) {
2992
+ const context = await this._buildContext("aggregatePaginate", { aggRequest: req });
2993
+ const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
2994
+ const session = context.session;
2995
+ const useKeyset = isKeysetMode(req);
2996
+ const cachedEnvelope = this._cachedValue(context);
2997
+ if (cachedEnvelope !== void 0) {
2998
+ await this._emitHook("after:aggregatePaginate", {
2999
+ context,
3000
+ result: cachedEnvelope,
3001
+ fromCache: true
3002
+ });
3003
+ return cachedEnvelope;
3004
+ }
3005
+ return this._composeMiddleware("aggregatePaginate", context, async () => {
3006
+ try {
3007
+ const finalReq = this._injectPolicyScopeIntoAgg(req, context);
3008
+ const result = await this._executeAggregatePaginate(finalReq, useKeyset, limit, session);
3009
+ await this._emitHook("after:aggregatePaginate", {
3010
+ context,
3011
+ result
3012
+ });
3013
+ return result;
3014
+ } catch (error) {
3015
+ await this._emitErrorHook("error:aggregatePaginate", {
3016
+ context,
3017
+ error
3018
+ });
3019
+ throw this._handleError(error);
3020
+ }
3021
+ });
3022
+ }
3023
+ /**
3024
+ * Internal: the actual aggregate-paginate execution body. Extracted
3025
+ * so `aggregatePaginate` can wrap it in the cache layer without
3026
+ * duplicating the keyset / offset branch logic.
3027
+ *
3028
+ * Receives `finalReq` (already policy-scoped) so the cache key
3029
+ * built by the outer wrapper matches the request the executor
3030
+ * sees — no cross-tenant cache poisoning.
3031
+ */
3032
+ async _executeAggregatePaginate(finalReq, useKeyset, limit, session) {
3033
+ if (useKeyset) {
3034
+ if (!finalReq.sort || Object.keys(finalReq.sort).length === 0) throw new Error("mongokit/aggregatePaginate: keyset pagination requires `sort` — the cursor anchors on the sort-key tuple");
3035
+ const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after) : void 0;
3036
+ const keysetMatch = cursor ? buildKeysetPredicate(finalReq.sort, cursor) : void 0;
3037
+ const { pipeline, prePaginationIndex } = buildAggPipeline(finalReq);
3038
+ const headStages = pipeline.slice(0, prePaginationIndex);
3039
+ const tailStages = [];
3040
+ if (keysetMatch) tailStages.push(keysetMatch);
3041
+ tailStages.push({ $sort: finalReq.sort });
3042
+ tailStages.push({ $limit: limit + 1 });
3043
+ const aggregation = this.Model.aggregate([...headStages, ...tailStages]);
3044
+ if (session) aggregation.session(session);
3045
+ applyExecutionHints(aggregation, finalReq.executionHints);
3046
+ const peeked = await aggregation.exec();
3047
+ const hasMore = peeked.length > limit;
3048
+ const data = hasMore ? peeked.slice(0, limit) : peeked;
3049
+ return {
3050
+ method: "keyset",
3051
+ data,
3052
+ limit,
3053
+ hasMore,
3054
+ next: hasMore && data.length > 0 ? encodeAggCursor(data[data.length - 1], finalReq.sort) : null
3055
+ };
3056
+ }
3057
+ const page = Math.max(1, finalReq.page ?? 1);
3058
+ const countStrategy = finalReq.countStrategy ?? "exact";
3059
+ const offset = (page - 1) * limit;
3060
+ if (countStrategy === "none") {
3061
+ const peek = await executeAgg(this.Model, {
3062
+ ...finalReq,
3063
+ limit: limit + 1,
3064
+ offset
3065
+ }, session ? { session } : {});
3066
+ const hasNext = peek.length > limit;
3067
+ return {
3068
+ method: "offset",
3069
+ data: hasNext ? peek.slice(0, limit) : peek,
3070
+ page,
3071
+ limit,
3072
+ total: 0,
3073
+ pages: 0,
3074
+ hasNext,
3075
+ hasPrev: page > 1
3076
+ };
3077
+ }
3078
+ const [data, total] = await Promise.all([executeAgg(this.Model, {
3079
+ ...finalReq,
3080
+ limit,
3081
+ offset
3082
+ }, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
3083
+ return {
3084
+ method: "offset",
3085
+ data,
3086
+ page,
3087
+ limit,
3088
+ total,
3089
+ pages: Math.max(1, Math.ceil(total / limit)),
3090
+ hasNext: page * limit < total,
3091
+ hasPrev: page > 1
3092
+ };
3093
+ }
3094
+ /**
3095
+ * Merge policy-hook-injected filters (multi-tenant scope,
3096
+ * soft-delete) into the pre-aggregate `filter` slot of the request.
3097
+ * Plugins speak mongo-query shape (`context.query` / `context.filters`);
3098
+ * the caller's `req.filter` may be either Filter IR or a mongo query.
3099
+ *
3100
+ * Each part is compiled through `compileFilterToMongo` BEFORE the
3101
+ * `$and` merge — otherwise an IR node like `eq('status', 'paid')`
3102
+ * (`{ op, field, value }`) gets nested inside `$and` as a literal,
3103
+ * `compileFilterToMongo` of the merged value finds no top-level `op`
3104
+ * (so `isFilter` is false), and Mongo receives the IR object as a
3105
+ * raw query clause that matches no real document. Symptom: aggregates
3106
+ * silently return 0 rows whenever a callerpasses IR + a policy
3107
+ * plugin is installed.
3108
+ */
3109
+ _injectPolicyScopeIntoAgg(req, context) {
3110
+ const scopeCandidates = [];
3111
+ for (const candidate of [context.filters, context.query]) if (candidate && typeof candidate === "object" && Object.keys(candidate).length > 0) scopeCandidates.push(candidate);
3112
+ if (scopeCandidates.length === 0) return req;
3113
+ const parts = [];
3114
+ for (const c of scopeCandidates) {
3115
+ const compiled = compileFilterToMongo(c);
3116
+ if (Object.keys(compiled).length > 0) parts.push(compiled);
3117
+ }
3118
+ if (req.filter) {
3119
+ const compiled = compileFilterToMongo(req.filter);
3120
+ if (Object.keys(compiled).length > 0) parts.push(compiled);
3121
+ }
3122
+ if (parts.length === 0) return req;
3123
+ const merged = parts.length === 1 ? parts[0] : { $and: parts };
3124
+ return {
3125
+ ...req,
3126
+ filter: merged
3127
+ };
3128
+ }
3129
+ /**
3130
+ * Get distinct values
3131
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
3132
+ */
3133
+ async distinct(field, query = {}, options = {}) {
3134
+ const context = await this._buildContext("distinct", {
3135
+ query,
3136
+ ...options
3137
+ });
3138
+ return this._runOp("distinct", context, () => {
3139
+ const finalQuery = context.query || query;
3140
+ const readPreference = context.readPreference ?? options.readPreference;
3141
+ return distinct(this.Model, field, finalQuery, {
3142
+ session: options.session,
3143
+ readPreference
3144
+ });
3145
+ });
3146
+ }
3147
+ /**
3148
+ * Query with custom field lookups ($lookup)
3149
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
3150
+ *
3151
+ * @example
3152
+ * ```typescript
3153
+ * // Join employees with departments using slug instead of ObjectId
3154
+ * const employees = await employeeRepo.lookupPopulate({
3155
+ * filters: { status: 'active' },
3156
+ * lookups: [
3157
+ * {
3158
+ * from: 'departments',
3159
+ * localField: 'departmentSlug',
3160
+ * foreignField: 'slug',
3161
+ * as: 'department',
3162
+ * single: true
3163
+ * }
3164
+ * ],
3165
+ * sort: '-createdAt',
3166
+ * page: 1,
3167
+ * limit: 50
3168
+ * });
3169
+ * ```
3170
+ */
3171
+ async lookupPopulate(options) {
3172
+ const context = await this._buildContext("lookupPopulate", options);
3173
+ return this._composeMiddleware("lookupPopulate", context, async () => {
3174
+ try {
3175
+ const MAX_LOOKUPS = 10;
3176
+ const lookups = context.lookups ?? options.lookups;
3177
+ if (lookups.length > MAX_LOOKUPS) throw createError(400, `Too many lookups (${lookups.length}). Maximum is ${MAX_LOOKUPS}.`);
3178
+ const filters = context.filters ?? options.filters;
3179
+ const sort = context.sort ?? options.sort;
3180
+ const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
3181
+ const readPref = context.readPreference ?? options.readPreference;
3182
+ const session = context.session ?? options.session;
3183
+ const collation = context.collation ?? options.collation;
3184
+ const after = context.after ?? options.after;
3185
+ const pageFromContext = context.page ?? options.page;
3186
+ const isKeyset = !!after || !pageFromContext && !!sort;
3187
+ const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
3188
+ const selectSpec = context.select ?? options.select;
3189
+ let projection;
3190
+ if (selectSpec) {
3191
+ if (typeof selectSpec === "string") {
3192
+ projection = {};
3193
+ for (const field of selectSpec.split(",").map((f) => f.trim())) if (field.startsWith("-")) projection[field.substring(1)] = 0;
3194
+ else projection[field] = 1;
3195
+ } else if (Array.isArray(selectSpec)) {
3196
+ projection = {};
3197
+ for (const field of selectSpec) if (field.startsWith("-")) projection[field.substring(1)] = 0;
3198
+ else projection[field] = 1;
3199
+ } else projection = { ...selectSpec };
3200
+ if (projection) {
3201
+ if (Object.values(projection).some((v) => v === 1)) for (const lookup of lookups) {
3202
+ const asField = lookup.as || lookup.from;
3203
+ if (!(asField in projection)) projection[asField] = 1;
3204
+ }
3205
+ }
3206
+ }
3207
+ const appendLookupStages = (pipeline) => {
3208
+ pipeline.push(...LookupBuilder.multiple(lookups));
3209
+ for (const lookup of lookups) if (lookup.single) {
3210
+ const asField = lookup.as || lookup.from;
3211
+ pipeline.push({ $addFields: { [asField]: { $ifNull: [`$${asField}`, null] } } });
3212
+ }
3213
+ const finalProjection = ensureLookupProjectionIncludesCursorFields(projection, isKeyset && sort ? this._parseSort(sort) : void 0);
3214
+ if (finalProjection) pipeline.push({ $project: finalProjection });
3215
+ };
3216
+ if (isKeyset && sort) {
3217
+ const parsedSort = this._parseSort(sort);
3218
+ const { validateKeysetSort } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
3219
+ const { encodeCursor, resolveCursorFilter } = await import("./cursor-J84rR6wG.mjs").then((n) => n.t);
3220
+ const { getPrimaryField } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
3221
+ const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
3222
+ const cursorVersion = this._pagination.config.cursorVersion ?? 1;
3223
+ const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
3224
+ const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
3225
+ if (projection) {
3226
+ if (Object.values(projection).some((v) => v === 1)) {
3227
+ for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
3228
+ }
3229
+ }
3230
+ const pipeline = [];
3231
+ if (Object.keys(matchFilters).length > 0) pipeline.push({ $match: matchFilters });
3232
+ pipeline.push({ $sort: normalizedSort });
3233
+ pipeline.push({ $limit: limit + 1 });
3234
+ appendLookupStages(pipeline);
3235
+ const aggregation = this.Model.aggregate(pipeline).session(session || null);
3236
+ if (collation) aggregation.collation(collation);
3237
+ if (readPref) aggregation.read(readPref);
3238
+ const data = await aggregation;
3239
+ const hasMore = data.length > limit;
3240
+ if (hasMore) data.pop();
3241
+ const primaryField = getPrimaryField(normalizedSort);
3242
+ const result = {
3243
+ method: "keyset",
3244
+ data,
3245
+ limit,
3246
+ hasMore,
3247
+ next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion) : null
3248
+ };
3249
+ await this._emitHook("after:lookupPopulate", {
3250
+ context,
3251
+ result
3252
+ });
3253
+ return result;
3254
+ }
3255
+ const page = pageFromContext ?? 1;
3256
+ const skip = (page - 1) * limit;
3257
+ if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
3258
+ const dataPipeline = [];
3259
+ if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
3260
+ if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
3261
+ if (countStrategy === "none") {
3262
+ dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
3263
+ appendLookupStages(dataPipeline);
3264
+ const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
3265
+ if (collation) aggregation.collation(collation);
3266
+ if (readPref) aggregation.read(readPref);
3267
+ const data = await aggregation;
3268
+ const hasNext = data.length > limit;
3269
+ if (hasNext) data.pop();
3270
+ const result = {
3271
+ method: "offset",
3272
+ data,
3273
+ page,
3274
+ limit,
3275
+ total: 0,
3276
+ pages: 0,
3277
+ hasNext,
3278
+ hasPrev: page > 1
3279
+ };
3280
+ await this._emitHook("after:lookupPopulate", {
3281
+ context,
3282
+ result
3283
+ });
3284
+ return result;
3285
+ }
3286
+ dataPipeline.push({ $skip: skip }, { $limit: limit });
3287
+ appendLookupStages(dataPipeline);
3288
+ const countPipeline = [];
3289
+ if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
3290
+ countPipeline.push({ $count: "total" });
3291
+ const pipeline = [{ $facet: {
3292
+ metadata: countPipeline,
3293
+ data: dataPipeline
3294
+ } }];
3295
+ const aggregation = this.Model.aggregate(pipeline).session(session || null);
3296
+ if (collation) aggregation.collation(collation);
3297
+ if (readPref) aggregation.read(readPref);
3298
+ const facetResult = (await aggregation)[0] || {
3299
+ metadata: [],
3300
+ data: []
3301
+ };
3302
+ const total = facetResult.metadata[0]?.total || 0;
3303
+ const result = {
3304
+ method: "offset",
3305
+ data: facetResult.data || [],
3306
+ page,
3307
+ limit,
3308
+ total,
3309
+ pages: Math.max(1, Math.ceil(total / limit)),
3310
+ hasNext: page * limit < total,
3311
+ hasPrev: page > 1
3312
+ };
3313
+ await this._emitHook("after:lookupPopulate", {
3314
+ context,
3315
+ result
3316
+ });
3317
+ return result;
3318
+ } catch (error) {
3319
+ await this._emitErrorHook("error:lookupPopulate", {
3320
+ context,
3321
+ error
3322
+ });
3323
+ throw this._handleError(error);
3324
+ }
3325
+ });
3326
+ }
3327
+ /**
3328
+ * Create an aggregation builder for this model
3329
+ * Useful for building complex custom aggregations
3330
+ *
3331
+ * @example
3332
+ * ```typescript
3333
+ * const pipeline = repo.buildAggregation()
3334
+ * .match({ status: 'active' })
3335
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
3336
+ * .group({ _id: '$department', count: { $sum: 1 } })
3337
+ * .sort({ count: -1 })
3338
+ * .build();
3339
+ *
3340
+ * const results = await repo.Model.aggregate(pipeline);
3341
+ * ```
3342
+ */
3343
+ buildAggregation() {
3344
+ return new AggregationBuilder();
3345
+ }
3346
+ /**
3347
+ * Create a lookup builder
3348
+ * Useful for building $lookup stages independently
3349
+ *
3350
+ * @example
3351
+ * ```typescript
3352
+ * const lookupStages = repo.buildLookup('departments')
3353
+ * .localField('deptSlug')
3354
+ * .foreignField('slug')
3355
+ * .as('department')
3356
+ * .single()
3357
+ * .build();
3358
+ *
3359
+ * const pipeline = [
3360
+ * { $match: { status: 'active' } },
3361
+ * ...lookupStages
3362
+ * ];
3363
+ * ```
3364
+ */
3365
+ buildLookup(from) {
3366
+ return new LookupBuilder(from);
3367
+ }
3368
+ /**
3369
+ * Execute callback within a MongoDB transaction with automatic retry on
3370
+ * transient failures.
3371
+ *
3372
+ * **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
3373
+ * not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
3374
+ * contract that every kit (sqlitekit, pgkit, prismakit) already implements
3375
+ * — so cross-kit plugins and apps are portable. Session threading happens
3376
+ * transparently: every CRUD call on `txRepo` auto-injects the session into
3377
+ * its options bag, including plugin-added methods (`upsert`, `increment`,
3378
+ * `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
3379
+ *
3380
+ * For raw-session cross-repo workflows — multiple repositories coordinating
3381
+ * writes — use the standalone `withTransaction(connection, fn)` export
3382
+ * from `@classytic/mongokit`. That helper remains session-based by design.
3383
+ *
3384
+ * @param callback - Receives a tx-bound `Repository<TDoc>` — call its
3385
+ * methods directly, no need to pass `{ session }` manually
3386
+ * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
3387
+ * @param options.onFallback - Called when falling back to non-transactional execution
3388
+ * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
3389
+ *
3390
+ * @example
3391
+ * ```typescript
3392
+ * const order = await repo.withTransaction(async (txRepo) => {
3393
+ * const created = await txRepo.create({ total: 100 });
3394
+ * await txRepo.update(created._id, { confirmed: true });
3395
+ * return created;
3396
+ * });
3397
+ *
3398
+ * // With fallback for standalone/dev environments
3399
+ * await repo.withTransaction(
3400
+ * async (txRepo) => {
3401
+ * await txRepo.create(doc);
3402
+ * },
3403
+ * {
3404
+ * allowFallback: true,
3405
+ * onFallback: (err) => logger.warn('Running without transaction', err),
3406
+ * },
3407
+ * );
3408
+ *
3409
+ * // Cross-repo with explicit session:
3410
+ * import { withTransaction } from '@classytic/mongokit';
3411
+ * await withTransaction(mongoose.connection, async (session) => {
3412
+ * const order = await orderRepo.create(data, { session });
3413
+ * await inventoryRepo.decrement(..., { session });
3414
+ * });
3415
+ * ```
3416
+ */
3417
+ async withTransaction(callback, options = {}) {
3418
+ return withTransaction(this.Model.db, async (session) => {
3419
+ return callback(createTxBoundRepo(this, session));
3420
+ }, options);
3421
+ }
3422
+ /**
3423
+ * Execute custom query with event emission
3424
+ */
3425
+ async _executeQuery(buildQuery) {
3426
+ const operation = buildQuery.name || "custom";
3427
+ const context = await this._buildContext(operation, {});
3428
+ return this._runOp(operation, context, () => buildQuery(this.Model));
3429
+ }
3430
+ /**
3431
+ * Build operation context and run before-hooks (sorted by priority).
3432
+ *
3433
+ * Narrows `RepositoryBase._buildContext`'s return type to mongokit's
3434
+ * richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
3435
+ * populate / readPreference / session, etc.). Runtime is inherited from
3436
+ * the base class — this override is purely a type-narrowing shim.
3437
+ *
3438
+ * Hook execution order is deterministic:
3439
+ * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
3440
+ * 2. CACHE (200) — cache lookup (after policy filters are injected)
3441
+ * 3. OBSERVABILITY (300) — audit logging, metrics
3442
+ * 4. DEFAULT (500) — user-registered hooks
3443
+ */
3444
+ async _buildContext(operation, options) {
3445
+ const normalized = this._normalizeFilterSlots(options);
3446
+ return await super._buildContext(operation, normalized);
3447
+ }
3448
+ /**
3449
+ * Walk the well-known filter-carrying keys (`query`, `filters`,
3450
+ * `having`) and route any Filter IR through `compileFilterToMongo`.
3451
+ * Plain Mongo queries pass through untouched — `isFilter` is the
3452
+ * discriminator. Returns a shallow clone; never mutates input.
3453
+ */
3454
+ _normalizeFilterSlots(options) {
3455
+ let cloned = null;
3456
+ for (const key of [
3457
+ "query",
3458
+ "filters",
3459
+ "having"
3460
+ ]) {
3461
+ const value = options[key];
3462
+ if (value === void 0 || value === null) continue;
3463
+ if (!isFilter(value)) continue;
3464
+ cloned ??= { ...options };
3465
+ cloned[key] = compileFilterToMongo(value);
3466
+ }
3467
+ return cloned ?? options;
3468
+ }
3469
+ /**
3470
+ * Resolve the effective sort for getAll. Handles $near conflict detection
3471
+ * (warns + drops explicit sort when $near is in filters).
3472
+ */
3473
+ _resolveSort(context, params, isNearQuery) {
3474
+ const explicitSort = context.sort ?? params.sort;
3475
+ if (isNearQuery) {
3476
+ if (explicitSort !== void 0) warn(`[mongokit] Repository "${this.model}" dropping explicit sort (${JSON.stringify(explicitSort)}) because the filter contains $near / $nearSphere. MongoDB forbids explicit sort with $near. Use [withinRadius] instead of [near] if you need a custom sort.`);
3477
+ return;
3478
+ }
3479
+ return explicitSort ?? "-createdAt";
3480
+ }
3481
+ /**
3482
+ * Detect whether to use keyset (cursor) or offset (page) pagination.
3483
+ */
3484
+ _detectPaginationMode(mode, page, after, sort, context, params) {
3485
+ if (mode) return mode === "keyset";
3486
+ return !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
3487
+ }
3488
+ /**
3489
+ * Build the MongoDB query filter with search merged in. Handles text
3490
+ * search ($text), regex search ($or of $regex), and the search-resolver
3491
+ * plugin contract (search already cleared by a before:getAll hook).
3492
+ */
3493
+ _buildSearchQuery(filters, search) {
3494
+ const query = { ...filters };
3495
+ if (!search) return query;
3496
+ if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
3497
+ let effectiveMode = this.searchMode === "regex" ? "regex" : "text";
3498
+ if (this.searchMode === "auto") effectiveMode = this._hasTextIndex ? "text" : "regex";
3499
+ if (effectiveMode === "regex") {
3500
+ if (!this.searchFields || this.searchFields.length === 0) throw createError(400, `Repository "${this.model}" configured with searchMode: '${this.searchMode}' but no searchFields provided.`);
3501
+ const escaped = String(search).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3502
+ const orConds = this.searchFields.map((field) => ({ [field]: {
3503
+ $regex: escaped,
3504
+ $options: "i"
3505
+ } }));
3506
+ if (Array.isArray(query.$or)) {
3507
+ const existingOr = query.$or;
3508
+ delete query.$or;
3509
+ query.$and = [
3510
+ ...Array.isArray(query.$and) ? query.$and : [],
3511
+ { $or: existingOr },
3512
+ { $or: orConds }
3513
+ ];
3514
+ } else query.$or = orConds;
3515
+ } else if (this._hasTextIndex) query.$text = { $search: search };
3516
+ else {
3517
+ const modelName = String(this.model);
3518
+ throw createError(400, `No text index found for ${modelName}. Cannot perform text search. Configure ${modelName}'s Repository with { searchMode: 'regex', searchFields: [...] } to enable index-free search, or create a text index on the collection for searchMode: 'text'. See https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter`, {
3519
+ code: "SEARCH_NOT_CONFIGURED",
3520
+ meta: {
3521
+ model: modelName,
3522
+ configuredMode: this.searchMode,
3523
+ availableModes: [
3524
+ "text",
3525
+ "regex",
3526
+ "auto"
3527
+ ],
3528
+ data: "https://github.com/classytic/mongokit/blob/main/docs/README.md#queryparser-url--filter"
3529
+ }
3530
+ });
3531
+ }
3532
+ return query;
3533
+ }
3534
+ /**
3535
+ * Parse sort string or object
3536
+ */
3537
+ _parseSort(sort) {
3538
+ if (!sort) return { createdAt: -1 };
3539
+ if (typeof sort === "object") {
3540
+ if (Object.keys(sort).length === 0) return { createdAt: -1 };
3541
+ return sort;
3542
+ }
3543
+ const sortObj = {};
3544
+ const fields = sort.split(",").map((s) => s.trim());
3545
+ for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
3546
+ else sortObj[field] = 1;
3547
+ return sortObj;
3548
+ }
3549
+ /**
3550
+ * Parse populate specification
3551
+ */
3552
+ _parsePopulate(populate) {
3553
+ if (!populate) return [];
3554
+ if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
3555
+ if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
3556
+ return [populate];
3557
+ }
3558
+ /**
3559
+ * Classify a driver error as an authoritative duplicate-key conflict.
3560
+ *
3561
+ * Used by arc's outbox / idempotency adapters to distinguish
3562
+ * "write already landed (idempotent no-op)" from "transient DB error
3563
+ * (must retry)". Every backend signals duplicate keys differently —
3564
+ * MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
3565
+ * predicate lives on the kit that knows its driver. Cross-driver
3566
+ * adapters then depend only on the boolean outcome, not the shape of
3567
+ * the underlying error.
3568
+ *
3569
+ * Deliberately narrow: matches ONLY `code === 11000` and
3570
+ * `codeName === 'DuplicateKey'`. Does NOT match
3571
+ * `err.name === 'MongoServerError'`, which is also true for
3572
+ * WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
3573
+ * other server-side error — treating those as duplicate keys would
3574
+ * silently swallow transactional retries.
3575
+ *
3576
+ * @example
3577
+ * try {
3578
+ * await outboxRepo.create(event);
3579
+ * } catch (err) {
3580
+ * if (outboxRepo.isDuplicateKeyError(err)) {
3581
+ * // Idempotent no-op — event was already written by a prior attempt.
3582
+ * return;
3583
+ * }
3584
+ * throw err; // transient DB error — upstream retries
3585
+ * }
3586
+ */
3587
+ isDuplicateKeyError(err) {
3588
+ return isDuplicateKeyError(err);
3589
+ }
3590
+ /**
3591
+ * Handle errors with proper HTTP status codes
3592
+ */
3593
+ _handleError(error) {
3594
+ if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
3595
+ if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
3596
+ const duplicateErr = parseDuplicateKeyError(error);
3597
+ if (duplicateErr) return duplicateErr;
3598
+ if (error.status && error.message) return error;
3599
+ return createError(500, error.message || "Internal Server Error");
3600
+ }
3601
+ };
3602
+ //#endregion
3603
+ export { withTransaction as a, MUTATING_OPERATIONS as c, operationsByPolicyKey as d, isTransactionUnsupported as i, OP_REGISTRY as l, Repository as n, AggregationBuilder as o, batchTransaction as r, ALL_OPERATIONS as s, HOOK_PRIORITY$1 as t, READ_OPERATIONS as u };