@classytic/mongokit 3.6.4 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/{PaginationEngine-CunXkR9p.mjs → PaginationEngine-C2_tAvAD.mjs} +8 -5
  2. package/dist/actions/index.d.mts +1 -1
  3. package/dist/actions/index.mjs +1 -1
  4. package/dist/ai/index.d.mts +23 -1
  5. package/dist/ai/index.mjs +194 -12
  6. package/dist/{cursor-CHToazHy.mjs → cursor-J84rR6wG.mjs} +11 -7
  7. package/dist/error-Lw9GEAro.mjs +46 -0
  8. package/dist/{index-jzO5hMtF.d.mts → index-DlSAFIc9.d.mts} +10 -2
  9. package/dist/index.d.mts +97 -9
  10. package/dist/index.mjs +53 -12
  11. package/dist/logger-DJ7egMnd.mjs +30 -0
  12. package/dist/{mongooseToJsonSchema-BvD1NDl9.d.mts → mongooseToJsonSchema-BZaKOXXI.d.mts} +18 -2
  13. package/dist/{mongooseToJsonSchema-BlfkI9LC.mjs → mongooseToJsonSchema-DUwXPStK.mjs} +1 -1
  14. package/dist/pagination/PaginationEngine.d.mts +3 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +2 -2
  17. package/dist/plugins/index.mjs +2 -2
  18. package/dist/query/primitives/coercion.d.mts +1 -1
  19. package/dist/query/primitives/coercion.mjs +1 -1
  20. package/dist/query/primitives/indexes.d.mts +1 -1
  21. package/dist/{sort-C-BJEWUZ.mjs → sort-CrZjTK25.mjs} +19 -4
  22. package/dist/{types-BoH1qdHo.d.mts → types-D57SZJeE.d.mts} +83 -3
  23. package/dist/{update-AVfKWNGt.mjs → update-yjSoNnWy.mjs} +26 -2
  24. package/dist/utils/index.d.mts +2 -2
  25. package/dist/utils/index.mjs +5 -4
  26. package/dist/{validation-chain.plugin-7A5qGEQq.mjs → validation-chain.plugin-DwoOOWse.mjs} +460 -144
  27. package/dist/{validation-chain.plugin-BXx6Ogpp.d.mts → validation-chain.plugin-wGF34Ym-.d.mts} +27 -4
  28. package/package.json +8 -3
  29. package/dist/error-Bpbi_NKo.mjs +0 -63
  30. /package/dist/{coercion-B19MD38s.d.mts → coercion-oM8G4b6m.d.mts} +0 -0
  31. /package/dist/{field-selection-C5o7jyyA.mjs → field-selection-dI0m4RBs.mjs} +0 -0
  32. /package/dist/{id-resolution-LZ-cCwR5.mjs → id-resolution-e4BT9zAV.mjs} +0 -0
  33. /package/dist/{indexes-qIBCZ3mY.d.mts → indexes-C26GNZRU.d.mts} +0 -0
@@ -1,10 +1,136 @@
1
- import { a as warn, i as debug, n as parseDuplicateKeyError, t as createError } from "./error-Bpbi_NKo.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-AVfKWNGt.mjs";
3
- import { t as PaginationEngine } from "./PaginationEngine-CunXkR9p.mjs";
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";
4
5
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
5
- import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-LZ-cCwR5.mjs";
6
- import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-C5o7jyyA.mjs";
6
+ import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-e4BT9zAV.mjs";
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";
7
8
  import mongoose from "mongoose";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ //#region src/operations.ts
11
+ /**
12
+ * The registry. Order intentionally groups by category for readability.
13
+ * Adding a new RepositoryOperation requires exactly one entry here, after
14
+ * which every bundled plugin auto-classifies it correctly.
15
+ */
16
+ const OP_REGISTRY = {
17
+ create: {
18
+ policyKey: "data",
19
+ mutates: true,
20
+ hasIdContext: false
21
+ },
22
+ update: {
23
+ policyKey: "query",
24
+ mutates: true,
25
+ hasIdContext: true
26
+ },
27
+ findOneAndUpdate: {
28
+ policyKey: "query",
29
+ mutates: true,
30
+ hasIdContext: false
31
+ },
32
+ delete: {
33
+ policyKey: "query",
34
+ mutates: true,
35
+ hasIdContext: true
36
+ },
37
+ restore: {
38
+ policyKey: "query",
39
+ mutates: true,
40
+ hasIdContext: true
41
+ },
42
+ createMany: {
43
+ policyKey: "dataArray",
44
+ mutates: true,
45
+ hasIdContext: false
46
+ },
47
+ updateMany: {
48
+ policyKey: "query",
49
+ mutates: true,
50
+ hasIdContext: false
51
+ },
52
+ deleteMany: {
53
+ policyKey: "query",
54
+ mutates: true,
55
+ hasIdContext: false
56
+ },
57
+ bulkWrite: {
58
+ policyKey: "operations",
59
+ mutates: true,
60
+ hasIdContext: false
61
+ },
62
+ getById: {
63
+ policyKey: "query",
64
+ mutates: false,
65
+ hasIdContext: true
66
+ },
67
+ getByQuery: {
68
+ policyKey: "query",
69
+ mutates: false,
70
+ hasIdContext: false
71
+ },
72
+ getOne: {
73
+ policyKey: "query",
74
+ mutates: false,
75
+ hasIdContext: false
76
+ },
77
+ findAll: {
78
+ policyKey: "query",
79
+ mutates: false,
80
+ hasIdContext: false
81
+ },
82
+ getOrCreate: {
83
+ policyKey: "query",
84
+ mutates: false,
85
+ hasIdContext: false
86
+ },
87
+ count: {
88
+ policyKey: "query",
89
+ mutates: false,
90
+ hasIdContext: false
91
+ },
92
+ exists: {
93
+ policyKey: "query",
94
+ mutates: false,
95
+ hasIdContext: false
96
+ },
97
+ distinct: {
98
+ policyKey: "query",
99
+ mutates: false,
100
+ hasIdContext: false
101
+ },
102
+ aggregate: {
103
+ policyKey: "query",
104
+ mutates: false,
105
+ hasIdContext: false
106
+ },
107
+ getAll: {
108
+ policyKey: "filters",
109
+ mutates: false,
110
+ hasIdContext: false
111
+ },
112
+ aggregatePaginate: {
113
+ policyKey: "filters",
114
+ mutates: false,
115
+ hasIdContext: false
116
+ },
117
+ lookupPopulate: {
118
+ policyKey: "filters",
119
+ mutates: false,
120
+ hasIdContext: false
121
+ }
122
+ };
123
+ /** All known repository operations, in registry order. */
124
+ const ALL_OPERATIONS = Object.keys(OP_REGISTRY);
125
+ /** Operations that mutate the database. Drives audit + cache invalidation lists. */
126
+ const MUTATING_OPERATIONS = ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].mutates);
127
+ /** Operations that don't mutate. Drives default cacheable ops. */
128
+ const READ_OPERATIONS = ALL_OPERATIONS.filter((op) => !OP_REGISTRY[op].mutates);
129
+ /** Filter ops by their policy injection key. */
130
+ function operationsByPolicyKey(key) {
131
+ return ALL_OPERATIONS.filter((op) => OP_REGISTRY[op].policyKey === key);
132
+ }
133
+ //#endregion
8
134
  //#region src/plugins/aggregate-helpers.plugin.ts
9
135
  /**
10
136
  * Aggregate helpers plugin
@@ -97,6 +223,22 @@ function auditLogPlugin(logger) {
97
223
  organizationId: context.organizationId
98
224
  });
99
225
  });
226
+ repo.on("after:findOneAndUpdate", ({ context, result }) => {
227
+ if (!result) return;
228
+ logger?.info?.("Document upserted/updated (findOneAndUpdate)", {
229
+ model: context.model || repo.model,
230
+ id: result?.[repo.idField || "_id"],
231
+ userId: context.user?._id || context.user?.id,
232
+ organizationId: context.organizationId
233
+ });
234
+ });
235
+ repo.on("error:findOneAndUpdate", ({ context, error }) => {
236
+ logger?.error?.("findOneAndUpdate failed", {
237
+ model: context.model || repo.model,
238
+ error: error.message,
239
+ userId: context.user?._id || context.user?.id
240
+ });
241
+ });
100
242
  repo.on("after:delete", ({ context }) => {
101
243
  logger?.info?.("Document deleted", {
102
244
  model: context.model || repo.model,
@@ -176,6 +318,7 @@ function getAuditModel(collectionName, ttlDays) {
176
318
  enum: [
177
319
  "create",
178
320
  "update",
321
+ "findOneAndUpdate",
179
322
  "delete"
180
323
  ]
181
324
  },
@@ -325,6 +468,19 @@ function auditTrailPlugin(options = {}) {
325
468
  });
326
469
  });
327
470
  }
471
+ if (opsSet.has("findOneAndUpdate")) repo.on("after:findOneAndUpdate", ({ context, result }) => {
472
+ if (!result) return;
473
+ const doc = result;
474
+ const idKey = repo.idField || "_id";
475
+ writeAudit(AuditModel, {
476
+ model: context.model || repo.model,
477
+ operation: "findOneAndUpdate",
478
+ documentId: doc?.[idKey],
479
+ userId: getUserId(context),
480
+ orgId: context.organizationId,
481
+ metadata: metadata?.(context)
482
+ });
483
+ });
328
484
  if (opsSet.has("delete")) repo.on("after:delete", ({ context }) => {
329
485
  writeAudit(AuditModel, {
330
486
  model: context.model || repo.model,
@@ -1228,6 +1384,40 @@ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1228
1384
  return nextProjection;
1229
1385
  }
1230
1386
  /**
1387
+ * Ordered pairs that produce wrong behavior when registered out of order.
1388
+ * Each tuple is `[mustComeFirst, mustComeAfter, reason]`.
1389
+ *
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
+ /**
1231
1421
  * Plugin phase priorities (lower = runs first)
1232
1422
  * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
1233
1423
  * to ensure filters are injected before cache keys are computed.
@@ -1262,6 +1452,7 @@ var Repository = class {
1262
1452
  this.searchMode = options.searchMode ?? "text";
1263
1453
  this.searchFields = options.searchFields;
1264
1454
  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");
1265
1456
  plugins.forEach((plugin) => {
1266
1457
  this.use(plugin);
1267
1458
  });
@@ -1539,10 +1730,10 @@ var Repository = class {
1539
1730
  */
1540
1731
  async findAll(filters = {}, options = {}) {
1541
1732
  const context = await this._buildContext("findAll", {
1542
- filters,
1733
+ query: filters,
1543
1734
  ...options
1544
1735
  });
1545
- const resolvedFilters = context.filters ?? filters;
1736
+ const resolvedFilters = context.query ?? filters;
1546
1737
  try {
1547
1738
  const query = this.Model.find(resolvedFilters);
1548
1739
  const sortSpec = context.sort || options.sort;
@@ -1835,6 +2026,64 @@ var Repository = class {
1835
2026
  }
1836
2027
  }
1837
2028
  /**
2029
+ * Atomic findOneAndUpdate (compare-and-set primitive).
2030
+ *
2031
+ * Single round-trip match-and-mutate for outbox relays, distributed locks,
2032
+ * and workflow semaphores. Goes through the full hook pipeline so
2033
+ * multi-tenant scope, soft-delete, audit, and cache plugins all apply.
2034
+ *
2035
+ * Returns the matched document (post-update by default) or `null` when no
2036
+ * document matches and `upsert` is false.
2037
+ *
2038
+ * @example FIFO claim-lease for an outbox worker
2039
+ * ```ts
2040
+ * const claimed = await outboxRepo.findOneAndUpdate(
2041
+ * { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
2042
+ * { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
2043
+ * { sort: { createdAt: 1 }, returnDocument: 'after' },
2044
+ * );
2045
+ * if (!claimed) return; // queue empty
2046
+ * ```
2047
+ *
2048
+ * @example CAS upsert
2049
+ * ```ts
2050
+ * const lock = await locksRepo.findOneAndUpdate(
2051
+ * { _id: lockKey, ownerId: { $in: [null, workerId] } },
2052
+ * { $set: { ownerId: workerId, expiresAt: leaseUntil } },
2053
+ * { upsert: true },
2054
+ * );
2055
+ * ```
2056
+ */
2057
+ async findOneAndUpdate(filter, update, options = {}) {
2058
+ const context = await this._buildContext("findOneAndUpdate", {
2059
+ query: filter,
2060
+ data: update,
2061
+ ...options
2062
+ });
2063
+ try {
2064
+ const finalQuery = context.query || filter;
2065
+ const finalUpdate = context.data || update;
2066
+ const result = await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
2067
+ ...options,
2068
+ sort: options.sort,
2069
+ returnDocument: options.returnDocument ?? "after",
2070
+ upsert: options.upsert ?? false,
2071
+ session: options.session
2072
+ });
2073
+ await this._emitHook("after:findOneAndUpdate", {
2074
+ context,
2075
+ result
2076
+ });
2077
+ return result;
2078
+ } catch (error) {
2079
+ await this._emitErrorHook("error:findOneAndUpdate", {
2080
+ context,
2081
+ error
2082
+ });
2083
+ throw this._handleError(error);
2084
+ }
2085
+ }
2086
+ /**
1838
2087
  * Delete a document by ID.
1839
2088
  *
1840
2089
  * By default the behavior is decided by the plugin stack:
@@ -2063,12 +2312,13 @@ var Repository = class {
2063
2312
  };
2064
2313
  if (isKeyset && sort) {
2065
2314
  const parsedSort = this._parseSort(sort);
2066
- const { validateKeysetSort } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
2067
- const { encodeCursor, resolveCursorFilter } = await import("./cursor-CHToazHy.mjs").then((n) => n.t);
2068
- const { getPrimaryField } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
2069
- const normalizedSort = validateKeysetSort(parsedSort);
2315
+ const { validateKeysetSort } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
2316
+ const { encodeCursor, resolveCursorFilter } = await import("./cursor-J84rR6wG.mjs").then((n) => n.t);
2317
+ const { getPrimaryField } = await import("./sort-CrZjTK25.mjs").then((n) => n.n);
2318
+ const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
2070
2319
  const cursorVersion = this._pagination.config.cursorVersion ?? 1;
2071
- const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }) : { ...filters || {} };
2320
+ const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
2321
+ const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
2072
2322
  if (projection) {
2073
2323
  if (Object.values(projection).some((v) => v === 1)) {
2074
2324
  for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
@@ -2415,6 +2665,28 @@ var Repository = class {
2415
2665
  * ```
2416
2666
  */
2417
2667
  /**
2668
+ * Resolve the user's jitter config into a deterministic `ttl -> ttl` function.
2669
+ *
2670
+ * - `undefined` / `0`: identity (no jitter).
2671
+ * - number in (0, 1]: symmetric fractional jitter. `0.1` → uniform in [0.9, 1.1].
2672
+ * - function: used as-is; caller owns the math. Clamped to >= 1s below.
2673
+ *
2674
+ * The final TTL is always clamped to a whole second >= 1 so adapters that
2675
+ * treat 0 as "no expiry" (Redis SETEX semantics) don't accidentally persist
2676
+ * entries forever.
2677
+ */
2678
+ function resolveJitter(jitter) {
2679
+ if (!jitter) return (ttl) => ttl;
2680
+ if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
2681
+ const fraction = Math.min(1, Math.max(0, jitter));
2682
+ if (fraction === 0) return (ttl) => ttl;
2683
+ return (ttl) => {
2684
+ const delta = ttl * fraction;
2685
+ const jittered = ttl - delta + Math.random() * 2 * delta;
2686
+ return Math.max(1, Math.round(jittered));
2687
+ };
2688
+ }
2689
+ /**
2418
2690
  * Cache plugin factory
2419
2691
  *
2420
2692
  * @param options - Cache configuration
@@ -2428,7 +2700,8 @@ function cachePlugin(options) {
2428
2700
  queryTtl: options.queryTtl ?? options.ttl ?? 60,
2429
2701
  prefix: options.prefix ?? "mk",
2430
2702
  debug: options.debug ?? false,
2431
- skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
2703
+ skipIfLargeLimit: options.skipIf?.largeLimit ?? 100,
2704
+ applyJitter: resolveJitter(options.jitter)
2432
2705
  };
2433
2706
  const stats = {
2434
2707
  hits: 0,
@@ -2468,7 +2741,7 @@ function cachePlugin(options) {
2468
2741
  async function bumpVersion() {
2469
2742
  const newVersion = Date.now();
2470
2743
  try {
2471
- await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
2744
+ await config.adapter.set(versionKey(config.prefix, model), newVersion, config.applyJitter(config.ttl * 10));
2472
2745
  stats.invalidations++;
2473
2746
  log(`Bumped version for ${model} to:`, newVersion);
2474
2747
  } catch (e) {
@@ -2647,7 +2920,7 @@ function cachePlugin(options) {
2647
2920
  populate: context.populate,
2648
2921
  lean: context.lean
2649
2922
  });
2650
- const ttl = context.cacheTtl ?? config.byIdTtl;
2923
+ const ttl = config.applyJitter(context.cacheTtl ?? config.byIdTtl);
2651
2924
  try {
2652
2925
  await config.adapter.set(key, result, ttl);
2653
2926
  trackByIdKey(id, key);
@@ -2671,7 +2944,7 @@ function cachePlugin(options) {
2671
2944
  select: context.select,
2672
2945
  populate: context.populate
2673
2946
  });
2674
- const ttl = context.cacheTtl ?? config.queryTtl;
2947
+ const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
2675
2948
  try {
2676
2949
  await config.adapter.set(key, result, ttl);
2677
2950
  stats.sets++;
@@ -2693,7 +2966,7 @@ function cachePlugin(options) {
2693
2966
  populate: context.populate
2694
2967
  });
2695
2968
  try {
2696
- await config.adapter.set(key, result, context.cacheTtl ?? config.queryTtl);
2969
+ await config.adapter.set(key, result, config.applyJitter(context.cacheTtl ?? config.queryTtl));
2697
2970
  stats.sets++;
2698
2971
  } catch (e) {
2699
2972
  log(`Failed to cache getOne:`, e);
@@ -2726,7 +2999,7 @@ function cachePlugin(options) {
2726
2999
  countStrategy: context.countStrategy
2727
3000
  };
2728
3001
  const key = listQueryKey(config.prefix, model, collectionVersion, params);
2729
- const ttl = context.cacheTtl ?? config.queryTtl;
3002
+ const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
2730
3003
  try {
2731
3004
  await config.adapter.set(key, result, ttl);
2732
3005
  stats.sets++;
@@ -2762,6 +3035,22 @@ function cachePlugin(options) {
2762
3035
  await bumpVersion();
2763
3036
  });
2764
3037
  /**
3038
+ * after:findOneAndUpdate — atomic CAS may insert (upsert) or mutate.
3039
+ * Result carries the matched/inserted doc; invalidate that ID and bump
3040
+ * version. Null result (no match, no upsert) is a no-op.
3041
+ */
3042
+ repo.on("after:findOneAndUpdate", async (payload) => {
3043
+ const { result } = payload;
3044
+ if (!result) return;
3045
+ const idKey = repo.idField || "_id";
3046
+ const id = result?.[idKey];
3047
+ if (id == null) {
3048
+ await bumpVersion();
3049
+ return;
3050
+ }
3051
+ await Promise.all([invalidateById(String(id)), bumpVersion()]);
3052
+ });
3053
+ /**
2765
3054
  * after:delete - Invalidate by ID and bump version
2766
3055
  */
2767
3056
  repo.on("after:delete", async (payload) => {
@@ -3720,45 +4009,20 @@ function mongoOperationsPlugin() {
3720
4009
  */
3721
4010
  function multiTenantPlugin(options = {}) {
3722
4011
  const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string" } = options;
3723
- const filterOps = [
3724
- "getAll",
3725
- "findAll",
3726
- "aggregatePaginate",
3727
- "lookupPopulate"
3728
- ];
3729
- const queryReadOps = [
3730
- "getById",
3731
- "getByQuery",
3732
- "getOne",
3733
- "count",
3734
- "exists",
3735
- "getOrCreate",
3736
- "distinct",
3737
- "aggregate"
3738
- ];
3739
- const constrainedWriteOps = [
3740
- "update",
3741
- "delete",
3742
- "restore"
3743
- ];
3744
- const filterReadOps = ["getDeleted"];
3745
- const createOps = ["create", "createMany"];
3746
- const batchQueryOps = ["updateMany", "deleteMany"];
3747
- const bulkOps = ["bulkWrite"];
3748
- const allOps = [
3749
- ...filterOps,
3750
- ...filterReadOps,
3751
- ...queryReadOps,
3752
- ...constrainedWriteOps,
3753
- ...createOps,
3754
- ...batchQueryOps,
3755
- ...bulkOps
3756
- ];
4012
+ const extraOps = [{
4013
+ op: "getDeleted",
4014
+ policyKey: "filters"
4015
+ }];
3757
4016
  return {
3758
4017
  name: "multi-tenant",
3759
4018
  apply(repo) {
3760
- for (const op of allOps) {
4019
+ const allOps = [...ALL_OPERATIONS.map((op) => ({
4020
+ op,
4021
+ policyKey: OP_REGISTRY[op].policyKey
4022
+ })), ...extraOps];
4023
+ for (const { op, policyKey } of allOps) {
3761
4024
  if (skipOperations.includes(op)) continue;
4025
+ if (policyKey === "none") continue;
3762
4026
  repo.on(`before:${op}`, (context) => {
3763
4027
  if (skipWhen?.(context, op)) return;
3764
4028
  let tenantId = context[contextKey];
@@ -3769,45 +4033,49 @@ function multiTenantPlugin(options = {}) {
3769
4033
  if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
3770
4034
  if (!tenantId) return;
3771
4035
  const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
3772
- if (filterOps.includes(op) || filterReadOps.includes(op)) context.filters = {
3773
- ...context.filters,
3774
- [tenantField]: castId
3775
- };
3776
- if (queryReadOps.includes(op)) context.query = {
3777
- ...context.query,
3778
- [tenantField]: castId
3779
- };
3780
- if (op === "create" && context.data) context.data[tenantField] = castId;
3781
- if (op === "createMany" && context.dataArray) {
3782
- for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
3783
- }
3784
- if (constrainedWriteOps.includes(op)) context.query = {
3785
- ...context.query,
3786
- [tenantField]: castId
3787
- };
3788
- if (batchQueryOps.includes(op)) context.query = {
3789
- ...context.query,
3790
- [tenantField]: castId
3791
- };
3792
- if (op === "bulkWrite" && context.operations) {
3793
- const ops = context.operations;
3794
- for (const subOp of ops) {
3795
- for (const key of [
3796
- "updateOne",
3797
- "updateMany",
3798
- "deleteOne",
3799
- "deleteMany",
3800
- "replaceOne"
3801
- ]) {
3802
- const opBody = subOp[key];
3803
- if (opBody?.filter) opBody.filter = {
3804
- ...opBody.filter,
3805
- [tenantField]: castId
3806
- };
4036
+ switch (policyKey) {
4037
+ case "filters":
4038
+ context.filters = {
4039
+ ...context.filters,
4040
+ [tenantField]: castId
4041
+ };
4042
+ break;
4043
+ case "query":
4044
+ context.query = {
4045
+ ...context.query,
4046
+ [tenantField]: castId
4047
+ };
4048
+ break;
4049
+ case "data":
4050
+ if (context.data) context.data[tenantField] = castId;
4051
+ break;
4052
+ case "dataArray":
4053
+ if (context.dataArray) {
4054
+ for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
3807
4055
  }
3808
- const insertBody = subOp.insertOne;
3809
- if (insertBody?.document) insertBody.document[tenantField] = castId;
3810
- }
4056
+ break;
4057
+ case "operations":
4058
+ if (context.operations) {
4059
+ const ops = context.operations;
4060
+ for (const subOp of ops) {
4061
+ for (const key of [
4062
+ "updateOne",
4063
+ "updateMany",
4064
+ "deleteOne",
4065
+ "deleteMany",
4066
+ "replaceOne"
4067
+ ]) {
4068
+ const opBody = subOp[key];
4069
+ if (opBody?.filter) opBody.filter = {
4070
+ ...opBody.filter,
4071
+ [tenantField]: castId
4072
+ };
4073
+ }
4074
+ const insertBody = subOp.insertOne;
4075
+ if (insertBody?.document) insertBody.document[tenantField] = castId;
4076
+ }
4077
+ }
4078
+ break;
3811
4079
  }
3812
4080
  }, { priority: HOOK_PRIORITY.POLICY });
3813
4081
  }
@@ -3816,24 +4084,26 @@ function multiTenantPlugin(options = {}) {
3816
4084
  }
3817
4085
  //#endregion
3818
4086
  //#region src/plugins/observability.plugin.ts
3819
- const DEFAULT_OPS = [
3820
- "create",
3821
- "createMany",
3822
- "update",
3823
- "delete",
3824
- "getById",
3825
- "getByQuery",
3826
- "getOne",
3827
- "getAll",
3828
- "findAll",
3829
- "getOrCreate",
3830
- "count",
3831
- "exists",
3832
- "distinct",
3833
- "aggregate",
3834
- "aggregatePaginate",
3835
- "lookupPopulate"
3836
- ];
4087
+ /**
4088
+ * Observability Plugin
4089
+ *
4090
+ * Adds operation timing, structured metrics, and APM hook points.
4091
+ * Works with any monitoring system — just provide an onMetric callback.
4092
+ *
4093
+ * @example
4094
+ * ```typescript
4095
+ * const repo = new Repository(User, [
4096
+ * observabilityPlugin({
4097
+ * onMetric: (metric) => {
4098
+ * // Send to DataDog, New Relic, OpenTelemetry, or console
4099
+ * console.log(`${metric.operation} took ${metric.durationMs}ms`);
4100
+ * statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
4101
+ * },
4102
+ * }),
4103
+ * ]);
4104
+ * ```
4105
+ */
4106
+ const DEFAULT_OPS = ALL_OPERATIONS;
3837
4107
  const timers = /* @__PURE__ */ new WeakMap();
3838
4108
  function observabilityPlugin(options) {
3839
4109
  const { onMetric, slowThresholdMs } = options;
@@ -4004,22 +4274,19 @@ function softDeletePlugin(options = {}) {
4004
4274
  };
4005
4275
  }
4006
4276
  };
4007
- for (const op of [
4008
- "getAll",
4009
- "findAll",
4010
- "aggregatePaginate",
4011
- "lookupPopulate"
4012
- ]) repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
4013
- for (const op of [
4014
- "getById",
4015
- "getOne",
4016
- "getByQuery",
4017
- "count",
4018
- "exists",
4019
- "getOrCreate",
4020
- "distinct",
4021
- "aggregate"
4022
- ]) repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
4277
+ const SOFT_DELETE_SPECIAL = new Set([
4278
+ "updateMany",
4279
+ "deleteMany",
4280
+ "bulkWrite"
4281
+ ]);
4282
+ for (const op of ALL_OPERATIONS) {
4283
+ if (SOFT_DELETE_SPECIAL.has(op)) continue;
4284
+ const desc = OP_REGISTRY[op];
4285
+ const isFilterableMutation = op === "findOneAndUpdate";
4286
+ 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 });
4289
+ }
4023
4290
  repo.on("before:updateMany", (context) => {
4024
4291
  if (options.soft !== false) {
4025
4292
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
@@ -4041,24 +4308,6 @@ function softDeletePlugin(options = {}) {
4041
4308
  context.softDeleted = true;
4042
4309
  }
4043
4310
  }, { priority: HOOK_PRIORITY.POLICY });
4044
- repo.on("before:aggregate", (context) => {
4045
- if (options.soft !== false) {
4046
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
4047
- if (Object.keys(deleteFilter).length > 0) context.query = {
4048
- ...context.query || {},
4049
- ...deleteFilter
4050
- };
4051
- }
4052
- }, { priority: HOOK_PRIORITY.POLICY });
4053
- repo.on("before:aggregatePaginate", (context) => {
4054
- if (options.soft !== false) {
4055
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
4056
- if (Object.keys(deleteFilter).length > 0) context.filters = {
4057
- ...context.filters || {},
4058
- ...deleteFilter
4059
- };
4060
- }
4061
- }, { priority: HOOK_PRIORITY.POLICY });
4062
4311
  if (addRestoreMethod) {
4063
4312
  const restoreMethod = async function(id, restoreOptions = {}) {
4064
4313
  const context = await this._buildContext("restore", {
@@ -4215,6 +4464,57 @@ function subdocumentPlugin() {
4215
4464
  };
4216
4465
  }
4217
4466
  //#endregion
4467
+ //#region src/plugins/tenant-context.ts
4468
+ /**
4469
+ * Request-scoped tenant context via AsyncLocalStorage.
4470
+ *
4471
+ * The multi-tenant plugin already accepts a `resolveContext` callback for
4472
+ * reading tenant ID from an external store. This helper wraps Node's
4473
+ * `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
4474
+ * to wire the plumbing by hand — a common source of cross-tenant leaks.
4475
+ *
4476
+ * @example
4477
+ * import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
4478
+ *
4479
+ * const tenantContext = createTenantContext();
4480
+ *
4481
+ * // In your HTTP middleware (Express / Fastify / NestJS):
4482
+ * app.use((req, _res, next) => {
4483
+ * tenantContext.run({ tenantId: req.auth.organizationId }, next);
4484
+ * });
4485
+ *
4486
+ * // In your repository definition:
4487
+ * const repo = new Repository(Invoice, [
4488
+ * multiTenantPlugin({
4489
+ * tenantField: 'organizationId',
4490
+ * resolveContext: () => tenantContext.getTenantId(),
4491
+ * }),
4492
+ * ]);
4493
+ *
4494
+ * // In your handler — no need to pass organizationId manually:
4495
+ * await repo.getAll({ filters: { status: 'paid' } });
4496
+ */
4497
+ function createTenantContext() {
4498
+ const storage = new AsyncLocalStorage();
4499
+ return {
4500
+ storage,
4501
+ run(store, fn) {
4502
+ return storage.run(store, fn);
4503
+ },
4504
+ getStore() {
4505
+ return storage.getStore();
4506
+ },
4507
+ getTenantId() {
4508
+ return storage.getStore()?.tenantId;
4509
+ },
4510
+ requireTenantId() {
4511
+ const id = storage.getStore()?.tenantId;
4512
+ if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
4513
+ return id;
4514
+ }
4515
+ };
4516
+ }
4517
+ //#endregion
4218
4518
  //#region src/plugins/timestamp.plugin.ts
4219
4519
  /**
4220
4520
  * Timestamp plugin that auto-injects timestamps
@@ -4236,6 +4536,20 @@ function timestampPlugin() {
4236
4536
  if (!context.data) return;
4237
4537
  context.data.updatedAt = /* @__PURE__ */ new Date();
4238
4538
  });
4539
+ repo.on("before:findOneAndUpdate", (context) => {
4540
+ if (!context.data) return;
4541
+ const now = /* @__PURE__ */ new Date();
4542
+ if (Array.isArray(context.data)) return;
4543
+ const update = context.data;
4544
+ if (Object.keys(update).some((k) => k.startsWith("$"))) {
4545
+ const set = update.$set || {};
4546
+ set.updatedAt = now;
4547
+ update.$set = set;
4548
+ const setOnInsert = update.$setOnInsert || {};
4549
+ if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
4550
+ update.$setOnInsert = setOnInsert;
4551
+ } else update.updatedAt = now;
4552
+ });
4239
4553
  }
4240
4554
  };
4241
4555
  }
@@ -4262,6 +4576,7 @@ function validationChainPlugin(validators = [], options = {}) {
4262
4576
  const validatorsByOperation = {
4263
4577
  create: [],
4264
4578
  update: [],
4579
+ findOneAndUpdate: [],
4265
4580
  delete: [],
4266
4581
  createMany: []
4267
4582
  };
@@ -4300,6 +4615,7 @@ function validationChainPlugin(validators = [], options = {}) {
4300
4615
  repo.on("before:create", async (context) => runValidators("create", context));
4301
4616
  repo.on("before:createMany", async (context) => runValidators("createMany", context));
4302
4617
  repo.on("before:update", async (context) => runValidators("update", context));
4618
+ repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
4303
4619
  repo.on("before:delete", async (context) => runValidators("delete", context));
4304
4620
  }
4305
4621
  };
@@ -4388,4 +4704,4 @@ function uniqueField(field, errorMessage) {
4388
4704
  };
4389
4705
  }
4390
4706
  //#endregion
4391
- export { auditTrailPlugin as A, HOOK_PRIORITY as C, AggregationBuilder as D, withTransaction as E, aggregateHelpersPlugin as M, batchOperationsPlugin as O, cachePlugin as S, isTransactionUnsupported as T, dateSequentialId as _, uniqueField as a, sequentialId as b, subdocumentPlugin as c, multiTenantPlugin as d, mongoOperationsPlugin as f, customIdPlugin as g, elasticSearchPlugin as h, requireField as i, auditLogPlugin as j, AuditTrailQuery as k, softDeletePlugin as l, fieldFilterPlugin as m, blockIf as n, validationChainPlugin as o, methodRegistryPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, observabilityPlugin as u, getNextSequence as v, Repository as w, cascadePlugin as x, prefixedId as y };
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 };