@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.
@@ -1,12 +1,149 @@
1
1
  import { n as debug, r as warn } from "./logger-DJ7egMnd.mjs";
2
- import { a as exists, c as getOrCreate, d as deleteByQuery, g as upsert, i as count, m as createMany, n as updateByQuery, o as getById, p as create, s as getByQuery, t as update, u as deleteById, v as distinct, y as LookupBuilder } from "./update-BDsQ3cdy.mjs";
3
- import { n as parseDuplicateKeyError, t as createError } from "./error-Lw9GEAro.mjs";
4
- import { t as PaginationEngine } from "./PaginationEngine-CPmZv7Ds.mjs";
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.aggregate(pipeline, options);
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.aggregate(pipeline, options))[0]?.[resultKey] || 0;
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
- * Ordered pairs that produce wrong behavior when registered out of order.
1234
- * Each tuple is `[mustComeFirst, mustComeAfter, reason]`.
1735
+ * Plugin phase priorities (lower = runs first).
1235
1736
  *
1236
- * Plugin names are read from `plugin.name` (Plugin objects). Plain-function
1237
- * plugins without a name are skipped — no false positives.
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 PLUGIN_ORDER_CONSTRAINTS = [[
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
- * Event-driven, plugin-based, with smart pagination
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
- validatePluginOrder(plugins, this.model, options.pluginOrderChecks ?? "warn");
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
- * Register event listener with optional priority for phase ordering.
1789
+ * Live read-through view of the hook engine's listener registry.
1316
1790
  *
1317
- * @param event - Event name (e.g. 'before:getAll')
1318
- * @param listener - Hook function
1319
- * @param options - Optional { priority } — use HOOK_PRIORITY constants.
1320
- * Lower priority numbers run first.
1321
- * Default: HOOK_PRIORITY.DEFAULT (500)
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
- removeAllListeners(event) {
1349
- if (event) this._hooks.delete(event);
1350
- else this._hooks.clear();
1351
- return this;
1798
+ get _hooks() {
1799
+ return this.hooks.listeners();
1352
1800
  }
1353
1801
  /**
1354
- * Emit event (sync - for backwards compatibility)
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
- if (this._hookMode === "async") {
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 (context.throwOnNotFound === false || options.throwOnNotFound === false) return null;
1478
- throw createError(404, "Document not found");
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.filters ?? filters;
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 lookupResult = await this.lookupPopulate({
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
- let result;
2247
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
1852
2248
  const effectiveIdField = options.idField ?? this.idField;
1853
- if (effectiveIdField !== "_id") {
1854
- const updated = await updateByQuery(this.Model, {
1855
- [effectiveIdField]: id,
1856
- ...context.query || {}
1857
- }, context.data || data, context);
1858
- if (!updated) throw createError(404, "Document not found");
1859
- result = updated;
1860
- } else result = await update(this.Model, id, context.data || data, context);
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
- * Execute aggregation pipeline
1939
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
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
- * @param pipeline - Aggregation pipeline stages
1942
- * @param options - Aggregation options including governance controls
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 aggregate(pipeline, options = {}) {
1945
- const context = await this._buildContext("aggregate", {
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:aggregate", {
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:aggregate", {
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
- * Aggregate pipeline with pagination
1978
- * Best for: Complex queries, grouping, joins
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
- * Policy hooks (multi-tenant, soft-delete) inject context.filters which are
1981
- * prepended as a $match stage to the pipeline, ensuring tenant isolation.
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 aggregatePaginate(options = {}) {
1984
- const context = await this._buildContext("aggregatePaginate", options);
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-ffe-9JBg.mjs").then((n) => n.n);
2104
- const { encodeCursor, resolveCursorFilter } = await import("./cursor-DZ9JSoMM.mjs").then((n) => n.t);
2105
- const { getPrimaryField } = await import("./sort-ffe-9JBg.mjs").then((n) => n.n);
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 nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, cursorVersion) : null;
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: docs
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
- await this._emitHook("after:lookupPopulate", {
2156
- context,
2157
- result: docs
2158
- });
2159
- return {
2160
- data: docs,
2161
- total: 0,
2783
+ const result = {
2784
+ method: "offset",
2785
+ docs,
2162
2786
  page,
2163
2787
  limit,
2164
- hasMore: hasNext
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 result = (await aggregation)[0] || {
2811
+ const facetResult = (await aggregation)[0] || {
2180
2812
  metadata: [],
2181
2813
  data: []
2182
2814
  };
2183
- const total = result.metadata[0]?.total || 0;
2184
- const data = result.data || [];
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: data
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 transient failures.
2881
+ * Execute callback within a MongoDB transaction with automatic retry on
2882
+ * transient failures.
2246
2883
  *
2247
- * Uses the MongoDB driver's `session.withTransaction()` which automatically retries
2248
- * on `TransientTransactionError` and `UnknownTransactionCommitResult`.
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
- * The callback always receives a `ClientSession`. When `allowFallback` is true
2251
- * and the MongoDB deployment doesn't support transactions (e.g., standalone),
2252
- * the callback runs without a transaction on the same session.
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 `ClientSession` to pass to repository operations
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 result = await repo.withTransaction(async (session) => {
2262
- * const order = await repo.create({ total: 100 }, { session });
2263
- * await paymentRepo.create({ orderId: order._id }, { session });
2264
- * return order;
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(callback, {
2269
- * allowFallback: true,
2270
- * onFallback: (err) => logger.warn('Running without transaction', err),
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, callback, options);
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 hooks (sorted by priority).
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 context = {
2309
- operation,
2310
- model: this.model,
2311
- ...options
2312
- };
2313
- const event = `before:${operation}`;
2314
- const hooks = this._hooks.get(event) || [];
2315
- for (const { listener } of hooks) await listener(context);
2316
- return context;
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 del(key) { await redis.del(key); },
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 del(),
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.del(baseKey);
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.del(key);
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 filterOps = [
3785
- "getAll",
3786
- "findAll",
3787
- "aggregatePaginate",
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
- for (const op of allOps) {
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
- if (filterOps.includes(op) || filterReadOps.includes(op)) context.filters = {
3834
- ...context.filters,
3835
- [tenantField]: castId
3836
- };
3837
- if (queryReadOps.includes(op)) context.query = {
3838
- ...context.query,
3839
- [tenantField]: castId
3840
- };
3841
- if (op === "create" && context.data) context.data[tenantField] = castId;
3842
- if (op === "createMany" && context.dataArray) {
3843
- for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
3844
- }
3845
- if (constrainedWriteOps.includes(op)) context.query = {
3846
- ...context.query,
3847
- [tenantField]: castId
3848
- };
3849
- if (batchQueryOps.includes(op)) context.query = {
3850
- ...context.query,
3851
- [tenantField]: castId
3852
- };
3853
- if (op === "bulkWrite" && context.operations) {
3854
- const ops = context.operations;
3855
- for (const subOp of ops) {
3856
- for (const key of [
3857
- "updateOne",
3858
- "updateMany",
3859
- "deleteOne",
3860
- "deleteMany",
3861
- "replaceOne"
3862
- ]) {
3863
- const opBody = subOp[key];
3864
- if (opBody?.filter) opBody.filter = {
3865
- ...opBody.filter,
3866
- [tenantField]: castId
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
- const insertBody = subOp.insertOne;
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
- const DEFAULT_OPS = [
3881
- "create",
3882
- "createMany",
3883
- "update",
3884
- "delete",
3885
- "getById",
3886
- "getByQuery",
3887
- "getOne",
3888
- "getAll",
3889
- "findAll",
3890
- "getOrCreate",
3891
- "count",
3892
- "exists",
3893
- "distinct",
3894
- "aggregate",
3895
- "aggregatePaginate",
3896
- "lookupPopulate"
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
- for (const op of [
4069
- "getAll",
4070
- "findAll",
4071
- "aggregatePaginate",
4072
- "lookupPopulate"
4073
- ]) repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
4074
- for (const op of [
4075
- "getById",
4076
- "getOne",
4077
- "getByQuery",
4078
- "count",
4079
- "exists",
4080
- "getOrCreate",
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 };