@classytic/mongokit 3.12.0 → 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.
- package/README.md +74 -4
- package/dist/{PaginationEngine-BDaAsyPn.mjs → PaginationEngine-CfQRM1yz.mjs} +16 -16
- package/dist/Repository-BY40uhcv.mjs +3603 -0
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.d.mts +190 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter-DTDMnUOH.mjs +158 -0
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +1 -1
- package/dist/better-auth/index.d.mts +149 -0
- package/dist/better-auth/index.mjs +81 -0
- package/dist/field-selection-Br3WPOEb.mjs +99 -0
- package/dist/{index-D9cORR-o.d.mts → index-BbHaKwi5.d.mts} +33 -13
- package/dist/index.d.mts +667 -30
- package/dist/index.mjs +240 -17
- package/dist/{logger-8-VS1za6.mjs → logger-DprijP5h.mjs} +1 -5
- package/dist/{mongooseToJsonSchema-B187ufOP.mjs → mongooseToJsonSchema-BYbG894y.mjs} +1 -1
- package/dist/{mongooseToJsonSchema-rtkdbBhf.d.mts → mongooseToJsonSchema-BgRAaXX-.d.mts} +2 -1
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +2 -2
- package/dist/plugins/index.mjs +2 -2
- package/dist/query/primitives/coercion.d.mts +1 -1
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/query/primitives/indexes.d.mts +1 -1
- package/dist/{types-DXscZnLu.d.mts → types-BbaPZleN.d.mts} +371 -112
- package/dist/{update-CDxPtpsk.mjs → update-DLcSSa5b.mjs} +41 -27
- package/dist/utils/index.d.mts +2 -91
- package/dist/utils/index.mjs +5 -5
- package/dist/{validation-chain.plugin-CH-xPoKM.d.mts → validation-chain.plugin-CAPYCj_T.d.mts} +212 -80
- package/dist/validation-chain.plugin-_-WuNml2.mjs +2399 -0
- package/package.json +20 -3
- package/dist/field-selection-dI0m4RBs.mjs +0 -221
- package/dist/validation-chain.plugin-BicIgzzt.mjs +0 -4970
- /package/dist/{coercion-oM8G4b6m.d.mts → coercion-TRMmR49h.d.mts} +0 -0
- /package/dist/{id-resolution-e4BT9zAV.mjs → id-resolution-B0Odvvou.mjs} +0 -0
- /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 };
|