@classytic/mongokit 3.7.0 → 3.10.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 +12 -3
- package/dist/{PaginationEngine-CPmZv7Ds.mjs → PaginationEngine-B93fLCcO.mjs} +3 -3
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/{error-Lw9GEAro.mjs → error-CzYwrfUP.mjs} +25 -3
- package/dist/{index-C-qZUqv8.d.mts → index-C7WmYP6F.d.mts} +14 -6
- package/dist/index.d.mts +263 -77
- package/dist/index.mjs +6 -6
- package/dist/{mongooseToJsonSchema-DUwXPStK.mjs → mongooseToJsonSchema-D1sdCOmj.mjs} +21 -103
- package/dist/{mongooseToJsonSchema-Du-UopRK.d.mts → mongooseToJsonSchema-t-MoVleF.d.mts} +20 -19
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/{types-BgdeBXI1.d.mts → types-BUS0Zj8y.d.mts} +70 -133
- package/dist/{update-BDsQ3cdy.mjs → update-CWXfFC6O.mjs} +52 -13
- package/dist/utils/index.d.mts +3 -3
- package/dist/utils/index.mjs +3 -3
- package/dist/{validation-chain.plugin-CEyJssZl.mjs → validation-chain.plugin-Ccu6irtx.mjs} +1081 -381
- package/dist/{validation-chain.plugin-Be9rG04B.d.mts → validation-chain.plugin-JkipjuOR.d.mts} +4 -36
- package/package.json +5 -3
- /package/dist/{cursor-DZ9JSoMM.mjs → cursor-J84rR6wG.mjs} +0 -0
- /package/dist/{sort-ffe-9JBg.mjs → sort-CrZjTK25.mjs} +0 -0
|
@@ -1,12 +1,149 @@
|
|
|
1
1
|
import { n as debug, r as warn } from "./logger-DJ7egMnd.mjs";
|
|
2
|
-
import { a as
|
|
3
|
-
import { n as parseDuplicateKeyError, t as createError } from "./error-
|
|
4
|
-
import { t as PaginationEngine } from "./PaginationEngine-
|
|
2
|
+
import { _ as upsert, 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, y as distinct } from "./update-CWXfFC6O.mjs";
|
|
3
|
+
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-CzYwrfUP.mjs";
|
|
4
|
+
import { t as PaginationEngine } from "./PaginationEngine-B93fLCcO.mjs";
|
|
5
5
|
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
6
6
|
import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-e4BT9zAV.mjs";
|
|
7
7
|
import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-dI0m4RBs.mjs";
|
|
8
8
|
import mongoose from "mongoose";
|
|
9
|
+
import { isFilter } from "@classytic/repo-core/filter";
|
|
10
|
+
import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
|
|
11
|
+
import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
|
|
9
12
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
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
|
+
delete: {
|
|
36
|
+
policyKey: "query",
|
|
37
|
+
mutates: true,
|
|
38
|
+
hasIdContext: true
|
|
39
|
+
},
|
|
40
|
+
restore: {
|
|
41
|
+
policyKey: "query",
|
|
42
|
+
mutates: true,
|
|
43
|
+
hasIdContext: true
|
|
44
|
+
},
|
|
45
|
+
createMany: {
|
|
46
|
+
policyKey: "dataArray",
|
|
47
|
+
mutates: true,
|
|
48
|
+
hasIdContext: false
|
|
49
|
+
},
|
|
50
|
+
updateMany: {
|
|
51
|
+
policyKey: "query",
|
|
52
|
+
mutates: true,
|
|
53
|
+
hasIdContext: false
|
|
54
|
+
},
|
|
55
|
+
deleteMany: {
|
|
56
|
+
policyKey: "query",
|
|
57
|
+
mutates: true,
|
|
58
|
+
hasIdContext: false
|
|
59
|
+
},
|
|
60
|
+
bulkWrite: {
|
|
61
|
+
policyKey: "operations",
|
|
62
|
+
mutates: true,
|
|
63
|
+
hasIdContext: false
|
|
64
|
+
},
|
|
65
|
+
getById: {
|
|
66
|
+
policyKey: "query",
|
|
67
|
+
mutates: false,
|
|
68
|
+
hasIdContext: true
|
|
69
|
+
},
|
|
70
|
+
getByQuery: {
|
|
71
|
+
policyKey: "query",
|
|
72
|
+
mutates: false,
|
|
73
|
+
hasIdContext: false
|
|
74
|
+
},
|
|
75
|
+
getOne: {
|
|
76
|
+
policyKey: "query",
|
|
77
|
+
mutates: false,
|
|
78
|
+
hasIdContext: false
|
|
79
|
+
},
|
|
80
|
+
findAll: {
|
|
81
|
+
policyKey: "query",
|
|
82
|
+
mutates: false,
|
|
83
|
+
hasIdContext: false
|
|
84
|
+
},
|
|
85
|
+
getOrCreate: {
|
|
86
|
+
policyKey: "query",
|
|
87
|
+
mutates: false,
|
|
88
|
+
hasIdContext: false
|
|
89
|
+
},
|
|
90
|
+
count: {
|
|
91
|
+
policyKey: "query",
|
|
92
|
+
mutates: false,
|
|
93
|
+
hasIdContext: false
|
|
94
|
+
},
|
|
95
|
+
exists: {
|
|
96
|
+
policyKey: "query",
|
|
97
|
+
mutates: false,
|
|
98
|
+
hasIdContext: false
|
|
99
|
+
},
|
|
100
|
+
distinct: {
|
|
101
|
+
policyKey: "query",
|
|
102
|
+
mutates: false,
|
|
103
|
+
hasIdContext: false
|
|
104
|
+
},
|
|
105
|
+
aggregate: {
|
|
106
|
+
policyKey: "query",
|
|
107
|
+
mutates: false,
|
|
108
|
+
hasIdContext: false
|
|
109
|
+
},
|
|
110
|
+
aggregatePipeline: {
|
|
111
|
+
policyKey: "query",
|
|
112
|
+
mutates: false,
|
|
113
|
+
hasIdContext: false
|
|
114
|
+
},
|
|
115
|
+
getAll: {
|
|
116
|
+
policyKey: "filters",
|
|
117
|
+
mutates: false,
|
|
118
|
+
hasIdContext: false
|
|
119
|
+
},
|
|
120
|
+
aggregatePaginate: {
|
|
121
|
+
policyKey: "filters",
|
|
122
|
+
mutates: false,
|
|
123
|
+
hasIdContext: false
|
|
124
|
+
},
|
|
125
|
+
aggregatePipelinePaginate: {
|
|
126
|
+
policyKey: "filters",
|
|
127
|
+
mutates: false,
|
|
128
|
+
hasIdContext: false
|
|
129
|
+
},
|
|
130
|
+
lookupPopulate: {
|
|
131
|
+
policyKey: "filters",
|
|
132
|
+
mutates: false,
|
|
133
|
+
hasIdContext: false
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** All known repository operations, in registry order. */
|
|
137
|
+
const ALL_OPERATIONS = Object.keys(OP_REGISTRY);
|
|
138
|
+
/** Operations that mutate the database. Drives audit + cache invalidation lists. */
|
|
139
|
+
const MUTATING_OPERATIONS = ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].mutates);
|
|
140
|
+
/** Operations that don't mutate. Drives default cacheable ops. */
|
|
141
|
+
const READ_OPERATIONS = ALL_OPERATIONS.filter((op) => !OP_REGISTRY[op].mutates);
|
|
142
|
+
/** Filter ops by their policy injection key. */
|
|
143
|
+
function operationsByPolicyKey(key) {
|
|
144
|
+
return ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].policyKey === key);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
10
147
|
//#region src/plugins/aggregate-helpers.plugin.ts
|
|
11
148
|
/**
|
|
12
149
|
* Aggregate helpers plugin
|
|
@@ -34,14 +171,14 @@ function aggregateHelpersPlugin() {
|
|
|
34
171
|
count: { $sum: 1 }
|
|
35
172
|
} }, { $sort: { count: -1 } }];
|
|
36
173
|
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
37
|
-
return this.
|
|
174
|
+
return this.aggregatePipeline(pipeline, options);
|
|
38
175
|
});
|
|
39
176
|
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
40
177
|
const pipeline = [{ $match: query }, { $group: {
|
|
41
178
|
_id: null,
|
|
42
179
|
[resultKey]: { [operator]: `$${field}` }
|
|
43
180
|
} }];
|
|
44
|
-
return (await this.
|
|
181
|
+
return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
|
|
45
182
|
};
|
|
46
183
|
/**
|
|
47
184
|
* Sum field values
|
|
@@ -99,6 +236,22 @@ function auditLogPlugin(logger) {
|
|
|
99
236
|
organizationId: context.organizationId
|
|
100
237
|
});
|
|
101
238
|
});
|
|
239
|
+
repo.on("after:findOneAndUpdate", ({ context, result }) => {
|
|
240
|
+
if (!result) return;
|
|
241
|
+
logger?.info?.("Document upserted/updated (findOneAndUpdate)", {
|
|
242
|
+
model: context.model || repo.model,
|
|
243
|
+
id: result?.[repo.idField || "_id"],
|
|
244
|
+
userId: context.user?._id || context.user?.id,
|
|
245
|
+
organizationId: context.organizationId
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
repo.on("error:findOneAndUpdate", ({ context, error }) => {
|
|
249
|
+
logger?.error?.("findOneAndUpdate failed", {
|
|
250
|
+
model: context.model || repo.model,
|
|
251
|
+
error: error.message,
|
|
252
|
+
userId: context.user?._id || context.user?.id
|
|
253
|
+
});
|
|
254
|
+
});
|
|
102
255
|
repo.on("after:delete", ({ context }) => {
|
|
103
256
|
logger?.info?.("Document deleted", {
|
|
104
257
|
model: context.model || repo.model,
|
|
@@ -178,6 +331,7 @@ function getAuditModel(collectionName, ttlDays) {
|
|
|
178
331
|
enum: [
|
|
179
332
|
"create",
|
|
180
333
|
"update",
|
|
334
|
+
"findOneAndUpdate",
|
|
181
335
|
"delete"
|
|
182
336
|
]
|
|
183
337
|
},
|
|
@@ -327,6 +481,19 @@ function auditTrailPlugin(options = {}) {
|
|
|
327
481
|
});
|
|
328
482
|
});
|
|
329
483
|
}
|
|
484
|
+
if (opsSet.has("findOneAndUpdate")) repo.on("after:findOneAndUpdate", ({ context, result }) => {
|
|
485
|
+
if (!result) return;
|
|
486
|
+
const doc = result;
|
|
487
|
+
const idKey = repo.idField || "_id";
|
|
488
|
+
writeAudit(AuditModel, {
|
|
489
|
+
model: context.model || repo.model,
|
|
490
|
+
operation: "findOneAndUpdate",
|
|
491
|
+
documentId: doc?.[idKey],
|
|
492
|
+
userId: getUserId(context),
|
|
493
|
+
orgId: context.organizationId,
|
|
494
|
+
metadata: metadata?.(context)
|
|
495
|
+
});
|
|
496
|
+
});
|
|
330
497
|
if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
|
|
331
498
|
writeAudit(AuditModel, {
|
|
332
499
|
model: context.model || repo.model,
|
|
@@ -635,6 +802,231 @@ function batchOperationsPlugin() {
|
|
|
635
802
|
};
|
|
636
803
|
}
|
|
637
804
|
//#endregion
|
|
805
|
+
//#region src/filter/compile.ts
|
|
806
|
+
/**
|
|
807
|
+
* Compile a Filter IR node to a MongoDB query object. Returns `{}`
|
|
808
|
+
* when the node is `TRUE` (or an empty `and`) so callers can merge
|
|
809
|
+
* with other scopes without special-casing.
|
|
810
|
+
*
|
|
811
|
+
* The `input` is typed as `unknown` so callers can pass either a
|
|
812
|
+
* Filter IR node or an already-built Mongo query and we dispatch
|
|
813
|
+
* appropriately. This keeps the portable API ergonomic for apps that
|
|
814
|
+
* write Mongo queries directly today.
|
|
815
|
+
*/
|
|
816
|
+
function compileFilterToMongo(input) {
|
|
817
|
+
if (!input) return {};
|
|
818
|
+
if (!isFilter(input)) return input;
|
|
819
|
+
return compile(input);
|
|
820
|
+
}
|
|
821
|
+
function compile(filter) {
|
|
822
|
+
switch (filter.op) {
|
|
823
|
+
case "true": return {};
|
|
824
|
+
case "false": return { $expr: false };
|
|
825
|
+
case "eq": return { [filter.field]: filter.value };
|
|
826
|
+
case "ne": return filter.value === null ? { [filter.field]: { $ne: null } } : { $and: [{ [filter.field]: { $ne: filter.value } }, { [filter.field]: { $ne: null } }] };
|
|
827
|
+
case "gt": return { [filter.field]: { $gt: filter.value } };
|
|
828
|
+
case "gte": return { [filter.field]: { $gte: filter.value } };
|
|
829
|
+
case "lt": return { [filter.field]: { $lt: filter.value } };
|
|
830
|
+
case "lte": return { [filter.field]: { $lte: filter.value } };
|
|
831
|
+
case "in":
|
|
832
|
+
if (filter.values.length === 0) return { $expr: false };
|
|
833
|
+
return { [filter.field]: { $in: [...filter.values] } };
|
|
834
|
+
case "nin":
|
|
835
|
+
if (filter.values.length === 0) return {};
|
|
836
|
+
return { [filter.field]: { $nin: [...filter.values] } };
|
|
837
|
+
case "exists": return filter.exists ? { [filter.field]: { $ne: null } } : { [filter.field]: null };
|
|
838
|
+
case "like": return { [filter.field]: likeToRegex(filter.pattern, filter.caseSensitivity) };
|
|
839
|
+
case "regex": return { [filter.field]: { $regex: filter.pattern } };
|
|
840
|
+
case "and": {
|
|
841
|
+
if (filter.children.length === 0) return {};
|
|
842
|
+
const parts = filter.children.map(compile);
|
|
843
|
+
if (parts.length === 1) return parts[0];
|
|
844
|
+
return { $and: parts };
|
|
845
|
+
}
|
|
846
|
+
case "or": {
|
|
847
|
+
if (filter.children.length === 0) return { $expr: false };
|
|
848
|
+
const parts = filter.children.map(compile);
|
|
849
|
+
if (parts.length === 1) return parts[0];
|
|
850
|
+
return { $or: parts };
|
|
851
|
+
}
|
|
852
|
+
case "not": return { $nor: [compile(filter.child)] };
|
|
853
|
+
case "raw": throw new Error("mongokit/filter: `raw` is a SQL escape hatch and has no safe MongoDB translation. Pass a Mongo query object directly to the filter slot instead.");
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Translate a SQL-style LIKE pattern to a MongoDB regex.
|
|
858
|
+
*
|
|
859
|
+
* SQL semantics:
|
|
860
|
+
* - `%` → match zero or more characters (`.*`)
|
|
861
|
+
* - `_` → match exactly one character (`.`)
|
|
862
|
+
* - `\%` → literal percent (escape via backslash)
|
|
863
|
+
* - `\_` → literal underscore
|
|
864
|
+
* - `\\` → literal backslash
|
|
865
|
+
* - everything else is literal — regex metacharacters get escaped.
|
|
866
|
+
*
|
|
867
|
+
* The naive implementation `pattern.replace(/%/g, '.*')` is wrong:
|
|
868
|
+
* it also expands escaped `\%` into a wildcard. We walk the pattern
|
|
869
|
+
* char-by-char, honoring backslash escapes, so `like('notes', '50\\% off')`
|
|
870
|
+
* matches the literal string `50% off` instead of `50<anything> off`.
|
|
871
|
+
*
|
|
872
|
+
* Anchored with `^` / `$` so LIKE matches the whole field value, the
|
|
873
|
+
* same way sqlitekit's `LIKE` semantically does.
|
|
874
|
+
*/
|
|
875
|
+
function likeToRegex(pattern, caseSensitivity) {
|
|
876
|
+
let out = "^";
|
|
877
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
878
|
+
const ch = pattern[i];
|
|
879
|
+
if (ch === "\\" && i + 1 < pattern.length) {
|
|
880
|
+
const next = pattern[i + 1];
|
|
881
|
+
if (next === "%" || next === "_" || next === "\\") {
|
|
882
|
+
out += escapeRegexChar(next);
|
|
883
|
+
i++;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (ch === "%") out += ".*";
|
|
888
|
+
else if (ch === "_") out += ".";
|
|
889
|
+
else out += escapeRegexChar(ch);
|
|
890
|
+
}
|
|
891
|
+
out += "$";
|
|
892
|
+
return caseSensitivity === "sensitive" ? { $regex: out } : {
|
|
893
|
+
$regex: out,
|
|
894
|
+
$options: "i"
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function escapeRegexChar(ch) {
|
|
898
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
899
|
+
}
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/actions/aggregate-ir/normalize.ts
|
|
902
|
+
/**
|
|
903
|
+
* Normalize `AggRequest['groupBy']` into a readonly string array.
|
|
904
|
+
* Returns `[]` for scalar aggregation (no groupBy). Downstream
|
|
905
|
+
* compilers treat `[]` uniformly — one `$group: { _id: null, ... }`
|
|
906
|
+
* stage, no per-key projection.
|
|
907
|
+
*/
|
|
908
|
+
function normalizeGroupBy(groupBy) {
|
|
909
|
+
if (!groupBy) return [];
|
|
910
|
+
if (typeof groupBy === "string") return [groupBy];
|
|
911
|
+
return groupBy;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Fail loud on an empty measures bag — there's nothing to compute and
|
|
915
|
+
* the caller's code path is almost certainly a wiring bug (conditional
|
|
916
|
+
* collapsed, key renamed, etc.). Silently returning `{ rows: [] }`
|
|
917
|
+
* would mask it.
|
|
918
|
+
*/
|
|
919
|
+
function validateMeasures(measures) {
|
|
920
|
+
if (!measures || Object.keys(measures).length === 0) throw new Error("mongokit/aggregate: AggRequest requires at least one measure — empty measures bag is a wiring bug");
|
|
921
|
+
}
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/actions/aggregate-ir/measure.ts
|
|
924
|
+
function compileMeasure(measure) {
|
|
925
|
+
switch (measure.op) {
|
|
926
|
+
case "count":
|
|
927
|
+
if (!measure.field || measure.field === "*") return { groupExpr: { $sum: 1 } };
|
|
928
|
+
return { groupExpr: { $sum: { $cond: [
|
|
929
|
+
{ $ne: [`$${measure.field}`, null] },
|
|
930
|
+
1,
|
|
931
|
+
0
|
|
932
|
+
] } } };
|
|
933
|
+
case "countDistinct": return {
|
|
934
|
+
groupExpr: { $addToSet: `$${measure.field}` },
|
|
935
|
+
projectExpr: { $size: { $ifNull: ["$$CURRENT", []] } }
|
|
936
|
+
};
|
|
937
|
+
case "sum": return { groupExpr: { $sum: `$${measure.field}` } };
|
|
938
|
+
case "avg": return { groupExpr: { $avg: `$${measure.field}` } };
|
|
939
|
+
case "min": return { groupExpr: { $min: `$${measure.field}` } };
|
|
940
|
+
case "max": return { groupExpr: { $max: `$${measure.field}` } };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/actions/aggregate-ir/pipeline.ts
|
|
945
|
+
function buildAggPipeline(req) {
|
|
946
|
+
validateMeasures(req.measures);
|
|
947
|
+
const groupCols = normalizeGroupBy(req.groupBy);
|
|
948
|
+
const stages = [];
|
|
949
|
+
if (req.filter) {
|
|
950
|
+
const match = compileFilterToMongo(req.filter);
|
|
951
|
+
if (Object.keys(match).length > 0) stages.push({ $match: match });
|
|
952
|
+
}
|
|
953
|
+
const groupStage = { _id: groupCols.length === 0 ? null : groupCols.reduce((acc, field) => {
|
|
954
|
+
acc[field] = `$${field}`;
|
|
955
|
+
return acc;
|
|
956
|
+
}, {}) };
|
|
957
|
+
const distinctSetAliases = [];
|
|
958
|
+
for (const [alias, measure] of Object.entries(req.measures)) {
|
|
959
|
+
groupStage[alias] = compileMeasure(measure).groupExpr;
|
|
960
|
+
if (measure.op === "countDistinct") distinctSetAliases.push(alias);
|
|
961
|
+
}
|
|
962
|
+
stages.push({ $group: groupStage });
|
|
963
|
+
if (distinctSetAliases.length > 0) {
|
|
964
|
+
const addFields = {};
|
|
965
|
+
for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
|
|
966
|
+
stages.push({ $addFields: addFields });
|
|
967
|
+
}
|
|
968
|
+
const projection = { _id: 0 };
|
|
969
|
+
for (const field of groupCols) projection[field] = `$_id.${field}`;
|
|
970
|
+
for (const alias of Object.keys(req.measures)) projection[alias] = 1;
|
|
971
|
+
stages.push({ $project: projection });
|
|
972
|
+
if (req.having) {
|
|
973
|
+
const match = compileFilterToMongo(req.having);
|
|
974
|
+
if (Object.keys(match).length > 0) stages.push({ $match: match });
|
|
975
|
+
}
|
|
976
|
+
const prePaginationIndex = stages.length;
|
|
977
|
+
if (req.sort) stages.push({ $sort: req.sort });
|
|
978
|
+
if (typeof req.offset === "number" && req.offset > 0) stages.push({ $skip: req.offset });
|
|
979
|
+
if (typeof req.limit === "number") stages.push({ $limit: req.limit });
|
|
980
|
+
return {
|
|
981
|
+
pipeline: stages,
|
|
982
|
+
prePaginationIndex
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/actions/aggregate-ir/count.ts
|
|
987
|
+
async function countAggGroups(Model, req, options = {}) {
|
|
988
|
+
const groupCols = normalizeGroupBy(req.groupBy);
|
|
989
|
+
if (req.having) {
|
|
990
|
+
const { pipeline, prePaginationIndex } = buildAggPipeline(req);
|
|
991
|
+
const finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
|
|
992
|
+
const aggregation = Model.aggregate(finalPipeline);
|
|
993
|
+
if (options.session) aggregation.session(options.session);
|
|
994
|
+
const [row] = await aggregation.exec();
|
|
995
|
+
return row?.n ?? 0;
|
|
996
|
+
}
|
|
997
|
+
const match = req.filter ? compileFilterToMongo(req.filter) : {};
|
|
998
|
+
if (groupCols.length === 0) {
|
|
999
|
+
const pipeline = [];
|
|
1000
|
+
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
1001
|
+
pipeline.push({ $limit: 1 });
|
|
1002
|
+
pipeline.push({ $count: "n" });
|
|
1003
|
+
const aggregation = Model.aggregate(pipeline);
|
|
1004
|
+
if (options.session) aggregation.session(options.session);
|
|
1005
|
+
const [row] = await aggregation.exec();
|
|
1006
|
+
return row?.n ?? 0;
|
|
1007
|
+
}
|
|
1008
|
+
const groupId = groupCols.reduce((acc, field) => {
|
|
1009
|
+
acc[field] = `$${field}`;
|
|
1010
|
+
return acc;
|
|
1011
|
+
}, {});
|
|
1012
|
+
const pipeline = [];
|
|
1013
|
+
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
1014
|
+
pipeline.push({ $group: { _id: groupId } });
|
|
1015
|
+
pipeline.push({ $count: "n" });
|
|
1016
|
+
const aggregation = Model.aggregate(pipeline);
|
|
1017
|
+
if (options.session) aggregation.session(options.session);
|
|
1018
|
+
const [row] = await aggregation.exec();
|
|
1019
|
+
return row?.n ?? 0;
|
|
1020
|
+
}
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/actions/aggregate-ir/execute.ts
|
|
1023
|
+
async function executeAgg(Model, req, options = {}) {
|
|
1024
|
+
const { pipeline } = buildAggPipeline(req);
|
|
1025
|
+
const aggregation = Model.aggregate(pipeline);
|
|
1026
|
+
if (options.session) aggregation.session(options.session);
|
|
1027
|
+
return await aggregation.exec();
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
638
1030
|
//#region src/query/AggregationBuilder.ts
|
|
639
1031
|
/**
|
|
640
1032
|
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
@@ -1221,7 +1613,117 @@ function isTransactionUnsupported(error) {
|
|
|
1221
1613
|
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
|
|
1222
1614
|
}
|
|
1223
1615
|
//#endregion
|
|
1616
|
+
//#region src/tx-bound.ts
|
|
1617
|
+
/**
|
|
1618
|
+
* Map of session-aware method name → positional index of the `{ session }`
|
|
1619
|
+
* options bag. Keep this list in sync with the method signatures in
|
|
1620
|
+
* `Repository.ts` and the plugin method definitions. Unknown plugin
|
|
1621
|
+
* methods pass through unwrapped (callers use the standalone helper).
|
|
1622
|
+
*/
|
|
1623
|
+
const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
1624
|
+
create: 1,
|
|
1625
|
+
update: 2,
|
|
1626
|
+
delete: 1,
|
|
1627
|
+
getById: 1,
|
|
1628
|
+
getAll: 1,
|
|
1629
|
+
createMany: 1,
|
|
1630
|
+
findAll: 1,
|
|
1631
|
+
getOrCreate: 2,
|
|
1632
|
+
count: 1,
|
|
1633
|
+
exists: 1,
|
|
1634
|
+
getByQuery: 1,
|
|
1635
|
+
getOne: 1,
|
|
1636
|
+
findOneAndUpdate: 2,
|
|
1637
|
+
distinct: 2,
|
|
1638
|
+
aggregate: 1,
|
|
1639
|
+
aggregatePaginate: 0,
|
|
1640
|
+
lookupPopulate: 0,
|
|
1641
|
+
upsert: 2,
|
|
1642
|
+
increment: 3,
|
|
1643
|
+
decrement: 3,
|
|
1644
|
+
multiplyField: 3,
|
|
1645
|
+
setMin: 3,
|
|
1646
|
+
setMax: 3,
|
|
1647
|
+
pushToArray: 3,
|
|
1648
|
+
pullFromArray: 3,
|
|
1649
|
+
addToSet: 3,
|
|
1650
|
+
setField: 3,
|
|
1651
|
+
unsetField: 2,
|
|
1652
|
+
renameField: 3,
|
|
1653
|
+
atomicUpdate: 2,
|
|
1654
|
+
updateMany: 2,
|
|
1655
|
+
deleteMany: 1,
|
|
1656
|
+
bulkWrite: 1,
|
|
1657
|
+
restore: 1,
|
|
1658
|
+
getDeleted: 1,
|
|
1659
|
+
addSubdocument: 3,
|
|
1660
|
+
getSubdocument: 3,
|
|
1661
|
+
updateSubdocument: 4,
|
|
1662
|
+
deleteSubdocument: 3,
|
|
1663
|
+
groupBy: 1,
|
|
1664
|
+
sum: 2,
|
|
1665
|
+
average: 2,
|
|
1666
|
+
min: 2,
|
|
1667
|
+
max: 2
|
|
1668
|
+
});
|
|
1669
|
+
/**
|
|
1670
|
+
* Build a session-threaded proxy over `outer`. The returned object has the
|
|
1671
|
+
* same method signatures, but every CRUD call auto-injects the supplied
|
|
1672
|
+
* session into the options bag. Non-CRUD properties (Model, modelName,
|
|
1673
|
+
* hook API, utility helpers) pass through.
|
|
1674
|
+
*/
|
|
1675
|
+
function createTxBoundRepo(outer, session) {
|
|
1676
|
+
return new Proxy(outer, { get(target, prop, receiver) {
|
|
1677
|
+
if (prop === "withTransaction") return () => {
|
|
1678
|
+
throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
|
|
1679
|
+
};
|
|
1680
|
+
const value = Reflect.get(target, prop, receiver);
|
|
1681
|
+
if (typeof value !== "function") return value;
|
|
1682
|
+
if (typeof prop === "symbol") return value.bind(target);
|
|
1683
|
+
if (prop.startsWith("_")) return value.bind(target);
|
|
1684
|
+
const optionsIndex = SESSION_OPTIONS_INDEX[prop];
|
|
1685
|
+
if (optionsIndex === void 0) return value.bind(target);
|
|
1686
|
+
return function txBoundMethod(...args) {
|
|
1687
|
+
while (args.length <= optionsIndex) args.push(void 0);
|
|
1688
|
+
const current = args[optionsIndex];
|
|
1689
|
+
if (current === void 0) args[optionsIndex] = { session };
|
|
1690
|
+
else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
|
|
1691
|
+
...current,
|
|
1692
|
+
session
|
|
1693
|
+
};
|
|
1694
|
+
else return value.apply(target, args);
|
|
1695
|
+
return value.apply(target, args);
|
|
1696
|
+
};
|
|
1697
|
+
} });
|
|
1698
|
+
}
|
|
1699
|
+
//#endregion
|
|
1224
1700
|
//#region src/Repository.ts
|
|
1701
|
+
/**
|
|
1702
|
+
* Repository Pattern - Data Access Layer
|
|
1703
|
+
*
|
|
1704
|
+
* Event-driven, plugin-based abstraction for MongoDB operations
|
|
1705
|
+
* Inspired by Meta & Stripe's repository patterns
|
|
1706
|
+
*
|
|
1707
|
+
* @example
|
|
1708
|
+
* ```typescript
|
|
1709
|
+
* const userRepo = new Repository(UserModel, [
|
|
1710
|
+
* timestampPlugin(),
|
|
1711
|
+
* softDeletePlugin(),
|
|
1712
|
+
* ]);
|
|
1713
|
+
*
|
|
1714
|
+
* // Create
|
|
1715
|
+
* const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
|
|
1716
|
+
*
|
|
1717
|
+
* // Read with pagination
|
|
1718
|
+
* const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
|
|
1719
|
+
*
|
|
1720
|
+
* // Update
|
|
1721
|
+
* const updated = await userRepo.update(user._id, { name: 'John Doe' });
|
|
1722
|
+
*
|
|
1723
|
+
* // Delete
|
|
1724
|
+
* await userRepo.delete(user._id);
|
|
1725
|
+
* ```
|
|
1726
|
+
*/
|
|
1225
1727
|
function ensureLookupProjectionIncludesCursorFields(projection, sort) {
|
|
1226
1728
|
if (!projection || !sort) return projection;
|
|
1227
1729
|
if (!Object.values(projection).some((value) => value === 1)) return projection;
|
|
@@ -1230,163 +1732,80 @@ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
|
|
|
1230
1732
|
return nextProjection;
|
|
1231
1733
|
}
|
|
1232
1734
|
/**
|
|
1233
|
-
*
|
|
1234
|
-
* Each tuple is `[mustComeFirst, mustComeAfter, reason]`.
|
|
1735
|
+
* Plugin phase priorities (lower = runs first).
|
|
1235
1736
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1737
|
+
* Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
|
|
1738
|
+
* the same constant whether they import from mongokit or directly from
|
|
1739
|
+
* repo-core — priorities are identical (POLICY=100, CACHE=200,
|
|
1740
|
+
* OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
|
|
1238
1741
|
*/
|
|
1239
|
-
const
|
|
1240
|
-
"soft-delete",
|
|
1241
|
-
"batch-operations",
|
|
1242
|
-
"soft-delete must precede batch-operations so bulk deletes/updates see the soft-delete filter"
|
|
1243
|
-
], [
|
|
1244
|
-
"multi-tenant",
|
|
1245
|
-
"cache",
|
|
1246
|
-
"multi-tenant must precede cache so tenant scoping is baked into cache keys (prevents cross-tenant cache poisoning)"
|
|
1247
|
-
]];
|
|
1248
|
-
function pluginName(plugin) {
|
|
1249
|
-
if (!plugin || typeof plugin === "function") return void 0;
|
|
1250
|
-
const name = plugin.name;
|
|
1251
|
-
return typeof name === "string" ? name : void 0;
|
|
1252
|
-
}
|
|
1253
|
-
function validatePluginOrder(plugins, modelName, mode) {
|
|
1254
|
-
if (mode === "off") return;
|
|
1255
|
-
const names = plugins.map(pluginName);
|
|
1256
|
-
for (const [first, after, reason] of PLUGIN_ORDER_CONSTRAINTS) {
|
|
1257
|
-
const firstIdx = names.indexOf(first);
|
|
1258
|
-
const afterIdx = names.indexOf(after);
|
|
1259
|
-
if (firstIdx === -1 || afterIdx === -1) continue;
|
|
1260
|
-
if (firstIdx < afterIdx) continue;
|
|
1261
|
-
const message = `[mongokit] Repository "${modelName}": plugin order issue — ${reason}. Got: [..., '${after}' at index ${afterIdx}, '${first}' at index ${firstIdx}]. Swap them, or pass { pluginOrderChecks: 'off' } to silence.`;
|
|
1262
|
-
if (mode === "throw") throw new Error(message);
|
|
1263
|
-
warn(message);
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
/**
|
|
1267
|
-
* Plugin phase priorities (lower = runs first)
|
|
1268
|
-
* Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
|
|
1269
|
-
* to ensure filters are injected before cache keys are computed.
|
|
1270
|
-
*/
|
|
1271
|
-
const HOOK_PRIORITY = {
|
|
1272
|
-
POLICY: 100,
|
|
1273
|
-
CACHE: 200,
|
|
1274
|
-
OBSERVABILITY: 300,
|
|
1275
|
-
DEFAULT: 500
|
|
1276
|
-
};
|
|
1742
|
+
const HOOK_PRIORITY$1 = HOOK_PRIORITY;
|
|
1277
1743
|
/**
|
|
1278
|
-
* Production-grade repository for MongoDB
|
|
1279
|
-
*
|
|
1744
|
+
* Production-grade repository for MongoDB.
|
|
1745
|
+
*
|
|
1746
|
+
* Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
|
|
1747
|
+
* driver-agnostic hook + plugin plumbing (context builder, priority-sorted
|
|
1748
|
+
* event engine, plugin-order validator) while layering every Mongo-specific
|
|
1749
|
+
* concern — populate, aggregate, lookup, keyset + cursor pagination, $near
|
|
1750
|
+
* rewriting — on top.
|
|
1751
|
+
*
|
|
1752
|
+
* Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
|
|
1753
|
+
* preserved as a live read-through getter; all CRUD, event, and plugin method
|
|
1754
|
+
* signatures unchanged). 3.10 is an internal re-plumbing release; consumers
|
|
1755
|
+
* upgrading from 3.9 need no code changes.
|
|
1280
1756
|
*/
|
|
1281
|
-
var Repository = class {
|
|
1757
|
+
var Repository = class extends RepositoryBase {
|
|
1282
1758
|
Model;
|
|
1759
|
+
/**
|
|
1760
|
+
* Mongoose model name. Duplicated on the instance for BC — arc / catalog /
|
|
1761
|
+
* user code read `repo.model`. `RepositoryBase.modelName` points to the
|
|
1762
|
+
* same string; prefer `modelName` in new code.
|
|
1763
|
+
*/
|
|
1283
1764
|
model;
|
|
1284
|
-
_hooks;
|
|
1285
1765
|
_pagination;
|
|
1286
|
-
_hookMode;
|
|
1287
1766
|
idField;
|
|
1288
1767
|
searchMode;
|
|
1289
1768
|
searchFields;
|
|
1290
1769
|
_hasTextIndex = null;
|
|
1291
1770
|
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
1771
|
+
super({
|
|
1772
|
+
name: Model.modelName,
|
|
1773
|
+
hooks: options.hooks ?? "async",
|
|
1774
|
+
pluginOrderChecks: "off"
|
|
1775
|
+
});
|
|
1776
|
+
validatePluginOrder(plugins, Model.modelName, options.pluginOrderChecks ?? "warn", (m) => {
|
|
1777
|
+
warn(m);
|
|
1778
|
+
});
|
|
1292
1779
|
this.Model = Model;
|
|
1293
1780
|
this.model = Model.modelName;
|
|
1294
|
-
this._hooks = /* @__PURE__ */ new Map();
|
|
1295
1781
|
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
1296
|
-
this._hookMode = options.hooks ?? "async";
|
|
1297
1782
|
this.idField = options.idField ?? "_id";
|
|
1298
1783
|
this.searchMode = options.searchMode ?? "text";
|
|
1299
1784
|
this.searchFields = options.searchFields;
|
|
1300
1785
|
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.`);
|
|
1301
|
-
|
|
1302
|
-
plugins.forEach((plugin) => {
|
|
1303
|
-
this.use(plugin);
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1306
|
-
/**
|
|
1307
|
-
* Register a plugin
|
|
1308
|
-
*/
|
|
1309
|
-
use(plugin) {
|
|
1310
|
-
if (typeof plugin === "function") plugin(this);
|
|
1311
|
-
else if (plugin && typeof plugin.apply === "function") plugin.apply(this);
|
|
1312
|
-
return this;
|
|
1786
|
+
for (const plugin of plugins) this.use(plugin);
|
|
1313
1787
|
}
|
|
1314
1788
|
/**
|
|
1315
|
-
*
|
|
1789
|
+
* Live read-through view of the hook engine's listener registry.
|
|
1316
1790
|
*
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
|
|
1323
|
-
on(event, listener, options) {
|
|
1324
|
-
if (!this._hooks.has(event)) this._hooks.set(event, []);
|
|
1325
|
-
const hooks = this._hooks.get(event) ?? [];
|
|
1326
|
-
const priority = options?.priority ?? HOOK_PRIORITY.DEFAULT;
|
|
1327
|
-
hooks.push({
|
|
1328
|
-
listener,
|
|
1329
|
-
priority
|
|
1330
|
-
});
|
|
1331
|
-
hooks.sort((a, b) => a.priority - b.priority);
|
|
1332
|
-
return this;
|
|
1333
|
-
}
|
|
1334
|
-
/**
|
|
1335
|
-
* Remove a specific event listener
|
|
1336
|
-
*/
|
|
1337
|
-
off(event, listener) {
|
|
1338
|
-
const hooks = this._hooks.get(event);
|
|
1339
|
-
if (hooks) {
|
|
1340
|
-
const idx = hooks.findIndex((h) => h.listener === listener);
|
|
1341
|
-
if (idx !== -1) hooks.splice(idx, 1);
|
|
1342
|
-
}
|
|
1343
|
-
return this;
|
|
1344
|
-
}
|
|
1345
|
-
/**
|
|
1346
|
-
* Remove all listeners for an event, or all listeners entirely
|
|
1791
|
+
* Preserved as a public field on the Repository for back-compat with
|
|
1792
|
+
* mongokit ≤3.9 — existing tests and observability code pattern-match
|
|
1793
|
+
* `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
|
|
1794
|
+
* returns a fresh snapshot of the underlying HookEngine state.
|
|
1795
|
+
*
|
|
1796
|
+
* Read-only. To register listeners use `repo.on(event, listener, { priority })`.
|
|
1347
1797
|
*/
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
else this._hooks.clear();
|
|
1351
|
-
return this;
|
|
1798
|
+
get _hooks() {
|
|
1799
|
+
return this.hooks.listeners();
|
|
1352
1800
|
}
|
|
1353
1801
|
/**
|
|
1354
|
-
*
|
|
1802
|
+
* Fire-and-forget emit aliased through RepositoryBase's async path so
|
|
1803
|
+
* error hooks converted to/from sync mode still route uniformly. Mongokit
|
|
1804
|
+
* ≤3.9 made this a private method on its subclass; the base class exposes
|
|
1805
|
+
* it publicly and wraps it in `_emitHook` below for internal use.
|
|
1355
1806
|
*/
|
|
1356
|
-
emit(event, data) {
|
|
1357
|
-
const hooks = this._hooks.get(event) || [];
|
|
1358
|
-
for (const { listener } of hooks) try {
|
|
1359
|
-
const result = listener(data);
|
|
1360
|
-
if (result && typeof result.then === "function") result.catch((error) => {
|
|
1361
|
-
if (event === "error:hook") return;
|
|
1362
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
1363
|
-
this.emit("error:hook", {
|
|
1364
|
-
event,
|
|
1365
|
-
error: err
|
|
1366
|
-
});
|
|
1367
|
-
});
|
|
1368
|
-
} catch (error) {
|
|
1369
|
-
if (event === "error:hook") continue;
|
|
1370
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
1371
|
-
this.emit("error:hook", {
|
|
1372
|
-
event,
|
|
1373
|
-
error: err
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* Emit event and await all async handlers (sorted by priority)
|
|
1379
|
-
*/
|
|
1380
|
-
async emitAsync(event, data) {
|
|
1381
|
-
const hooks = this._hooks.get(event) || [];
|
|
1382
|
-
for (const { listener } of hooks) await listener(data);
|
|
1383
|
-
}
|
|
1384
1807
|
async _emitHook(event, data) {
|
|
1385
|
-
|
|
1386
|
-
await this.emitAsync(event, data);
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
this.emit(event, data);
|
|
1808
|
+
await this.hooks.emitAccordingToMode(event, data);
|
|
1390
1809
|
}
|
|
1391
1810
|
async _emitErrorHook(event, data) {
|
|
1392
1811
|
try {
|
|
@@ -1473,11 +1892,13 @@ var Repository = class {
|
|
|
1473
1892
|
});
|
|
1474
1893
|
return result;
|
|
1475
1894
|
}
|
|
1895
|
+
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
1476
1896
|
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1897
|
+
if (wantsThrow) throw createError(404, "Document not found");
|
|
1898
|
+
return null;
|
|
1479
1899
|
}
|
|
1480
1900
|
const result = await getById(this.Model, id, context);
|
|
1901
|
+
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
1481
1902
|
await this._emitHook("after:getById", {
|
|
1482
1903
|
context,
|
|
1483
1904
|
result
|
|
@@ -1576,10 +1997,10 @@ var Repository = class {
|
|
|
1576
1997
|
*/
|
|
1577
1998
|
async findAll(filters = {}, options = {}) {
|
|
1578
1999
|
const context = await this._buildContext("findAll", {
|
|
1579
|
-
filters,
|
|
2000
|
+
query: filters,
|
|
1580
2001
|
...options
|
|
1581
2002
|
});
|
|
1582
|
-
const resolvedFilters = context.
|
|
2003
|
+
const resolvedFilters = context.query ?? filters;
|
|
1583
2004
|
try {
|
|
1584
2005
|
const query = this.Model.find(resolvedFilters);
|
|
1585
2006
|
const sortSpec = context.sort || options.sort;
|
|
@@ -1676,7 +2097,7 @@ var Repository = class {
|
|
|
1676
2097
|
};
|
|
1677
2098
|
const lookups = context.lookups ?? params.lookups;
|
|
1678
2099
|
if (lookups && lookups.length > 0) try {
|
|
1679
|
-
const
|
|
2100
|
+
const result = await this.lookupPopulate({
|
|
1680
2101
|
filters: query,
|
|
1681
2102
|
lookups,
|
|
1682
2103
|
sort: paginationOptions.sort,
|
|
@@ -1689,31 +2110,6 @@ var Repository = class {
|
|
|
1689
2110
|
collation: paginationOptions.collation,
|
|
1690
2111
|
countStrategy: context.countStrategy ?? params.countStrategy
|
|
1691
2112
|
});
|
|
1692
|
-
let result;
|
|
1693
|
-
if (lookupResult.next !== void 0) result = {
|
|
1694
|
-
method: "keyset",
|
|
1695
|
-
docs: lookupResult.data,
|
|
1696
|
-
limit: lookupResult.limit ?? limit,
|
|
1697
|
-
hasMore: lookupResult.hasMore ?? false,
|
|
1698
|
-
next: lookupResult.next ?? null
|
|
1699
|
-
};
|
|
1700
|
-
else {
|
|
1701
|
-
const total = lookupResult.total ?? 0;
|
|
1702
|
-
const resultLimit = lookupResult.limit ?? limit;
|
|
1703
|
-
const totalPages = Math.ceil(total / resultLimit);
|
|
1704
|
-
const currentPage = lookupResult.page ?? 1;
|
|
1705
|
-
const hasNext = lookupResult.hasMore !== void 0 ? lookupResult.hasMore : currentPage < totalPages;
|
|
1706
|
-
result = {
|
|
1707
|
-
method: "offset",
|
|
1708
|
-
docs: lookupResult.data,
|
|
1709
|
-
page: currentPage,
|
|
1710
|
-
limit: resultLimit,
|
|
1711
|
-
total,
|
|
1712
|
-
pages: totalPages,
|
|
1713
|
-
hasNext,
|
|
1714
|
-
hasPrev: currentPage > 1
|
|
1715
|
-
};
|
|
1716
|
-
}
|
|
1717
2113
|
await this._emitHook("after:getAll", {
|
|
1718
2114
|
context,
|
|
1719
2115
|
result
|
|
@@ -1848,16 +2244,25 @@ var Repository = class {
|
|
|
1848
2244
|
...options
|
|
1849
2245
|
});
|
|
1850
2246
|
try {
|
|
1851
|
-
|
|
2247
|
+
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
1852
2248
|
const effectiveIdField = options.idField ?? this.idField;
|
|
1853
|
-
if (effectiveIdField
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
2249
|
+
if (effectiveIdField === "_id") {
|
|
2250
|
+
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
2251
|
+
if (wantsThrow) throw createError(404, "Document not found");
|
|
2252
|
+
await this._emitHook("after:update", {
|
|
2253
|
+
context,
|
|
2254
|
+
result: null
|
|
2255
|
+
});
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
let result;
|
|
2260
|
+
if (effectiveIdField !== "_id") result = await updateByQuery(this.Model, {
|
|
2261
|
+
[effectiveIdField]: id,
|
|
2262
|
+
...context.query || {}
|
|
2263
|
+
}, context.data || data, context);
|
|
2264
|
+
else result = await update(this.Model, id, context.data || data, context);
|
|
2265
|
+
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
1861
2266
|
await this._emitHook("after:update", {
|
|
1862
2267
|
context,
|
|
1863
2268
|
result
|
|
@@ -1872,6 +2277,64 @@ var Repository = class {
|
|
|
1872
2277
|
}
|
|
1873
2278
|
}
|
|
1874
2279
|
/**
|
|
2280
|
+
* Atomic findOneAndUpdate (compare-and-set primitive).
|
|
2281
|
+
*
|
|
2282
|
+
* Single round-trip match-and-mutate for outbox relays, distributed locks,
|
|
2283
|
+
* and workflow semaphores. Goes through the full hook pipeline so
|
|
2284
|
+
* multi-tenant scope, soft-delete, audit, and cache plugins all apply.
|
|
2285
|
+
*
|
|
2286
|
+
* Returns the matched document (post-update by default) or `null` when no
|
|
2287
|
+
* document matches and `upsert` is false.
|
|
2288
|
+
*
|
|
2289
|
+
* @example FIFO claim-lease for an outbox worker
|
|
2290
|
+
* ```ts
|
|
2291
|
+
* const claimed = await outboxRepo.findOneAndUpdate(
|
|
2292
|
+
* { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
|
|
2293
|
+
* { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
|
|
2294
|
+
* { sort: { createdAt: 1 }, returnDocument: 'after' },
|
|
2295
|
+
* );
|
|
2296
|
+
* if (!claimed) return; // queue empty
|
|
2297
|
+
* ```
|
|
2298
|
+
*
|
|
2299
|
+
* @example CAS upsert
|
|
2300
|
+
* ```ts
|
|
2301
|
+
* const lock = await locksRepo.findOneAndUpdate(
|
|
2302
|
+
* { _id: lockKey, ownerId: { $in: [null, workerId] } },
|
|
2303
|
+
* { $set: { ownerId: workerId, expiresAt: leaseUntil } },
|
|
2304
|
+
* { upsert: true },
|
|
2305
|
+
* );
|
|
2306
|
+
* ```
|
|
2307
|
+
*/
|
|
2308
|
+
async findOneAndUpdate(filter, update, options = {}) {
|
|
2309
|
+
const context = await this._buildContext("findOneAndUpdate", {
|
|
2310
|
+
query: filter,
|
|
2311
|
+
data: update,
|
|
2312
|
+
...options
|
|
2313
|
+
});
|
|
2314
|
+
try {
|
|
2315
|
+
const finalQuery = context.query || filter;
|
|
2316
|
+
const finalUpdate = context.data || update;
|
|
2317
|
+
const result = await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
|
|
2318
|
+
...options,
|
|
2319
|
+
sort: options.sort,
|
|
2320
|
+
returnDocument: options.returnDocument ?? "after",
|
|
2321
|
+
upsert: options.upsert ?? false,
|
|
2322
|
+
session: options.session
|
|
2323
|
+
});
|
|
2324
|
+
await this._emitHook("after:findOneAndUpdate", {
|
|
2325
|
+
context,
|
|
2326
|
+
result
|
|
2327
|
+
});
|
|
2328
|
+
return result;
|
|
2329
|
+
} catch (error) {
|
|
2330
|
+
await this._emitErrorHook("error:findOneAndUpdate", {
|
|
2331
|
+
context,
|
|
2332
|
+
error
|
|
2333
|
+
});
|
|
2334
|
+
throw this._handleError(error);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
1875
2338
|
* Delete a document by ID.
|
|
1876
2339
|
*
|
|
1877
2340
|
* By default the behavior is decided by the plugin stack:
|
|
@@ -1913,6 +2376,22 @@ var Repository = class {
|
|
|
1913
2376
|
return result;
|
|
1914
2377
|
}
|
|
1915
2378
|
const effectiveIdField = options.idField ?? this.idField;
|
|
2379
|
+
const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
|
|
2380
|
+
if (effectiveIdField === "_id") {
|
|
2381
|
+
if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
|
|
2382
|
+
if (wantsThrow) throw createError(404, "Document not found");
|
|
2383
|
+
const result = {
|
|
2384
|
+
success: false,
|
|
2385
|
+
message: "Document not found",
|
|
2386
|
+
id: String(id)
|
|
2387
|
+
};
|
|
2388
|
+
await this._emitHook("after:delete", {
|
|
2389
|
+
context,
|
|
2390
|
+
result
|
|
2391
|
+
});
|
|
2392
|
+
return result;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
1916
2395
|
const deleteQuery = effectiveIdField !== "_id" ? {
|
|
1917
2396
|
[effectiveIdField]: id,
|
|
1918
2397
|
...context.query || {}
|
|
@@ -1921,6 +2400,7 @@ var Repository = class {
|
|
|
1921
2400
|
session: options.session,
|
|
1922
2401
|
query: context.query
|
|
1923
2402
|
});
|
|
2403
|
+
if (!result.success && wantsThrow) throw createError(404, "Document not found");
|
|
1924
2404
|
await this._emitHook("after:delete", {
|
|
1925
2405
|
context,
|
|
1926
2406
|
result
|
|
@@ -1935,14 +2415,24 @@ var Repository = class {
|
|
|
1935
2415
|
}
|
|
1936
2416
|
}
|
|
1937
2417
|
/**
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
2418
|
+
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
2419
|
+
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
|
2420
|
+
* `$facet`, `$graphLookup`, and any other mongo-specific power
|
|
2421
|
+
* features that don't translate to SQL.
|
|
2422
|
+
*
|
|
2423
|
+
* For backend-portable aggregations (filter + group + measures +
|
|
2424
|
+
* sort + limit) use `aggregate(req: AggRequest)` instead — it
|
|
2425
|
+
* compiles the repo-core IR to the equivalent pipeline internally
|
|
2426
|
+
* and produces the same row shape sqlitekit emits.
|
|
1940
2427
|
*
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
2428
|
+
* Routes through the hook system for policy enforcement
|
|
2429
|
+
* (multi-tenant, soft-delete).
|
|
2430
|
+
*
|
|
2431
|
+
* @param pipeline - MongoDB aggregation stage array
|
|
2432
|
+
* @param options - Aggregation options including governance controls
|
|
1943
2433
|
*/
|
|
1944
|
-
async
|
|
1945
|
-
const context = await this._buildContext("
|
|
2434
|
+
async aggregatePipeline(pipeline, options = {}) {
|
|
2435
|
+
const context = await this._buildContext("aggregatePipeline", {
|
|
1946
2436
|
pipeline,
|
|
1947
2437
|
...options
|
|
1948
2438
|
});
|
|
@@ -1960,13 +2450,13 @@ var Repository = class {
|
|
|
1960
2450
|
if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
|
|
1961
2451
|
if (options.collation) aggregation.collation(options.collation);
|
|
1962
2452
|
const result = await aggregation.exec();
|
|
1963
|
-
await this._emitHook("after:
|
|
2453
|
+
await this._emitHook("after:aggregatePipeline", {
|
|
1964
2454
|
context,
|
|
1965
2455
|
result
|
|
1966
2456
|
});
|
|
1967
2457
|
return result;
|
|
1968
2458
|
} catch (error) {
|
|
1969
|
-
await this._emitErrorHook("error:
|
|
2459
|
+
await this._emitErrorHook("error:aggregatePipeline", {
|
|
1970
2460
|
context,
|
|
1971
2461
|
error
|
|
1972
2462
|
});
|
|
@@ -1974,14 +2464,19 @@ var Repository = class {
|
|
|
1974
2464
|
}
|
|
1975
2465
|
}
|
|
1976
2466
|
/**
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
2467
|
+
* Paginated kit-native aggregation pipeline. Same mongo-specific
|
|
2468
|
+
* scope as `aggregatePipeline` — reach for this when you need
|
|
2469
|
+
* `$lookup` / `$unwind` / `$facet` alongside pagination.
|
|
1979
2470
|
*
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
2471
|
+
* Prefer `aggregatePaginate(req: AggPaginationRequest)` for
|
|
2472
|
+
* cross-backend dashboard code; that method compiles the portable
|
|
2473
|
+
* IR to an equivalent pipeline.
|
|
2474
|
+
*
|
|
2475
|
+
* Policy hooks (multi-tenant, soft-delete) inject context.filters
|
|
2476
|
+
* which are prepended as a `$match` stage.
|
|
1982
2477
|
*/
|
|
1983
|
-
async
|
|
1984
|
-
const context = await this._buildContext("
|
|
2478
|
+
async aggregatePipelinePaginate(options = {}) {
|
|
2479
|
+
const context = await this._buildContext("aggregatePipelinePaginate", options);
|
|
1985
2480
|
const finalPipeline = [...context.pipeline || options.pipeline || []];
|
|
1986
2481
|
if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
|
|
1987
2482
|
const aggOptions = {
|
|
@@ -1990,6 +2485,116 @@ var Repository = class {
|
|
|
1990
2485
|
};
|
|
1991
2486
|
try {
|
|
1992
2487
|
const result = await this._pagination.aggregatePaginate(aggOptions);
|
|
2488
|
+
await this._emitHook("after:aggregatePipelinePaginate", {
|
|
2489
|
+
context,
|
|
2490
|
+
result
|
|
2491
|
+
});
|
|
2492
|
+
return result;
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
await this._emitErrorHook("error:aggregatePipelinePaginate", {
|
|
2495
|
+
context,
|
|
2496
|
+
error
|
|
2497
|
+
});
|
|
2498
|
+
throw this._handleError(error);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* Portable aggregation. Compiles the repo-core `AggRequest` IR to
|
|
2503
|
+
* `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
|
|
2504
|
+
* {$skip}, {$limit}]` against this repo's collection. Output rows
|
|
2505
|
+
* carry one key per `groupBy` column plus one key per measure alias
|
|
2506
|
+
* — identical shape to sqlitekit's `aggregate(req)`, so dashboards
|
|
2507
|
+
* work unchanged across backends.
|
|
2508
|
+
*
|
|
2509
|
+
* Without `groupBy`: returns a single-row result with just the
|
|
2510
|
+
* measures (scalar aggregation). Pass
|
|
2511
|
+
* `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
|
|
2512
|
+
* simple summary.
|
|
2513
|
+
*
|
|
2514
|
+
* For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
|
|
2515
|
+
* `$graphLookup`, window operators) reach for `aggregatePipeline`
|
|
2516
|
+
* — that's the kit-native escape hatch.
|
|
2517
|
+
*
|
|
2518
|
+
* Routes through the hook system for policy enforcement
|
|
2519
|
+
* (multi-tenant, soft-delete) — plugins inject their scope into
|
|
2520
|
+
* `context.filter`, which is merged into the pre-aggregate `$match`
|
|
2521
|
+
* before compilation.
|
|
2522
|
+
*/
|
|
2523
|
+
async aggregate(req) {
|
|
2524
|
+
const context = await this._buildContext("aggregate", { aggRequest: req });
|
|
2525
|
+
try {
|
|
2526
|
+
const finalReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
2527
|
+
const result = { rows: await executeAgg(this.Model, finalReq, { session: context.session }) };
|
|
2528
|
+
await this._emitHook("after:aggregate", {
|
|
2529
|
+
context,
|
|
2530
|
+
result
|
|
2531
|
+
});
|
|
2532
|
+
return result;
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
await this._emitErrorHook("error:aggregate", {
|
|
2535
|
+
context,
|
|
2536
|
+
error
|
|
2537
|
+
});
|
|
2538
|
+
throw this._handleError(error);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Offset-paginated portable aggregation. Same IR as `aggregate`,
|
|
2543
|
+
* wrapped in the standard `OffsetPaginationResultCore` envelope so
|
|
2544
|
+
* UI code paginates aggregated dashboards with the same primitives
|
|
2545
|
+
* as raw document lists.
|
|
2546
|
+
*
|
|
2547
|
+
* `countStrategy: 'none'` skips the second round-trip that computes
|
|
2548
|
+
* `total`; the envelope reports `total: 0`, `pages: 0`, and derives
|
|
2549
|
+
* `hasNext` from a `LIMIT N+1` peek on the data pipeline.
|
|
2550
|
+
*/
|
|
2551
|
+
async aggregatePaginate(req) {
|
|
2552
|
+
const context = await this._buildContext("aggregatePaginate", { aggRequest: req });
|
|
2553
|
+
const page = Math.max(1, req.page ?? 1);
|
|
2554
|
+
const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
|
|
2555
|
+
const countStrategy = req.countStrategy ?? "exact";
|
|
2556
|
+
const offset = (page - 1) * limit;
|
|
2557
|
+
const session = context.session;
|
|
2558
|
+
try {
|
|
2559
|
+
const finalReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
2560
|
+
if (countStrategy === "none") {
|
|
2561
|
+
const peek = await executeAgg(this.Model, {
|
|
2562
|
+
...finalReq,
|
|
2563
|
+
limit: limit + 1,
|
|
2564
|
+
offset
|
|
2565
|
+
}, session ? { session } : {});
|
|
2566
|
+
const hasNext = peek.length > limit;
|
|
2567
|
+
const result = {
|
|
2568
|
+
method: "offset",
|
|
2569
|
+
docs: hasNext ? peek.slice(0, limit) : peek,
|
|
2570
|
+
page,
|
|
2571
|
+
limit,
|
|
2572
|
+
total: 0,
|
|
2573
|
+
pages: 0,
|
|
2574
|
+
hasNext,
|
|
2575
|
+
hasPrev: page > 1
|
|
2576
|
+
};
|
|
2577
|
+
await this._emitHook("after:aggregatePaginate", {
|
|
2578
|
+
context,
|
|
2579
|
+
result
|
|
2580
|
+
});
|
|
2581
|
+
return result;
|
|
2582
|
+
}
|
|
2583
|
+
const [docs, total] = await Promise.all([executeAgg(this.Model, {
|
|
2584
|
+
...finalReq,
|
|
2585
|
+
limit,
|
|
2586
|
+
offset
|
|
2587
|
+
}, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
|
|
2588
|
+
const result = {
|
|
2589
|
+
method: "offset",
|
|
2590
|
+
docs,
|
|
2591
|
+
page,
|
|
2592
|
+
limit,
|
|
2593
|
+
total,
|
|
2594
|
+
pages: Math.max(1, Math.ceil(total / limit)),
|
|
2595
|
+
hasNext: page * limit < total,
|
|
2596
|
+
hasPrev: page > 1
|
|
2597
|
+
};
|
|
1993
2598
|
await this._emitHook("after:aggregatePaginate", {
|
|
1994
2599
|
context,
|
|
1995
2600
|
result
|
|
@@ -2004,6 +2609,29 @@ var Repository = class {
|
|
|
2004
2609
|
}
|
|
2005
2610
|
}
|
|
2006
2611
|
/**
|
|
2612
|
+
* Merge policy-hook-injected filters (multi-tenant scope,
|
|
2613
|
+
* soft-delete) into the pre-aggregate `filter` slot of the request.
|
|
2614
|
+
* Plugins speak mongo-query shape (`context.query` / `context.filters`);
|
|
2615
|
+
* we merge those with the portable `req.filter` using `$and`.
|
|
2616
|
+
*
|
|
2617
|
+
* When the caller's filter is a Filter IR node we don't unwrap it —
|
|
2618
|
+
* the pipeline compiler does Filter→mongo translation. Merging here
|
|
2619
|
+
* as a mongo-shaped `$and` is safe because `compileFilterToMongo`
|
|
2620
|
+
* passes through already-mongo queries unchanged.
|
|
2621
|
+
*/
|
|
2622
|
+
_injectPolicyScopeIntoAgg(req, context) {
|
|
2623
|
+
const scopeCandidates = [];
|
|
2624
|
+
for (const candidate of [context.filters, context.query]) if (candidate && typeof candidate === "object" && Object.keys(candidate).length > 0) scopeCandidates.push(candidate);
|
|
2625
|
+
if (scopeCandidates.length === 0) return req;
|
|
2626
|
+
const parts = [...scopeCandidates];
|
|
2627
|
+
if (req.filter) parts.push(req.filter);
|
|
2628
|
+
const merged = parts.length === 1 ? parts[0] : { $and: parts };
|
|
2629
|
+
return {
|
|
2630
|
+
...req,
|
|
2631
|
+
filter: merged
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2007
2635
|
* Get distinct values
|
|
2008
2636
|
* Routes through hook system for policy enforcement (multi-tenant, soft-delete)
|
|
2009
2637
|
*/
|
|
@@ -2100,9 +2728,9 @@ var Repository = class {
|
|
|
2100
2728
|
};
|
|
2101
2729
|
if (isKeyset && sort) {
|
|
2102
2730
|
const parsedSort = this._parseSort(sort);
|
|
2103
|
-
const { validateKeysetSort } = await import("./sort-
|
|
2104
|
-
const { encodeCursor, resolveCursorFilter } = await import("./cursor-
|
|
2105
|
-
const { getPrimaryField } = await import("./sort-
|
|
2731
|
+
const { validateKeysetSort } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
|
|
2732
|
+
const { encodeCursor, resolveCursorFilter } = await import("./cursor-J84rR6wG.mjs").then((n) => n.t);
|
|
2733
|
+
const { getPrimaryField } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
|
|
2106
2734
|
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
2107
2735
|
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
2108
2736
|
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
|
@@ -2124,18 +2752,18 @@ var Repository = class {
|
|
|
2124
2752
|
const hasMore = docs.length > limit;
|
|
2125
2753
|
if (hasMore) docs.pop();
|
|
2126
2754
|
const primaryField = getPrimaryField(normalizedSort);
|
|
2127
|
-
const
|
|
2755
|
+
const result = {
|
|
2756
|
+
method: "keyset",
|
|
2757
|
+
docs,
|
|
2758
|
+
limit,
|
|
2759
|
+
hasMore,
|
|
2760
|
+
next: hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null
|
|
2761
|
+
};
|
|
2128
2762
|
await this._emitHook("after:lookupPopulate", {
|
|
2129
2763
|
context,
|
|
2130
|
-
result
|
|
2764
|
+
result
|
|
2131
2765
|
});
|
|
2132
|
-
return
|
|
2133
|
-
data: docs,
|
|
2134
|
-
total: 0,
|
|
2135
|
-
limit,
|
|
2136
|
-
next: nextCursor,
|
|
2137
|
-
hasMore
|
|
2138
|
-
};
|
|
2766
|
+
return result;
|
|
2139
2767
|
}
|
|
2140
2768
|
const page = pageFromContext ?? 1;
|
|
2141
2769
|
const skip = (page - 1) * limit;
|
|
@@ -2152,17 +2780,21 @@ var Repository = class {
|
|
|
2152
2780
|
const docs = await aggregation;
|
|
2153
2781
|
const hasNext = docs.length > limit;
|
|
2154
2782
|
if (hasNext) docs.pop();
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
});
|
|
2159
|
-
return {
|
|
2160
|
-
data: docs,
|
|
2161
|
-
total: 0,
|
|
2783
|
+
const result = {
|
|
2784
|
+
method: "offset",
|
|
2785
|
+
docs,
|
|
2162
2786
|
page,
|
|
2163
2787
|
limit,
|
|
2164
|
-
|
|
2788
|
+
total: 0,
|
|
2789
|
+
pages: 0,
|
|
2790
|
+
hasNext,
|
|
2791
|
+
hasPrev: page > 1
|
|
2165
2792
|
};
|
|
2793
|
+
await this._emitHook("after:lookupPopulate", {
|
|
2794
|
+
context,
|
|
2795
|
+
result
|
|
2796
|
+
});
|
|
2797
|
+
return result;
|
|
2166
2798
|
}
|
|
2167
2799
|
dataPipeline.push({ $skip: skip }, { $limit: limit });
|
|
2168
2800
|
appendLookupStages(dataPipeline);
|
|
@@ -2176,22 +2808,26 @@ var Repository = class {
|
|
|
2176
2808
|
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
2177
2809
|
if (collation) aggregation.collation(collation);
|
|
2178
2810
|
if (readPref) aggregation.read(readPref);
|
|
2179
|
-
const
|
|
2811
|
+
const facetResult = (await aggregation)[0] || {
|
|
2180
2812
|
metadata: [],
|
|
2181
2813
|
data: []
|
|
2182
2814
|
};
|
|
2183
|
-
const total =
|
|
2184
|
-
const
|
|
2815
|
+
const total = facetResult.metadata[0]?.total || 0;
|
|
2816
|
+
const result = {
|
|
2817
|
+
method: "offset",
|
|
2818
|
+
docs: facetResult.data || [],
|
|
2819
|
+
page,
|
|
2820
|
+
limit,
|
|
2821
|
+
total,
|
|
2822
|
+
pages: Math.max(1, Math.ceil(total / limit)),
|
|
2823
|
+
hasNext: page * limit < total,
|
|
2824
|
+
hasPrev: page > 1
|
|
2825
|
+
};
|
|
2185
2826
|
await this._emitHook("after:lookupPopulate", {
|
|
2186
2827
|
context,
|
|
2187
|
-
result
|
|
2828
|
+
result
|
|
2188
2829
|
});
|
|
2189
|
-
return
|
|
2190
|
-
data,
|
|
2191
|
-
total,
|
|
2192
|
-
page,
|
|
2193
|
-
limit
|
|
2194
|
-
};
|
|
2830
|
+
return result;
|
|
2195
2831
|
} catch (error) {
|
|
2196
2832
|
await this._emitErrorHook("error:lookupPopulate", {
|
|
2197
2833
|
context,
|
|
@@ -2242,37 +2878,58 @@ var Repository = class {
|
|
|
2242
2878
|
return new LookupBuilder(from);
|
|
2243
2879
|
}
|
|
2244
2880
|
/**
|
|
2245
|
-
* Execute callback within a transaction with automatic retry on
|
|
2881
|
+
* Execute callback within a MongoDB transaction with automatic retry on
|
|
2882
|
+
* transient failures.
|
|
2246
2883
|
*
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2884
|
+
* **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
|
|
2885
|
+
* not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
|
|
2886
|
+
* contract that every kit (sqlitekit, pgkit, prismakit) already implements
|
|
2887
|
+
* — so cross-kit plugins and apps are portable. Session threading happens
|
|
2888
|
+
* transparently: every CRUD call on `txRepo` auto-injects the session into
|
|
2889
|
+
* its options bag, including plugin-added methods (`upsert`, `increment`,
|
|
2890
|
+
* `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
|
|
2249
2891
|
*
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2252
|
-
*
|
|
2892
|
+
* For raw-session cross-repo workflows — multiple repositories coordinating
|
|
2893
|
+
* writes — use the standalone `withTransaction(connection, fn)` export
|
|
2894
|
+
* from `@classytic/mongokit`. That helper remains session-based by design.
|
|
2253
2895
|
*
|
|
2254
|
-
* @param callback - Receives a `
|
|
2896
|
+
* @param callback - Receives a tx-bound `Repository<TDoc>` — call its
|
|
2897
|
+
* methods directly, no need to pass `{ session }` manually
|
|
2255
2898
|
* @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
|
|
2256
2899
|
* @param options.onFallback - Called when falling back to non-transactional execution
|
|
2257
2900
|
* @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
|
|
2258
2901
|
*
|
|
2259
2902
|
* @example
|
|
2260
2903
|
* ```typescript
|
|
2261
|
-
* const
|
|
2262
|
-
* const
|
|
2263
|
-
* await
|
|
2264
|
-
* return
|
|
2904
|
+
* const order = await repo.withTransaction(async (txRepo) => {
|
|
2905
|
+
* const created = await txRepo.create({ total: 100 });
|
|
2906
|
+
* await txRepo.update(created._id, { confirmed: true });
|
|
2907
|
+
* return created;
|
|
2265
2908
|
* });
|
|
2266
2909
|
*
|
|
2267
2910
|
* // With fallback for standalone/dev environments
|
|
2268
|
-
* await repo.withTransaction(
|
|
2269
|
-
*
|
|
2270
|
-
*
|
|
2911
|
+
* await repo.withTransaction(
|
|
2912
|
+
* async (txRepo) => {
|
|
2913
|
+
* await txRepo.create(doc);
|
|
2914
|
+
* },
|
|
2915
|
+
* {
|
|
2916
|
+
* allowFallback: true,
|
|
2917
|
+
* onFallback: (err) => logger.warn('Running without transaction', err),
|
|
2918
|
+
* },
|
|
2919
|
+
* );
|
|
2920
|
+
*
|
|
2921
|
+
* // Cross-repo with explicit session:
|
|
2922
|
+
* import { withTransaction } from '@classytic/mongokit';
|
|
2923
|
+
* await withTransaction(mongoose.connection, async (session) => {
|
|
2924
|
+
* const order = await orderRepo.create(data, { session });
|
|
2925
|
+
* await inventoryRepo.decrement(..., { session });
|
|
2271
2926
|
* });
|
|
2272
2927
|
* ```
|
|
2273
2928
|
*/
|
|
2274
2929
|
async withTransaction(callback, options = {}) {
|
|
2275
|
-
return withTransaction(this.Model.db,
|
|
2930
|
+
return withTransaction(this.Model.db, async (session) => {
|
|
2931
|
+
return callback(createTxBoundRepo(this, session));
|
|
2932
|
+
}, options);
|
|
2276
2933
|
}
|
|
2277
2934
|
/**
|
|
2278
2935
|
* Execute custom query with event emission
|
|
@@ -2296,7 +2953,12 @@ var Repository = class {
|
|
|
2296
2953
|
}
|
|
2297
2954
|
}
|
|
2298
2955
|
/**
|
|
2299
|
-
* Build operation context and run before
|
|
2956
|
+
* Build operation context and run before-hooks (sorted by priority).
|
|
2957
|
+
*
|
|
2958
|
+
* Narrows `RepositoryBase._buildContext`'s return type to mongokit's
|
|
2959
|
+
* richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
|
|
2960
|
+
* populate / readPreference / session, etc.). Runtime is inherited from
|
|
2961
|
+
* the base class — this override is purely a type-narrowing shim.
|
|
2300
2962
|
*
|
|
2301
2963
|
* Hook execution order is deterministic:
|
|
2302
2964
|
* 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
|
|
@@ -2305,15 +2967,29 @@ var Repository = class {
|
|
|
2305
2967
|
* 4. DEFAULT (500) — user-registered hooks
|
|
2306
2968
|
*/
|
|
2307
2969
|
async _buildContext(operation, options) {
|
|
2308
|
-
const
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2970
|
+
const normalized = this._normalizeFilterSlots(options);
|
|
2971
|
+
return await super._buildContext(operation, normalized);
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Walk the well-known filter-carrying keys (`query`, `filters`,
|
|
2975
|
+
* `having`) and route any Filter IR through `compileFilterToMongo`.
|
|
2976
|
+
* Plain Mongo queries pass through untouched — `isFilter` is the
|
|
2977
|
+
* discriminator. Returns a shallow clone; never mutates input.
|
|
2978
|
+
*/
|
|
2979
|
+
_normalizeFilterSlots(options) {
|
|
2980
|
+
let cloned = null;
|
|
2981
|
+
for (const key of [
|
|
2982
|
+
"query",
|
|
2983
|
+
"filters",
|
|
2984
|
+
"having"
|
|
2985
|
+
]) {
|
|
2986
|
+
const value = options[key];
|
|
2987
|
+
if (value === void 0 || value === null) continue;
|
|
2988
|
+
if (!isFilter(value)) continue;
|
|
2989
|
+
cloned ??= { ...options };
|
|
2990
|
+
cloned[key] = compileFilterToMongo(value);
|
|
2991
|
+
}
|
|
2992
|
+
return cloned ?? options;
|
|
2317
2993
|
}
|
|
2318
2994
|
/**
|
|
2319
2995
|
* Resolve the effective sort for getAll. Handles $near conflict detection
|
|
@@ -2390,6 +3066,38 @@ var Repository = class {
|
|
|
2390
3066
|
return [populate];
|
|
2391
3067
|
}
|
|
2392
3068
|
/**
|
|
3069
|
+
* Classify a driver error as an authoritative duplicate-key conflict.
|
|
3070
|
+
*
|
|
3071
|
+
* Used by arc's outbox / idempotency adapters to distinguish
|
|
3072
|
+
* "write already landed (idempotent no-op)" from "transient DB error
|
|
3073
|
+
* (must retry)". Every backend signals duplicate keys differently —
|
|
3074
|
+
* MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
|
|
3075
|
+
* predicate lives on the kit that knows its driver. Cross-driver
|
|
3076
|
+
* adapters then depend only on the boolean outcome, not the shape of
|
|
3077
|
+
* the underlying error.
|
|
3078
|
+
*
|
|
3079
|
+
* Deliberately narrow: matches ONLY `code === 11000` and
|
|
3080
|
+
* `codeName === 'DuplicateKey'`. Does NOT match
|
|
3081
|
+
* `err.name === 'MongoServerError'`, which is also true for
|
|
3082
|
+
* WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
|
|
3083
|
+
* other server-side error — treating those as duplicate keys would
|
|
3084
|
+
* silently swallow transactional retries.
|
|
3085
|
+
*
|
|
3086
|
+
* @example
|
|
3087
|
+
* try {
|
|
3088
|
+
* await outboxRepo.create(event);
|
|
3089
|
+
* } catch (err) {
|
|
3090
|
+
* if (outboxRepo.isDuplicateKeyError(err)) {
|
|
3091
|
+
* // Idempotent no-op — event was already written by a prior attempt.
|
|
3092
|
+
* return;
|
|
3093
|
+
* }
|
|
3094
|
+
* throw err; // transient DB error — upstream retries
|
|
3095
|
+
* }
|
|
3096
|
+
*/
|
|
3097
|
+
isDuplicateKeyError(err) {
|
|
3098
|
+
return isDuplicateKeyError(err);
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
2393
3101
|
* Handle errors with proper HTTP status codes
|
|
2394
3102
|
*/
|
|
2395
3103
|
_handleError(error) {
|
|
@@ -2428,7 +3136,7 @@ var Repository = class {
|
|
|
2428
3136
|
* adapter: {
|
|
2429
3137
|
* async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
|
|
2430
3138
|
* async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
|
|
2431
|
-
* async
|
|
3139
|
+
* async delete(key) { await redis.del(key); },
|
|
2432
3140
|
* async clear(pattern) {
|
|
2433
3141
|
* const keys = await redis.keys(pattern || '*');
|
|
2434
3142
|
* if (keys.length) await redis.del(...keys);
|
|
@@ -2538,16 +3246,16 @@ function cachePlugin(options) {
|
|
|
2538
3246
|
}
|
|
2539
3247
|
/**
|
|
2540
3248
|
* Invalidate a specific document by ID (all shape variants).
|
|
2541
|
-
* Deletes every tracked shape-variant key individually via
|
|
3249
|
+
* Deletes every tracked shape-variant key individually via delete(),
|
|
2542
3250
|
* so adapters without pattern-based clear() still get full invalidation.
|
|
2543
3251
|
*/
|
|
2544
3252
|
async function invalidateById(id) {
|
|
2545
3253
|
try {
|
|
2546
3254
|
const baseKey = byIdKey(config.prefix, model, id);
|
|
2547
|
-
await config.adapter.
|
|
3255
|
+
await config.adapter.delete(baseKey);
|
|
2548
3256
|
const trackedKeys = byIdKeyRegistry.get(id);
|
|
2549
3257
|
if (trackedKeys) {
|
|
2550
|
-
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.
|
|
3258
|
+
for (const key of trackedKeys) if (key !== baseKey) await config.adapter.delete(key);
|
|
2551
3259
|
byIdKeyRegistry.delete(id);
|
|
2552
3260
|
}
|
|
2553
3261
|
stats.invalidations++;
|
|
@@ -2586,7 +3294,7 @@ function cachePlugin(options) {
|
|
|
2586
3294
|
log(`Cache error for getById:`, e);
|
|
2587
3295
|
stats.errors++;
|
|
2588
3296
|
}
|
|
2589
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
3297
|
+
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
2590
3298
|
/**
|
|
2591
3299
|
* before:getByQuery - Check cache for single-doc query
|
|
2592
3300
|
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
@@ -2617,7 +3325,7 @@ function cachePlugin(options) {
|
|
|
2617
3325
|
log(`Cache error for getByQuery:`, e);
|
|
2618
3326
|
stats.errors++;
|
|
2619
3327
|
}
|
|
2620
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
3328
|
+
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
2621
3329
|
/**
|
|
2622
3330
|
* before:getOne - Same cache logic as getByQuery (single-doc)
|
|
2623
3331
|
*/
|
|
@@ -2645,7 +3353,7 @@ function cachePlugin(options) {
|
|
|
2645
3353
|
log(`Cache error for getOne:`, e);
|
|
2646
3354
|
stats.errors++;
|
|
2647
3355
|
}
|
|
2648
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
3356
|
+
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
2649
3357
|
/**
|
|
2650
3358
|
* before:getAll - Check cache for list query
|
|
2651
3359
|
* Runs at CACHE priority (200) — after policy hooks inject filters
|
|
@@ -2693,7 +3401,7 @@ function cachePlugin(options) {
|
|
|
2693
3401
|
log(`Cache error for getAll:`, e);
|
|
2694
3402
|
stats.errors++;
|
|
2695
3403
|
}
|
|
2696
|
-
}, { priority: HOOK_PRIORITY.CACHE });
|
|
3404
|
+
}, { priority: HOOK_PRIORITY$1.CACHE });
|
|
2697
3405
|
/**
|
|
2698
3406
|
* after:getById - Cache the result
|
|
2699
3407
|
*/
|
|
@@ -2823,6 +3531,22 @@ function cachePlugin(options) {
|
|
|
2823
3531
|
await bumpVersion();
|
|
2824
3532
|
});
|
|
2825
3533
|
/**
|
|
3534
|
+
* after:findOneAndUpdate — atomic CAS may insert (upsert) or mutate.
|
|
3535
|
+
* Result carries the matched/inserted doc; invalidate that ID and bump
|
|
3536
|
+
* version. Null result (no match, no upsert) is a no-op.
|
|
3537
|
+
*/
|
|
3538
|
+
repo.on("after:findOneAndUpdate", async (payload) => {
|
|
3539
|
+
const { result } = payload;
|
|
3540
|
+
if (!result) return;
|
|
3541
|
+
const idKey = repo.idField || "_id";
|
|
3542
|
+
const id = result?.[idKey];
|
|
3543
|
+
if (id == null) {
|
|
3544
|
+
await bumpVersion();
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
await Promise.all([invalidateById(String(id)), bumpVersion()]);
|
|
3548
|
+
});
|
|
3549
|
+
/**
|
|
2826
3550
|
* after:delete - Invalidate by ID and bump version
|
|
2827
3551
|
*/
|
|
2828
3552
|
repo.on("after:delete", async (payload) => {
|
|
@@ -3781,45 +4505,20 @@ function mongoOperationsPlugin() {
|
|
|
3781
4505
|
*/
|
|
3782
4506
|
function multiTenantPlugin(options = {}) {
|
|
3783
4507
|
const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string" } = options;
|
|
3784
|
-
const
|
|
3785
|
-
"
|
|
3786
|
-
"
|
|
3787
|
-
|
|
3788
|
-
"lookupPopulate"
|
|
3789
|
-
];
|
|
3790
|
-
const queryReadOps = [
|
|
3791
|
-
"getById",
|
|
3792
|
-
"getByQuery",
|
|
3793
|
-
"getOne",
|
|
3794
|
-
"count",
|
|
3795
|
-
"exists",
|
|
3796
|
-
"getOrCreate",
|
|
3797
|
-
"distinct",
|
|
3798
|
-
"aggregate"
|
|
3799
|
-
];
|
|
3800
|
-
const constrainedWriteOps = [
|
|
3801
|
-
"update",
|
|
3802
|
-
"delete",
|
|
3803
|
-
"restore"
|
|
3804
|
-
];
|
|
3805
|
-
const filterReadOps = ["getDeleted"];
|
|
3806
|
-
const createOps = ["create", "createMany"];
|
|
3807
|
-
const batchQueryOps = ["updateMany", "deleteMany"];
|
|
3808
|
-
const bulkOps = ["bulkWrite"];
|
|
3809
|
-
const allOps = [
|
|
3810
|
-
...filterOps,
|
|
3811
|
-
...filterReadOps,
|
|
3812
|
-
...queryReadOps,
|
|
3813
|
-
...constrainedWriteOps,
|
|
3814
|
-
...createOps,
|
|
3815
|
-
...batchQueryOps,
|
|
3816
|
-
...bulkOps
|
|
3817
|
-
];
|
|
4508
|
+
const extraOps = [{
|
|
4509
|
+
op: "getDeleted",
|
|
4510
|
+
policyKey: "filters"
|
|
4511
|
+
}];
|
|
3818
4512
|
return {
|
|
3819
4513
|
name: "multi-tenant",
|
|
3820
4514
|
apply(repo) {
|
|
3821
|
-
|
|
4515
|
+
const allOps = [...ALL_OPERATIONS.map((op) => ({
|
|
4516
|
+
op,
|
|
4517
|
+
policyKey: OP_REGISTRY[op].policyKey
|
|
4518
|
+
})), ...extraOps];
|
|
4519
|
+
for (const { op, policyKey } of allOps) {
|
|
3822
4520
|
if (skipOperations.includes(op)) continue;
|
|
4521
|
+
if (policyKey === "none") continue;
|
|
3823
4522
|
repo.on(`before:${op}`, (context) => {
|
|
3824
4523
|
if (skipWhen?.(context, op)) return;
|
|
3825
4524
|
let tenantId = context[contextKey];
|
|
@@ -3830,71 +4529,77 @@ function multiTenantPlugin(options = {}) {
|
|
|
3830
4529
|
if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
|
|
3831
4530
|
if (!tenantId) return;
|
|
3832
4531
|
const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
4532
|
+
switch (policyKey) {
|
|
4533
|
+
case "filters":
|
|
4534
|
+
context.filters = {
|
|
4535
|
+
...context.filters,
|
|
4536
|
+
[tenantField]: castId
|
|
4537
|
+
};
|
|
4538
|
+
break;
|
|
4539
|
+
case "query":
|
|
4540
|
+
context.query = {
|
|
4541
|
+
...context.query,
|
|
4542
|
+
[tenantField]: castId
|
|
4543
|
+
};
|
|
4544
|
+
break;
|
|
4545
|
+
case "data":
|
|
4546
|
+
if (context.data) context.data[tenantField] = castId;
|
|
4547
|
+
break;
|
|
4548
|
+
case "dataArray":
|
|
4549
|
+
if (context.dataArray) {
|
|
4550
|
+
for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
|
|
4551
|
+
}
|
|
4552
|
+
break;
|
|
4553
|
+
case "operations":
|
|
4554
|
+
if (context.operations) {
|
|
4555
|
+
const ops = context.operations;
|
|
4556
|
+
for (const subOp of ops) {
|
|
4557
|
+
for (const key of [
|
|
4558
|
+
"updateOne",
|
|
4559
|
+
"updateMany",
|
|
4560
|
+
"deleteOne",
|
|
4561
|
+
"deleteMany",
|
|
4562
|
+
"replaceOne"
|
|
4563
|
+
]) {
|
|
4564
|
+
const opBody = subOp[key];
|
|
4565
|
+
if (opBody?.filter) opBody.filter = {
|
|
4566
|
+
...opBody.filter,
|
|
4567
|
+
[tenantField]: castId
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
const insertBody = subOp.insertOne;
|
|
4571
|
+
if (insertBody?.document) insertBody.document[tenantField] = castId;
|
|
4572
|
+
}
|
|
3868
4573
|
}
|
|
3869
|
-
|
|
3870
|
-
if (insertBody?.document) insertBody.document[tenantField] = castId;
|
|
3871
|
-
}
|
|
4574
|
+
break;
|
|
3872
4575
|
}
|
|
3873
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4576
|
+
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
3874
4577
|
}
|
|
3875
4578
|
}
|
|
3876
4579
|
};
|
|
3877
4580
|
}
|
|
3878
4581
|
//#endregion
|
|
3879
4582
|
//#region src/plugins/observability.plugin.ts
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
4583
|
+
/**
|
|
4584
|
+
* Observability Plugin
|
|
4585
|
+
*
|
|
4586
|
+
* Adds operation timing, structured metrics, and APM hook points.
|
|
4587
|
+
* Works with any monitoring system — just provide an onMetric callback.
|
|
4588
|
+
*
|
|
4589
|
+
* @example
|
|
4590
|
+
* ```typescript
|
|
4591
|
+
* const repo = new Repository(User, [
|
|
4592
|
+
* observabilityPlugin({
|
|
4593
|
+
* onMetric: (metric) => {
|
|
4594
|
+
* // Send to DataDog, New Relic, OpenTelemetry, or console
|
|
4595
|
+
* console.log(`${metric.operation} took ${metric.durationMs}ms`);
|
|
4596
|
+
* statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
|
|
4597
|
+
* },
|
|
4598
|
+
* }),
|
|
4599
|
+
* ]);
|
|
4600
|
+
* ```
|
|
4601
|
+
*/
|
|
4602
|
+
const DEFAULT_OPS = ALL_OPERATIONS;
|
|
3898
4603
|
const timers = /* @__PURE__ */ new WeakMap();
|
|
3899
4604
|
function observabilityPlugin(options) {
|
|
3900
4605
|
const { onMetric, slowThresholdMs } = options;
|
|
@@ -4046,7 +4751,7 @@ function softDeletePlugin(options = {}) {
|
|
|
4046
4751
|
}
|
|
4047
4752
|
context.softDeleted = true;
|
|
4048
4753
|
}
|
|
4049
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4754
|
+
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4050
4755
|
const injectDeleteFilterToFilters = (context) => {
|
|
4051
4756
|
if (options.soft !== false) {
|
|
4052
4757
|
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
@@ -4065,22 +4770,19 @@ function softDeletePlugin(options = {}) {
|
|
|
4065
4770
|
};
|
|
4066
4771
|
}
|
|
4067
4772
|
};
|
|
4068
|
-
|
|
4069
|
-
"
|
|
4070
|
-
"
|
|
4071
|
-
"
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
"
|
|
4077
|
-
|
|
4078
|
-
"
|
|
4079
|
-
"
|
|
4080
|
-
|
|
4081
|
-
"distinct",
|
|
4082
|
-
"aggregate"
|
|
4083
|
-
]) repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
|
|
4773
|
+
const SOFT_DELETE_SPECIAL = new Set([
|
|
4774
|
+
"updateMany",
|
|
4775
|
+
"deleteMany",
|
|
4776
|
+
"bulkWrite"
|
|
4777
|
+
]);
|
|
4778
|
+
for (const op of ALL_OPERATIONS) {
|
|
4779
|
+
if (SOFT_DELETE_SPECIAL.has(op)) continue;
|
|
4780
|
+
const desc = OP_REGISTRY[op];
|
|
4781
|
+
const isFilterableMutation = op === "findOneAndUpdate";
|
|
4782
|
+
if (desc.mutates && !isFilterableMutation) continue;
|
|
4783
|
+
if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4784
|
+
else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4785
|
+
}
|
|
4084
4786
|
repo.on("before:updateMany", (context) => {
|
|
4085
4787
|
if (options.soft !== false) {
|
|
4086
4788
|
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
@@ -4089,7 +4791,7 @@ function softDeletePlugin(options = {}) {
|
|
|
4089
4791
|
...deleteFilter
|
|
4090
4792
|
};
|
|
4091
4793
|
}
|
|
4092
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4794
|
+
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4093
4795
|
repo.on("before:deleteMany", async (context) => {
|
|
4094
4796
|
if (context.deleteMode === "hard") return;
|
|
4095
4797
|
if (options.soft !== false) {
|
|
@@ -4101,25 +4803,7 @@ function softDeletePlugin(options = {}) {
|
|
|
4101
4803
|
await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
|
|
4102
4804
|
context.softDeleted = true;
|
|
4103
4805
|
}
|
|
4104
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4105
|
-
repo.on("before:aggregate", (context) => {
|
|
4106
|
-
if (options.soft !== false) {
|
|
4107
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
4108
|
-
if (Object.keys(deleteFilter).length > 0) context.query = {
|
|
4109
|
-
...context.query || {},
|
|
4110
|
-
...deleteFilter
|
|
4111
|
-
};
|
|
4112
|
-
}
|
|
4113
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4114
|
-
repo.on("before:aggregatePaginate", (context) => {
|
|
4115
|
-
if (options.soft !== false) {
|
|
4116
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
4117
|
-
if (Object.keys(deleteFilter).length > 0) context.filters = {
|
|
4118
|
-
...context.filters || {},
|
|
4119
|
-
...deleteFilter
|
|
4120
|
-
};
|
|
4121
|
-
}
|
|
4122
|
-
}, { priority: HOOK_PRIORITY.POLICY });
|
|
4806
|
+
}, { priority: HOOK_PRIORITY$1.POLICY });
|
|
4123
4807
|
if (addRestoreMethod) {
|
|
4124
4808
|
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
4125
4809
|
const context = await this._buildContext("restore", {
|
|
@@ -4348,6 +5032,20 @@ function timestampPlugin() {
|
|
|
4348
5032
|
if (!context.data) return;
|
|
4349
5033
|
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
4350
5034
|
});
|
|
5035
|
+
repo.on("before:findOneAndUpdate", (context) => {
|
|
5036
|
+
if (!context.data) return;
|
|
5037
|
+
const now = /* @__PURE__ */ new Date();
|
|
5038
|
+
if (Array.isArray(context.data)) return;
|
|
5039
|
+
const update = context.data;
|
|
5040
|
+
if (Object.keys(update).some((k) => k.startsWith("$"))) {
|
|
5041
|
+
const set = update.$set || {};
|
|
5042
|
+
set.updatedAt = now;
|
|
5043
|
+
update.$set = set;
|
|
5044
|
+
const setOnInsert = update.$setOnInsert || {};
|
|
5045
|
+
if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
|
|
5046
|
+
update.$setOnInsert = setOnInsert;
|
|
5047
|
+
} else update.updatedAt = now;
|
|
5048
|
+
});
|
|
4351
5049
|
}
|
|
4352
5050
|
};
|
|
4353
5051
|
}
|
|
@@ -4374,6 +5072,7 @@ function validationChainPlugin(validators = [], options = {}) {
|
|
|
4374
5072
|
const validatorsByOperation = {
|
|
4375
5073
|
create: [],
|
|
4376
5074
|
update: [],
|
|
5075
|
+
findOneAndUpdate: [],
|
|
4377
5076
|
delete: [],
|
|
4378
5077
|
createMany: []
|
|
4379
5078
|
};
|
|
@@ -4412,6 +5111,7 @@ function validationChainPlugin(validators = [], options = {}) {
|
|
|
4412
5111
|
repo.on("before:create", async (context) => runValidators("create", context));
|
|
4413
5112
|
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
4414
5113
|
repo.on("before:update", async (context) => runValidators("update", context));
|
|
5114
|
+
repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
|
|
4415
5115
|
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
4416
5116
|
}
|
|
4417
5117
|
};
|
|
@@ -4500,4 +5200,4 @@ function uniqueField(field, errorMessage) {
|
|
|
4500
5200
|
};
|
|
4501
5201
|
}
|
|
4502
5202
|
//#endregion
|
|
4503
|
-
export { AuditTrailQuery as A, cachePlugin as C, withTransaction as D, isTransactionUnsupported as E, auditLogPlugin as M, aggregateHelpersPlugin as N, AggregationBuilder as O, cascadePlugin as S, Repository as T, customIdPlugin as _, uniqueField as a, prefixedId as b, createTenantContext as c, observabilityPlugin as d, multiTenantPlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, auditTrailPlugin as j, batchOperationsPlugin as k, subdocumentPlugin as l, methodRegistryPlugin as m, blockIf as n, validationChainPlugin as o, mongoOperationsPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, dateSequentialId as v, HOOK_PRIORITY as w, sequentialId as x, getNextSequence as y };
|
|
5203
|
+
export { AuditTrailQuery as A, cachePlugin as C, withTransaction as D, isTransactionUnsupported as E, MUTATING_OPERATIONS as F, OP_REGISTRY as I, READ_OPERATIONS as L, auditLogPlugin as M, aggregateHelpersPlugin as N, AggregationBuilder as O, ALL_OPERATIONS as P, operationsByPolicyKey as R, cascadePlugin as S, Repository as T, customIdPlugin as _, uniqueField as a, prefixedId as b, createTenantContext as c, observabilityPlugin as d, multiTenantPlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, auditTrailPlugin as j, batchOperationsPlugin as k, subdocumentPlugin as l, methodRegistryPlugin as m, blockIf as n, validationChainPlugin as o, mongoOperationsPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, dateSequentialId as v, HOOK_PRIORITY$1 as w, sequentialId as x, getNextSequence as y };
|