@classytic/mongokit 3.8.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,11 +1,14 @@
1
1
  import { n as debug, r as warn } from "./logger-DJ7egMnd.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-yjSoNnWy.mjs";
3
- import { n as parseDuplicateKeyError, t as createError } from "./error-Lw9GEAro.mjs";
4
- import { t as PaginationEngine } from "./PaginationEngine-C2_tAvAD.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";
10
13
  //#region src/operations.ts
11
14
  /**
@@ -104,6 +107,11 @@ const OP_REGISTRY = {
104
107
  mutates: false,
105
108
  hasIdContext: false
106
109
  },
110
+ aggregatePipeline: {
111
+ policyKey: "query",
112
+ mutates: false,
113
+ hasIdContext: false
114
+ },
107
115
  getAll: {
108
116
  policyKey: "filters",
109
117
  mutates: false,
@@ -114,6 +122,11 @@ const OP_REGISTRY = {
114
122
  mutates: false,
115
123
  hasIdContext: false
116
124
  },
125
+ aggregatePipelinePaginate: {
126
+ policyKey: "filters",
127
+ mutates: false,
128
+ hasIdContext: false
129
+ },
117
130
  lookupPopulate: {
118
131
  policyKey: "filters",
119
132
  mutates: false,
@@ -158,14 +171,14 @@ function aggregateHelpersPlugin() {
158
171
  count: { $sum: 1 }
159
172
  } }, { $sort: { count: -1 } }];
160
173
  if (options.limit) pipeline.push({ $limit: options.limit });
161
- return this.aggregate(pipeline, options);
174
+ return this.aggregatePipeline(pipeline, options);
162
175
  });
163
176
  const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
164
177
  const pipeline = [{ $match: query }, { $group: {
165
178
  _id: null,
166
179
  [resultKey]: { [operator]: `$${field}` }
167
180
  } }];
168
- return (await this.aggregate(pipeline, options))[0]?.[resultKey] || 0;
181
+ return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
169
182
  };
170
183
  /**
171
184
  * Sum field values
@@ -789,6 +802,231 @@ function batchOperationsPlugin() {
789
802
  };
790
803
  }
791
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
792
1030
  //#region src/query/AggregationBuilder.ts
793
1031
  /**
794
1032
  * Normalize SortSpec to MongoDB's strict format (1 | -1)
@@ -1375,7 +1613,117 @@ function isTransactionUnsupported(error) {
1375
1613
  return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
1376
1614
  }
1377
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
1378
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
+ */
1379
1727
  function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1380
1728
  if (!projection || !sort) return projection;
1381
1729
  if (!Object.values(projection).some((value) => value === 1)) return projection;
@@ -1384,163 +1732,80 @@ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1384
1732
  return nextProjection;
1385
1733
  }
1386
1734
  /**
1387
- * Ordered pairs that produce wrong behavior when registered out of order.
1388
- * Each tuple is `[mustComeFirst, mustComeAfter, reason]`.
1735
+ * Plugin phase priorities (lower = runs first).
1389
1736
  *
1390
- * Plugin names are read from `plugin.name` (Plugin objects). Plain-function
1391
- * plugins without a name are skipped — no false positives.
1392
- */
1393
- const PLUGIN_ORDER_CONSTRAINTS = [[
1394
- "soft-delete",
1395
- "batch-operations",
1396
- "soft-delete must precede batch-operations so bulk deletes/updates see the soft-delete filter"
1397
- ], [
1398
- "multi-tenant",
1399
- "cache",
1400
- "multi-tenant must precede cache so tenant scoping is baked into cache keys (prevents cross-tenant cache poisoning)"
1401
- ]];
1402
- function pluginName(plugin) {
1403
- if (!plugin || typeof plugin === "function") return void 0;
1404
- const name = plugin.name;
1405
- return typeof name === "string" ? name : void 0;
1406
- }
1407
- function validatePluginOrder(plugins, modelName, mode) {
1408
- if (mode === "off") return;
1409
- const names = plugins.map(pluginName);
1410
- for (const [first, after, reason] of PLUGIN_ORDER_CONSTRAINTS) {
1411
- const firstIdx = names.indexOf(first);
1412
- const afterIdx = names.indexOf(after);
1413
- if (firstIdx === -1 || afterIdx === -1) continue;
1414
- if (firstIdx < afterIdx) continue;
1415
- 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.`;
1416
- if (mode === "throw") throw new Error(message);
1417
- warn(message);
1418
- }
1419
- }
1420
- /**
1421
- * Plugin phase priorities (lower = runs first)
1422
- * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
1423
- * to ensure filters are injected before cache keys are computed.
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.
1424
1741
  */
1425
- const HOOK_PRIORITY = {
1426
- POLICY: 100,
1427
- CACHE: 200,
1428
- OBSERVABILITY: 300,
1429
- DEFAULT: 500
1430
- };
1742
+ const HOOK_PRIORITY$1 = HOOK_PRIORITY;
1431
1743
  /**
1432
- * Production-grade repository for MongoDB
1433
- * 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.
1434
1756
  */
1435
- var Repository = class {
1757
+ var Repository = class extends RepositoryBase {
1436
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
+ */
1437
1764
  model;
1438
- _hooks;
1439
1765
  _pagination;
1440
- _hookMode;
1441
1766
  idField;
1442
1767
  searchMode;
1443
1768
  searchFields;
1444
1769
  _hasTextIndex = null;
1445
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
+ });
1446
1779
  this.Model = Model;
1447
1780
  this.model = Model.modelName;
1448
- this._hooks = /* @__PURE__ */ new Map();
1449
1781
  this._pagination = new PaginationEngine(Model, paginationConfig);
1450
- this._hookMode = options.hooks ?? "async";
1451
1782
  this.idField = options.idField ?? "_id";
1452
1783
  this.searchMode = options.searchMode ?? "text";
1453
1784
  this.searchFields = options.searchFields;
1454
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.`);
1455
- validatePluginOrder(plugins, this.model, options.pluginOrderChecks ?? "warn");
1456
- plugins.forEach((plugin) => {
1457
- this.use(plugin);
1458
- });
1459
- }
1460
- /**
1461
- * Register a plugin
1462
- */
1463
- use(plugin) {
1464
- if (typeof plugin === "function") plugin(this);
1465
- else if (plugin && typeof plugin.apply === "function") plugin.apply(this);
1466
- return this;
1786
+ for (const plugin of plugins) this.use(plugin);
1467
1787
  }
1468
1788
  /**
1469
- * Register event listener with optional priority for phase ordering.
1789
+ * Live read-through view of the hook engine's listener registry.
1470
1790
  *
1471
- * @param event - Event name (e.g. 'before:getAll')
1472
- * @param listener - Hook function
1473
- * @param options - Optional { priority } — use HOOK_PRIORITY constants.
1474
- * Lower priority numbers run first.
1475
- * Default: HOOK_PRIORITY.DEFAULT (500)
1476
- */
1477
- on(event, listener, options) {
1478
- if (!this._hooks.has(event)) this._hooks.set(event, []);
1479
- const hooks = this._hooks.get(event) ?? [];
1480
- const priority = options?.priority ?? HOOK_PRIORITY.DEFAULT;
1481
- hooks.push({
1482
- listener,
1483
- priority
1484
- });
1485
- hooks.sort((a, b) => a.priority - b.priority);
1486
- return this;
1487
- }
1488
- /**
1489
- * Remove a specific event listener
1490
- */
1491
- off(event, listener) {
1492
- const hooks = this._hooks.get(event);
1493
- if (hooks) {
1494
- const idx = hooks.findIndex((h) => h.listener === listener);
1495
- if (idx !== -1) hooks.splice(idx, 1);
1496
- }
1497
- return this;
1498
- }
1499
- /**
1500
- * Remove all listeners for an event, or all listeners entirely
1501
- */
1502
- removeAllListeners(event) {
1503
- if (event) this._hooks.delete(event);
1504
- else this._hooks.clear();
1505
- return this;
1506
- }
1507
- /**
1508
- * Emit event (sync - for backwards compatibility)
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 })`.
1509
1797
  */
1510
- emit(event, data) {
1511
- const hooks = this._hooks.get(event) || [];
1512
- for (const { listener } of hooks) try {
1513
- const result = listener(data);
1514
- if (result && typeof result.then === "function") result.catch((error) => {
1515
- if (event === "error:hook") return;
1516
- const err = error instanceof Error ? error : new Error(String(error));
1517
- this.emit("error:hook", {
1518
- event,
1519
- error: err
1520
- });
1521
- });
1522
- } catch (error) {
1523
- if (event === "error:hook") continue;
1524
- const err = error instanceof Error ? error : new Error(String(error));
1525
- this.emit("error:hook", {
1526
- event,
1527
- error: err
1528
- });
1529
- }
1798
+ get _hooks() {
1799
+ return this.hooks.listeners();
1530
1800
  }
1531
1801
  /**
1532
- * Emit event and await all async handlers (sorted by priority)
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.
1533
1806
  */
1534
- async emitAsync(event, data) {
1535
- const hooks = this._hooks.get(event) || [];
1536
- for (const { listener } of hooks) await listener(data);
1537
- }
1538
1807
  async _emitHook(event, data) {
1539
- if (this._hookMode === "async") {
1540
- await this.emitAsync(event, data);
1541
- return;
1542
- }
1543
- this.emit(event, data);
1808
+ await this.hooks.emitAccordingToMode(event, data);
1544
1809
  }
1545
1810
  async _emitErrorHook(event, data) {
1546
1811
  try {
@@ -1627,11 +1892,13 @@ var Repository = class {
1627
1892
  });
1628
1893
  return result;
1629
1894
  }
1895
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
1630
1896
  if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
1631
- if (context.throwOnNotFound === false || options.throwOnNotFound === false) return null;
1632
- throw createError(404, "Document not found");
1897
+ if (wantsThrow) throw createError(404, "Document not found");
1898
+ return null;
1633
1899
  }
1634
1900
  const result = await getById(this.Model, id, context);
1901
+ if (!result && wantsThrow) throw createError(404, "Document not found");
1635
1902
  await this._emitHook("after:getById", {
1636
1903
  context,
1637
1904
  result
@@ -1830,7 +2097,7 @@ var Repository = class {
1830
2097
  };
1831
2098
  const lookups = context.lookups ?? params.lookups;
1832
2099
  if (lookups && lookups.length > 0) try {
1833
- const lookupResult = await this.lookupPopulate({
2100
+ const result = await this.lookupPopulate({
1834
2101
  filters: query,
1835
2102
  lookups,
1836
2103
  sort: paginationOptions.sort,
@@ -1843,31 +2110,6 @@ var Repository = class {
1843
2110
  collation: paginationOptions.collation,
1844
2111
  countStrategy: context.countStrategy ?? params.countStrategy
1845
2112
  });
1846
- let result;
1847
- if (lookupResult.next !== void 0) result = {
1848
- method: "keyset",
1849
- docs: lookupResult.data,
1850
- limit: lookupResult.limit ?? limit,
1851
- hasMore: lookupResult.hasMore ?? false,
1852
- next: lookupResult.next ?? null
1853
- };
1854
- else {
1855
- const total = lookupResult.total ?? 0;
1856
- const resultLimit = lookupResult.limit ?? limit;
1857
- const totalPages = Math.ceil(total / resultLimit);
1858
- const currentPage = lookupResult.page ?? 1;
1859
- const hasNext = lookupResult.hasMore !== void 0 ? lookupResult.hasMore : currentPage < totalPages;
1860
- result = {
1861
- method: "offset",
1862
- docs: lookupResult.data,
1863
- page: currentPage,
1864
- limit: resultLimit,
1865
- total,
1866
- pages: totalPages,
1867
- hasNext,
1868
- hasPrev: currentPage > 1
1869
- };
1870
- }
1871
2113
  await this._emitHook("after:getAll", {
1872
2114
  context,
1873
2115
  result
@@ -2002,16 +2244,25 @@ var Repository = class {
2002
2244
  ...options
2003
2245
  });
2004
2246
  try {
2005
- let result;
2247
+ const wantsThrow = context.throwOnNotFound === true || options.throwOnNotFound === true;
2006
2248
  const effectiveIdField = options.idField ?? this.idField;
2007
- if (effectiveIdField !== "_id") {
2008
- const updated = await updateByQuery(this.Model, {
2009
- [effectiveIdField]: id,
2010
- ...context.query || {}
2011
- }, context.data || data, context);
2012
- if (!updated) throw createError(404, "Document not found");
2013
- result = updated;
2014
- } 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");
2015
2266
  await this._emitHook("after:update", {
2016
2267
  context,
2017
2268
  result
@@ -2125,6 +2376,22 @@ var Repository = class {
2125
2376
  return result;
2126
2377
  }
2127
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
+ }
2128
2395
  const deleteQuery = effectiveIdField !== "_id" ? {
2129
2396
  [effectiveIdField]: id,
2130
2397
  ...context.query || {}
@@ -2133,6 +2400,7 @@ var Repository = class {
2133
2400
  session: options.session,
2134
2401
  query: context.query
2135
2402
  });
2403
+ if (!result.success && wantsThrow) throw createError(404, "Document not found");
2136
2404
  await this._emitHook("after:delete", {
2137
2405
  context,
2138
2406
  result
@@ -2147,14 +2415,24 @@ var Repository = class {
2147
2415
  }
2148
2416
  }
2149
2417
  /**
2150
- * Execute aggregation pipeline
2151
- * 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.
2152
2427
  *
2153
- * @param pipeline - Aggregation pipeline stages
2154
- * @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
2155
2433
  */
2156
- async aggregate(pipeline, options = {}) {
2157
- const context = await this._buildContext("aggregate", {
2434
+ async aggregatePipeline(pipeline, options = {}) {
2435
+ const context = await this._buildContext("aggregatePipeline", {
2158
2436
  pipeline,
2159
2437
  ...options
2160
2438
  });
@@ -2172,13 +2450,13 @@ var Repository = class {
2172
2450
  if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
2173
2451
  if (options.collation) aggregation.collation(options.collation);
2174
2452
  const result = await aggregation.exec();
2175
- await this._emitHook("after:aggregate", {
2453
+ await this._emitHook("after:aggregatePipeline", {
2176
2454
  context,
2177
2455
  result
2178
2456
  });
2179
2457
  return result;
2180
2458
  } catch (error) {
2181
- await this._emitErrorHook("error:aggregate", {
2459
+ await this._emitErrorHook("error:aggregatePipeline", {
2182
2460
  context,
2183
2461
  error
2184
2462
  });
@@ -2186,14 +2464,19 @@ var Repository = class {
2186
2464
  }
2187
2465
  }
2188
2466
  /**
2189
- * Aggregate pipeline with pagination
2190
- * 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.
2470
+ *
2471
+ * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
2472
+ * cross-backend dashboard code; that method compiles the portable
2473
+ * IR to an equivalent pipeline.
2191
2474
  *
2192
- * Policy hooks (multi-tenant, soft-delete) inject context.filters which are
2193
- * prepended as a $match stage to the pipeline, ensuring tenant isolation.
2475
+ * Policy hooks (multi-tenant, soft-delete) inject context.filters
2476
+ * which are prepended as a `$match` stage.
2194
2477
  */
2195
- async aggregatePaginate(options = {}) {
2196
- const context = await this._buildContext("aggregatePaginate", options);
2478
+ async aggregatePipelinePaginate(options = {}) {
2479
+ const context = await this._buildContext("aggregatePipelinePaginate", options);
2197
2480
  const finalPipeline = [...context.pipeline || options.pipeline || []];
2198
2481
  if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
2199
2482
  const aggOptions = {
@@ -2202,6 +2485,116 @@ var Repository = class {
2202
2485
  };
2203
2486
  try {
2204
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
+ };
2205
2598
  await this._emitHook("after:aggregatePaginate", {
2206
2599
  context,
2207
2600
  result
@@ -2216,6 +2609,29 @@ var Repository = class {
2216
2609
  }
2217
2610
  }
2218
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
+ /**
2219
2635
  * Get distinct values
2220
2636
  * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
2221
2637
  */
@@ -2336,18 +2752,18 @@ var Repository = class {
2336
2752
  const hasMore = docs.length > limit;
2337
2753
  if (hasMore) docs.pop();
2338
2754
  const primaryField = getPrimaryField(normalizedSort);
2339
- 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
+ };
2340
2762
  await this._emitHook("after:lookupPopulate", {
2341
2763
  context,
2342
- result: docs
2764
+ result
2343
2765
  });
2344
- return {
2345
- data: docs,
2346
- total: 0,
2347
- limit,
2348
- next: nextCursor,
2349
- hasMore
2350
- };
2766
+ return result;
2351
2767
  }
2352
2768
  const page = pageFromContext ?? 1;
2353
2769
  const skip = (page - 1) * limit;
@@ -2364,17 +2780,21 @@ var Repository = class {
2364
2780
  const docs = await aggregation;
2365
2781
  const hasNext = docs.length > limit;
2366
2782
  if (hasNext) docs.pop();
2367
- await this._emitHook("after:lookupPopulate", {
2368
- context,
2369
- result: docs
2370
- });
2371
- return {
2372
- data: docs,
2373
- total: 0,
2783
+ const result = {
2784
+ method: "offset",
2785
+ docs,
2374
2786
  page,
2375
2787
  limit,
2376
- hasMore: hasNext
2788
+ total: 0,
2789
+ pages: 0,
2790
+ hasNext,
2791
+ hasPrev: page > 1
2377
2792
  };
2793
+ await this._emitHook("after:lookupPopulate", {
2794
+ context,
2795
+ result
2796
+ });
2797
+ return result;
2378
2798
  }
2379
2799
  dataPipeline.push({ $skip: skip }, { $limit: limit });
2380
2800
  appendLookupStages(dataPipeline);
@@ -2388,22 +2808,26 @@ var Repository = class {
2388
2808
  const aggregation = this.Model.aggregate(pipeline).session(session || null);
2389
2809
  if (collation) aggregation.collation(collation);
2390
2810
  if (readPref) aggregation.read(readPref);
2391
- const result = (await aggregation)[0] || {
2811
+ const facetResult = (await aggregation)[0] || {
2392
2812
  metadata: [],
2393
2813
  data: []
2394
2814
  };
2395
- const total = result.metadata[0]?.total || 0;
2396
- 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
+ };
2397
2826
  await this._emitHook("after:lookupPopulate", {
2398
2827
  context,
2399
- result: data
2828
+ result
2400
2829
  });
2401
- return {
2402
- data,
2403
- total,
2404
- page,
2405
- limit
2406
- };
2830
+ return result;
2407
2831
  } catch (error) {
2408
2832
  await this._emitErrorHook("error:lookupPopulate", {
2409
2833
  context,
@@ -2454,37 +2878,58 @@ var Repository = class {
2454
2878
  return new LookupBuilder(from);
2455
2879
  }
2456
2880
  /**
2457
- * 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.
2458
2883
  *
2459
- * Uses the MongoDB driver's `session.withTransaction()` which automatically retries
2460
- * 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.
2461
2891
  *
2462
- * The callback always receives a `ClientSession`. When `allowFallback` is true
2463
- * and the MongoDB deployment doesn't support transactions (e.g., standalone),
2464
- * 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.
2465
2895
  *
2466
- * @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
2467
2898
  * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
2468
2899
  * @param options.onFallback - Called when falling back to non-transactional execution
2469
2900
  * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
2470
2901
  *
2471
2902
  * @example
2472
2903
  * ```typescript
2473
- * const result = await repo.withTransaction(async (session) => {
2474
- * const order = await repo.create({ total: 100 }, { session });
2475
- * await paymentRepo.create({ orderId: order._id }, { session });
2476
- * 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;
2477
2908
  * });
2478
2909
  *
2479
2910
  * // With fallback for standalone/dev environments
2480
- * await repo.withTransaction(callback, {
2481
- * allowFallback: true,
2482
- * 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 });
2483
2926
  * });
2484
2927
  * ```
2485
2928
  */
2486
2929
  async withTransaction(callback, options = {}) {
2487
- return withTransaction(this.Model.db, callback, options);
2930
+ return withTransaction(this.Model.db, async (session) => {
2931
+ return callback(createTxBoundRepo(this, session));
2932
+ }, options);
2488
2933
  }
2489
2934
  /**
2490
2935
  * Execute custom query with event emission
@@ -2508,7 +2953,12 @@ var Repository = class {
2508
2953
  }
2509
2954
  }
2510
2955
  /**
2511
- * 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.
2512
2962
  *
2513
2963
  * Hook execution order is deterministic:
2514
2964
  * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
@@ -2517,15 +2967,29 @@ var Repository = class {
2517
2967
  * 4. DEFAULT (500) — user-registered hooks
2518
2968
  */
2519
2969
  async _buildContext(operation, options) {
2520
- const context = {
2521
- operation,
2522
- model: this.model,
2523
- ...options
2524
- };
2525
- const event = `before:${operation}`;
2526
- const hooks = this._hooks.get(event) || [];
2527
- for (const { listener } of hooks) await listener(context);
2528
- 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;
2529
2993
  }
2530
2994
  /**
2531
2995
  * Resolve the effective sort for getAll. Handles $near conflict detection
@@ -2602,6 +3066,38 @@ var Repository = class {
2602
3066
  return [populate];
2603
3067
  }
2604
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
+ /**
2605
3101
  * Handle errors with proper HTTP status codes
2606
3102
  */
2607
3103
  _handleError(error) {
@@ -2640,7 +3136,7 @@ var Repository = class {
2640
3136
  * adapter: {
2641
3137
  * async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
2642
3138
  * async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
2643
- * async del(key) { await redis.del(key); },
3139
+ * async delete(key) { await redis.del(key); },
2644
3140
  * async clear(pattern) {
2645
3141
  * const keys = await redis.keys(pattern || '*');
2646
3142
  * if (keys.length) await redis.del(...keys);
@@ -2750,16 +3246,16 @@ function cachePlugin(options) {
2750
3246
  }
2751
3247
  /**
2752
3248
  * Invalidate a specific document by ID (all shape variants).
2753
- * Deletes every tracked shape-variant key individually via del(),
3249
+ * Deletes every tracked shape-variant key individually via delete(),
2754
3250
  * so adapters without pattern-based clear() still get full invalidation.
2755
3251
  */
2756
3252
  async function invalidateById(id) {
2757
3253
  try {
2758
3254
  const baseKey = byIdKey(config.prefix, model, id);
2759
- await config.adapter.del(baseKey);
3255
+ await config.adapter.delete(baseKey);
2760
3256
  const trackedKeys = byIdKeyRegistry.get(id);
2761
3257
  if (trackedKeys) {
2762
- 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);
2763
3259
  byIdKeyRegistry.delete(id);
2764
3260
  }
2765
3261
  stats.invalidations++;
@@ -2798,7 +3294,7 @@ function cachePlugin(options) {
2798
3294
  log(`Cache error for getById:`, e);
2799
3295
  stats.errors++;
2800
3296
  }
2801
- }, { priority: HOOK_PRIORITY.CACHE });
3297
+ }, { priority: HOOK_PRIORITY$1.CACHE });
2802
3298
  /**
2803
3299
  * before:getByQuery - Check cache for single-doc query
2804
3300
  * Runs at CACHE priority (200) — after policy hooks inject filters
@@ -2829,7 +3325,7 @@ function cachePlugin(options) {
2829
3325
  log(`Cache error for getByQuery:`, e);
2830
3326
  stats.errors++;
2831
3327
  }
2832
- }, { priority: HOOK_PRIORITY.CACHE });
3328
+ }, { priority: HOOK_PRIORITY$1.CACHE });
2833
3329
  /**
2834
3330
  * before:getOne - Same cache logic as getByQuery (single-doc)
2835
3331
  */
@@ -2857,7 +3353,7 @@ function cachePlugin(options) {
2857
3353
  log(`Cache error for getOne:`, e);
2858
3354
  stats.errors++;
2859
3355
  }
2860
- }, { priority: HOOK_PRIORITY.CACHE });
3356
+ }, { priority: HOOK_PRIORITY$1.CACHE });
2861
3357
  /**
2862
3358
  * before:getAll - Check cache for list query
2863
3359
  * Runs at CACHE priority (200) — after policy hooks inject filters
@@ -2905,7 +3401,7 @@ function cachePlugin(options) {
2905
3401
  log(`Cache error for getAll:`, e);
2906
3402
  stats.errors++;
2907
3403
  }
2908
- }, { priority: HOOK_PRIORITY.CACHE });
3404
+ }, { priority: HOOK_PRIORITY$1.CACHE });
2909
3405
  /**
2910
3406
  * after:getById - Cache the result
2911
3407
  */
@@ -4077,7 +4573,7 @@ function multiTenantPlugin(options = {}) {
4077
4573
  }
4078
4574
  break;
4079
4575
  }
4080
- }, { priority: HOOK_PRIORITY.POLICY });
4576
+ }, { priority: HOOK_PRIORITY$1.POLICY });
4081
4577
  }
4082
4578
  }
4083
4579
  };
@@ -4255,7 +4751,7 @@ function softDeletePlugin(options = {}) {
4255
4751
  }
4256
4752
  context.softDeleted = true;
4257
4753
  }
4258
- }, { priority: HOOK_PRIORITY.POLICY });
4754
+ }, { priority: HOOK_PRIORITY$1.POLICY });
4259
4755
  const injectDeleteFilterToFilters = (context) => {
4260
4756
  if (options.soft !== false) {
4261
4757
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
@@ -4284,8 +4780,8 @@ function softDeletePlugin(options = {}) {
4284
4780
  const desc = OP_REGISTRY[op];
4285
4781
  const isFilterableMutation = op === "findOneAndUpdate";
4286
4782
  if (desc.mutates && !isFilterableMutation) continue;
4287
- if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
4288
- else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
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 });
4289
4785
  }
4290
4786
  repo.on("before:updateMany", (context) => {
4291
4787
  if (options.soft !== false) {
@@ -4295,7 +4791,7 @@ function softDeletePlugin(options = {}) {
4295
4791
  ...deleteFilter
4296
4792
  };
4297
4793
  }
4298
- }, { priority: HOOK_PRIORITY.POLICY });
4794
+ }, { priority: HOOK_PRIORITY$1.POLICY });
4299
4795
  repo.on("before:deleteMany", async (context) => {
4300
4796
  if (context.deleteMode === "hard") return;
4301
4797
  if (options.soft !== false) {
@@ -4307,7 +4803,7 @@ function softDeletePlugin(options = {}) {
4307
4803
  await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
4308
4804
  context.softDeleted = true;
4309
4805
  }
4310
- }, { priority: HOOK_PRIORITY.POLICY });
4806
+ }, { priority: HOOK_PRIORITY$1.POLICY });
4311
4807
  if (addRestoreMethod) {
4312
4808
  const restoreMethod = async function(id, restoreOptions = {}) {
4313
4809
  const context = await this._buildContext("restore", {
@@ -4704,4 +5200,4 @@ function uniqueField(field, errorMessage) {
4704
5200
  };
4705
5201
  }
4706
5202
  //#endregion
4707
- 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 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 };