@classytic/mongokit 3.5.5 → 3.6.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 (26) hide show
  1. package/README.md +189 -1486
  2. package/dist/{PaginationEngine-CpsQl471.mjs → PaginationEngine-CunXkR9p.mjs} +100 -4
  3. package/dist/actions/index.d.mts +1 -1
  4. package/dist/ai/index.d.mts +1 -1
  5. package/dist/id-resolution-LZ-cCwR5.mjs +81 -0
  6. package/dist/{index-Buj7P006.d.mts → index-BSRSsRRx.d.mts} +1 -1
  7. package/dist/index.d.mts +81 -9
  8. package/dist/index.mjs +5 -5
  9. package/dist/{mongooseToJsonSchema-DpjhFfaO.mjs → mongooseToJsonSchema-8Cyu3NFJ.mjs} +50 -45
  10. package/dist/{mongooseToJsonSchema-BlDfbg_0.d.mts → mongooseToJsonSchema-D2YjHo7W.d.mts} +4 -3
  11. package/dist/pagination/PaginationEngine.d.mts +8 -1
  12. package/dist/pagination/PaginationEngine.mjs +1 -1
  13. package/dist/plugins/index.d.mts +1 -1
  14. package/dist/plugins/index.mjs +1 -1
  15. package/dist/query/primitives/coercion.d.mts +1 -1
  16. package/dist/query/primitives/coercion.mjs +4 -3
  17. package/dist/query/primitives/indexes.d.mts +1 -1
  18. package/dist/{types-CWUL0yiw.d.mts → types-DiQfDesD.d.mts} +29 -3
  19. package/dist/utils/index.d.mts +60 -3
  20. package/dist/utils/index.mjs +4 -3
  21. package/dist/{validation-chain.plugin-BCPh2UZG.mjs → validation-chain.plugin-3rr7dlw6.mjs} +308 -164
  22. package/dist/{validation-chain.plugin-Dn41aAbK.d.mts → validation-chain.plugin-COKNRAwp.d.mts} +15 -12
  23. package/package.json +2 -2
  24. /package/dist/{coercion-01tEHsKF.d.mts → coercion-B19MD38s.d.mts} +0 -0
  25. /package/dist/{field-selection-reyDRzXf.mjs → field-selection-C5o7jyyA.mjs} +0 -0
  26. /package/dist/{indexes-CiIT-Zli.d.mts → indexes-qIBCZ3mY.d.mts} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { a as warn, i as debug, n as parseDuplicateKeyError, t as createError } from "./error-Bpbi_NKo.mjs";
2
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-CpsQl471.mjs";
3
+ import { t as PaginationEngine } from "./PaginationEngine-CunXkR9p.mjs";
4
4
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
5
- import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-reyDRzXf.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
7
  import mongoose from "mongoose";
7
8
  //#region src/plugins/aggregate-helpers.plugin.ts
8
9
  /**
@@ -582,12 +583,23 @@ function batchOperationsPlugin() {
582
583
  }
583
584
  });
584
585
  /**
585
- * Delete multiple documents
586
+ * Delete multiple documents matching a query.
587
+ *
588
+ * Behavior mirrors `Repository.delete`: defaults to soft-delete when
589
+ * `softDeletePlugin` is wired, physical delete otherwise. Pass
590
+ * `{ mode: 'hard' }` to bypass soft-delete for GDPR / cleanup paths —
591
+ * multi-tenant scoping and audit hooks still fire.
592
+ *
593
+ * Rejects empty filters even after policy hooks run, to prevent
594
+ * accidental collection wipes.
586
595
  */
587
596
  repo.registerMethod("deleteMany", async function(query, options = {}) {
597
+ if (!query || Object.keys(query).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
598
+ const mode = options.mode;
588
599
  const context = await this._buildContext("deleteMany", {
589
600
  query,
590
- ...options
601
+ ...options,
602
+ ...mode ? { deleteMode: mode } : {}
591
603
  });
592
604
  try {
593
605
  if (context.softDeleted) {
@@ -602,7 +614,7 @@ function batchOperationsPlugin() {
602
614
  return result;
603
615
  }
604
616
  const finalQuery = context.query || query;
605
- if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass deletes.");
617
+ if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
606
618
  const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
607
619
  await this.emitAsync("after:deleteMany", {
608
620
  context,
@@ -1153,6 +1165,60 @@ var AggregationBuilder = class AggregationBuilder {
1153
1165
  * ```
1154
1166
  */
1155
1167
  //#endregion
1168
+ //#region src/transaction.ts
1169
+ /**
1170
+ * Run a callback inside a MongoDB transaction on the given connection.
1171
+ *
1172
+ * - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
1173
+ * on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
1174
+ * always ends the session in `finally`.
1175
+ * - When `allowFallback` is true and the deployment doesn't support transactions
1176
+ * (e.g. standalone MongoDB in dev), the callback runs once without a
1177
+ * transaction on the same session. `onFallback` is invoked with the original
1178
+ * error so hosts can log the degradation.
1179
+ *
1180
+ * @example
1181
+ * ```ts
1182
+ * import mongoose from 'mongoose';
1183
+ * import { withTransaction } from '@classytic/mongokit';
1184
+ *
1185
+ * await withTransaction(mongoose.connection, async (session) => {
1186
+ * const order = await orderRepo.create(data, { session });
1187
+ * await inventoryRepo.decrement(order.items, { session });
1188
+ * return order;
1189
+ * });
1190
+ * ```
1191
+ */
1192
+ async function withTransaction(connection, callback, options = {}) {
1193
+ const session = await connection.startSession();
1194
+ try {
1195
+ return await session.withTransaction(() => callback(session), options.transactionOptions);
1196
+ } catch (error) {
1197
+ const err = error;
1198
+ if (options.allowFallback && isTransactionUnsupported(err)) {
1199
+ options.onFallback?.(err);
1200
+ return await callback(session);
1201
+ }
1202
+ throw err;
1203
+ } finally {
1204
+ await session.endSession();
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Detect whether an error indicates the MongoDB deployment does not support
1209
+ * multi-document transactions (standalone server, older topology, etc.).
1210
+ *
1211
+ * Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
1212
+ * topology) — with a message-matching fallback for edge cases surfaced by
1213
+ * older driver versions.
1214
+ */
1215
+ function isTransactionUnsupported(error) {
1216
+ const code = error.code;
1217
+ if (code === 263 || code === 20) return true;
1218
+ const message = (error.message || "").toLowerCase();
1219
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
1220
+ }
1221
+ //#endregion
1156
1222
  //#region src/Repository.ts
1157
1223
  function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1158
1224
  if (!projection || !sort) return projection;
@@ -1370,7 +1436,7 @@ var Repository = class {
1370
1436
  });
1371
1437
  return result;
1372
1438
  }
1373
- if (typeof id === "string" && !mongoose.Types.ObjectId.isValid(id)) {
1439
+ if (!isValidIdForType(id, getSchemaIdType(this.Model.schema))) {
1374
1440
  if (context.throwOnNotFound === false || options.throwOnNotFound === false) return null;
1375
1441
  throw createError(404, "Document not found");
1376
1442
  }
@@ -1549,41 +1615,14 @@ var Repository = class {
1549
1615
  });
1550
1616
  const filters = context.filters ?? params.filters ?? {};
1551
1617
  const search = context.search;
1552
- const isNearQuery = hasNearOperator(context.filters ?? params.filters ?? {});
1553
- const explicitSort = context.sort ?? params.sort;
1554
- if (isNearQuery && explicitSort !== void 0) warn(`[mongokit] Repository "${this.model}" dropping explicit sort (${JSON.stringify(explicitSort)}) because the filter contains $near / $nearSphere. MongoDB forbids explicit sort with $near. Use [withinRadius] instead of [near] if you need a custom sort.`);
1555
- const sort = isNearQuery ? void 0 : explicitSort ?? "-createdAt";
1618
+ const isNearQuery = hasNearOperator(filters);
1619
+ const sort = this._resolveSort(context, params, isNearQuery);
1556
1620
  const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
1557
1621
  const page = context.page ?? params.pagination?.page ?? params.page;
1558
1622
  const after = context.after ?? params.cursor ?? params.after;
1559
1623
  const mode = context.mode ?? params.mode;
1560
- let useKeyset = false;
1561
- if (mode) useKeyset = mode === "keyset";
1562
- else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
1563
- const query = { ...filters };
1564
- if (search) {
1565
- if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
1566
- let effectiveMode = this.searchMode === "regex" ? "regex" : "text";
1567
- if (this.searchMode === "auto") effectiveMode = this._hasTextIndex ? "text" : "regex";
1568
- if (effectiveMode === "regex") {
1569
- if (!this.searchFields || this.searchFields.length === 0) throw createError(400, `Repository "${this.model}" configured with searchMode: '${this.searchMode}' but no searchFields provided.`);
1570
- const escaped = String(search).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1571
- const orConds = this.searchFields.map((field) => ({ [field]: {
1572
- $regex: escaped,
1573
- $options: "i"
1574
- } }));
1575
- if (Array.isArray(query.$or)) {
1576
- const existingOr = query.$or;
1577
- delete query.$or;
1578
- query.$and = [
1579
- ...Array.isArray(query.$and) ? query.$and : [],
1580
- { $or: existingOr },
1581
- { $or: orConds }
1582
- ];
1583
- } else query.$or = orConds;
1584
- } else if (this._hasTextIndex) query.$text = { $search: search };
1585
- else throw createError(400, `No text index found for ${this.model}. Cannot perform text search. Configure Repository with searchMode: 'regex' (and searchFields) or 'auto' to enable index-free search.`);
1586
- }
1624
+ const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
1625
+ const query = this._buildSearchQuery(filters, search);
1587
1626
  const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
1588
1627
  const paginationOptions = {
1589
1628
  filters: query,
@@ -1796,12 +1835,31 @@ var Repository = class {
1796
1835
  }
1797
1836
  }
1798
1837
  /**
1799
- * Delete document by ID
1838
+ * Delete a document by ID.
1839
+ *
1840
+ * By default the behavior is decided by the plugin stack:
1841
+ * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
1842
+ * - Without the plugin → physical delete
1843
+ *
1844
+ * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
1845
+ * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
1846
+ * cascade) still fire — only the soft-delete interception is skipped. This
1847
+ * is the GDPR / admin-cleanup path.
1848
+ *
1849
+ * @example
1850
+ * ```ts
1851
+ * // Respects softDeletePlugin — soft if wired
1852
+ * await userRepo.delete(userId);
1853
+ *
1854
+ * // GDPR erasure — physical delete, audit hooks still fire
1855
+ * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
1856
+ * ```
1800
1857
  */
1801
1858
  async delete(id, options = {}) {
1802
1859
  const context = await this._buildContext("delete", {
1803
1860
  id,
1804
- ...options
1861
+ ...options,
1862
+ ...options.mode ? { deleteMode: options.mode } : {}
1805
1863
  });
1806
1864
  try {
1807
1865
  if (context.softDeleted) {
@@ -2176,25 +2234,7 @@ var Repository = class {
2176
2234
  * ```
2177
2235
  */
2178
2236
  async withTransaction(callback, options = {}) {
2179
- const session = await this.Model.db.startSession();
2180
- try {
2181
- return await session.withTransaction(() => callback(session), options.transactionOptions);
2182
- } catch (error) {
2183
- const err = error;
2184
- if (options.allowFallback && this._isTransactionUnsupported(err)) {
2185
- options.onFallback?.(err);
2186
- return await callback(session);
2187
- }
2188
- throw err;
2189
- } finally {
2190
- await session.endSession();
2191
- }
2192
- }
2193
- _isTransactionUnsupported(error) {
2194
- const code = error.code;
2195
- if (code === 263 || code === 20) return true;
2196
- const message = (error.message || "").toLowerCase();
2197
- return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported");
2237
+ return withTransaction(this.Model.db, callback, options);
2198
2238
  }
2199
2239
  /**
2200
2240
  * Execute custom query with event emission
@@ -2238,6 +2278,56 @@ var Repository = class {
2238
2278
  return context;
2239
2279
  }
2240
2280
  /**
2281
+ * Resolve the effective sort for getAll. Handles $near conflict detection
2282
+ * (warns + drops explicit sort when $near is in filters).
2283
+ */
2284
+ _resolveSort(context, params, isNearQuery) {
2285
+ const explicitSort = context.sort ?? params.sort;
2286
+ if (isNearQuery) {
2287
+ if (explicitSort !== void 0) warn(`[mongokit] Repository "${this.model}" dropping explicit sort (${JSON.stringify(explicitSort)}) because the filter contains $near / $nearSphere. MongoDB forbids explicit sort with $near. Use [withinRadius] instead of [near] if you need a custom sort.`);
2288
+ return;
2289
+ }
2290
+ return explicitSort ?? "-createdAt";
2291
+ }
2292
+ /**
2293
+ * Detect whether to use keyset (cursor) or offset (page) pagination.
2294
+ */
2295
+ _detectPaginationMode(mode, page, after, sort, context, params) {
2296
+ if (mode) return mode === "keyset";
2297
+ return !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
2298
+ }
2299
+ /**
2300
+ * Build the MongoDB query filter with search merged in. Handles text
2301
+ * search ($text), regex search ($or of $regex), and the search-resolver
2302
+ * plugin contract (search already cleared by a before:getAll hook).
2303
+ */
2304
+ _buildSearchQuery(filters, search) {
2305
+ const query = { ...filters };
2306
+ if (!search) return query;
2307
+ if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
2308
+ let effectiveMode = this.searchMode === "regex" ? "regex" : "text";
2309
+ if (this.searchMode === "auto") effectiveMode = this._hasTextIndex ? "text" : "regex";
2310
+ if (effectiveMode === "regex") {
2311
+ if (!this.searchFields || this.searchFields.length === 0) throw createError(400, `Repository "${this.model}" configured with searchMode: '${this.searchMode}' but no searchFields provided.`);
2312
+ const escaped = String(search).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2313
+ const orConds = this.searchFields.map((field) => ({ [field]: {
2314
+ $regex: escaped,
2315
+ $options: "i"
2316
+ } }));
2317
+ if (Array.isArray(query.$or)) {
2318
+ const existingOr = query.$or;
2319
+ delete query.$or;
2320
+ query.$and = [
2321
+ ...Array.isArray(query.$and) ? query.$and : [],
2322
+ { $or: existingOr },
2323
+ { $or: orConds }
2324
+ ];
2325
+ } else query.$or = orConds;
2326
+ } else if (this._hasTextIndex) query.$text = { $search: search };
2327
+ else throw createError(400, `No text index found for ${this.model}. Cannot perform text search. Configure Repository with searchMode: 'regex' (and searchFields) or 'auto' to enable index-free search.`);
2328
+ return query;
2329
+ }
2330
+ /**
2241
2331
  * Parse sort string or object
2242
2332
  */
2243
2333
  _parseSort(sort) {
@@ -2758,39 +2848,73 @@ function cachePlugin(options) {
2758
2848
  //#region src/plugins/cascade.plugin.ts
2759
2849
  /**
2760
2850
  * Cascade Delete Plugin
2761
- * Automatically deletes related documents when a parent document is deleted
2762
2851
  *
2763
- * @example
2764
- * ```typescript
2765
- * import mongoose from 'mongoose';
2766
- * import { Repository, cascadePlugin, methodRegistryPlugin } from '@classytic/mongokit';
2852
+ * Automatically deletes related documents when a parent document is deleted.
2853
+ *
2854
+ * Two routing modes per relation:
2855
+ *
2856
+ * 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
2857
+ * Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
2858
+ * query, { mode })`, so the target's `before:delete` / `before:deleteMany`
2859
+ * hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
2860
+ * and the target's own `softDeletePlugin` (with its configured
2861
+ * `deletedField`) all run correctly.
2862
+ *
2863
+ * 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
2864
+ * writes directly via `mongoose.models[name].updateMany / deleteMany`,
2865
+ * **bypassing** the target's hooks. Safe only for trivial targets with
2866
+ * no policy plugins. Retained for backwards compatibility — prefer the
2867
+ * repo-routed form for new code.
2767
2868
  *
2869
+ * The parent's delete mode propagates: a hard-deleted parent cascades hard,
2870
+ * a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
2871
+ * the decision per-relation.
2872
+ *
2873
+ * @example Repo-routed (new)
2874
+ * ```ts
2768
2875
  * const productRepo = new Repository(Product, [
2769
2876
  * methodRegistryPlugin(),
2770
2877
  * cascadePlugin({
2771
2878
  * relations: [
2772
- * { model: 'StockEntry', foreignKey: 'product' },
2773
- * { model: 'StockMovement', foreignKey: 'product' },
2774
- * ]
2775
- * })
2879
+ * { repo: stockEntryRepo, foreignKey: 'product' },
2880
+ * { repo: stockMovementRepo, foreignKey: 'product' },
2881
+ * ],
2882
+ * }),
2776
2883
  * ]);
2884
+ * ```
2777
2885
  *
2778
- * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
2779
- * await productRepo.delete(productId);
2886
+ * @example Model-routed (legacy)
2887
+ * ```ts
2888
+ * cascadePlugin({
2889
+ * relations: [
2890
+ * { model: 'StockEntry', foreignKey: 'product' },
2891
+ * ],
2892
+ * });
2780
2893
  * ```
2781
2894
  */
2782
2895
  /**
2783
- * Cascade delete plugin
2784
- *
2785
- * Deletes related documents after the parent document is deleted.
2786
- * Works with both hard delete and soft delete scenarios.
2787
- *
2788
- * @param options - Cascade configuration
2789
- * @returns Plugin
2896
+ * Collect top-level parent-context fields that a target plugin (multi-tenant,
2897
+ * audit) will likely need. We can't know the target's contextKey, so we
2898
+ * forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
2899
+ * These are the conventions enforced by the built-in plugins.
2900
+ */
2901
+ function collectScopeForward(context) {
2902
+ const out = {};
2903
+ if (context.organizationId !== void 0) out.organizationId = context.organizationId;
2904
+ if (context.tenantId !== void 0) out.tenantId = context.tenantId;
2905
+ if (context.user !== void 0) out.user = context.user;
2906
+ return out;
2907
+ }
2908
+ /**
2909
+ * Cascade delete plugin.
2790
2910
  */
2791
2911
  function cascadePlugin(options) {
2792
2912
  const { relations, parallel = true, logger } = options;
2793
2913
  if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
2914
+ for (const rel of relations) {
2915
+ if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
2916
+ if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
2917
+ }
2794
2918
  return {
2795
2919
  name: "cascade",
2796
2920
  apply(repo) {
@@ -2801,59 +2925,32 @@ function cascadePlugin(options) {
2801
2925
  logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
2802
2926
  return;
2803
2927
  }
2804
- const isSoftDelete = context.softDeleted === true;
2805
- const cascadeDelete = async (relation) => {
2806
- const RelatedModel = mongoose.models[relation.model];
2807
- if (!RelatedModel) {
2808
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2809
- parentModel: context.model,
2810
- parentId: String(deletedId)
2811
- });
2812
- return;
2813
- }
2814
- const query = { [relation.foreignKey]: deletedId };
2928
+ const isSoftParent = context.softDeleted === true;
2929
+ const scopeForward = collectScopeForward(context);
2930
+ const cascadeOne = async (relation) => {
2931
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
2932
+ const cascadeCtx = {
2933
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
2934
+ session: context.session,
2935
+ shouldSoftDelete,
2936
+ user: context.user,
2937
+ scopeForward
2938
+ };
2815
2939
  try {
2816
- if (relation.softDelete ?? isSoftDelete) {
2817
- const updateResult = await RelatedModel.updateMany(query, {
2818
- deletedAt: /* @__PURE__ */ new Date(),
2819
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2820
- }, { session: context.session });
2821
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2822
- parentModel: context.model,
2823
- parentId: String(deletedId),
2824
- relatedModel: relation.model,
2825
- foreignKey: relation.foreignKey,
2826
- count: updateResult.modifiedCount
2827
- });
2828
- } else {
2829
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2830
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2831
- parentModel: context.model,
2832
- parentId: String(deletedId),
2833
- relatedModel: relation.model,
2834
- foreignKey: relation.foreignKey,
2835
- count: deleteResult.deletedCount
2836
- });
2837
- }
2940
+ if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
2941
+ else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
2838
2942
  } catch (error) {
2839
- logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2943
+ logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
2840
2944
  parentModel: context.model,
2841
2945
  parentId: String(deletedId),
2842
- relatedModel: relation.model,
2946
+ relatedModel: cascadeCtx.targetModelName,
2843
2947
  foreignKey: relation.foreignKey,
2844
2948
  error: error.message
2845
2949
  });
2846
2950
  throw error;
2847
2951
  }
2848
2952
  };
2849
- if (parallel) {
2850
- const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
2851
- if (failures.length) {
2852
- const err = failures[0].reason;
2853
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2854
- throw err;
2855
- }
2856
- } else for (const relation of relations) await cascadeDelete(relation);
2953
+ await runCascades(relations, cascadeOne, parallel);
2857
2954
  });
2858
2955
  repo.on("before:deleteMany", async (context) => {
2859
2956
  const query = context.query;
@@ -2865,60 +2962,106 @@ function cascadePlugin(options) {
2865
2962
  const { context } = payload;
2866
2963
  const ids = context._cascadeIds;
2867
2964
  if (!ids || ids.length === 0) return;
2868
- const isSoftDelete = context.softDeleted === true;
2869
- const cascadeDeleteMany = async (relation) => {
2870
- const RelatedModel = mongoose.models[relation.model];
2871
- if (!RelatedModel) {
2872
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
2873
- return;
2874
- }
2875
- const query = { [relation.foreignKey]: { $in: ids } };
2876
- const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
2965
+ const isSoftParent = context.softDeleted === true;
2966
+ const scopeForward = collectScopeForward(context);
2967
+ const cascadeBulk = async (relation) => {
2968
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
2969
+ const cascadeCtx = {
2970
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
2971
+ session: context.session,
2972
+ shouldSoftDelete,
2973
+ user: context.user,
2974
+ scopeForward
2975
+ };
2877
2976
  try {
2878
- if (shouldSoftDelete) {
2879
- const updateResult = await RelatedModel.updateMany(query, {
2880
- deletedAt: /* @__PURE__ */ new Date(),
2881
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2882
- }, { session: context.session });
2883
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2884
- parentModel: context.model,
2885
- parentCount: ids.length,
2886
- relatedModel: relation.model,
2887
- foreignKey: relation.foreignKey,
2888
- count: updateResult.modifiedCount
2889
- });
2890
- } else {
2891
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2892
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2893
- parentModel: context.model,
2894
- parentCount: ids.length,
2895
- relatedModel: relation.model,
2896
- foreignKey: relation.foreignKey,
2897
- count: deleteResult.deletedCount
2898
- });
2899
- }
2977
+ if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
2978
+ else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
2900
2979
  } catch (error) {
2901
- logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2980
+ logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
2902
2981
  parentModel: context.model,
2903
- relatedModel: relation.model,
2982
+ relatedModel: cascadeCtx.targetModelName,
2904
2983
  foreignKey: relation.foreignKey,
2905
2984
  error: error.message
2906
2985
  });
2907
2986
  throw error;
2908
2987
  }
2909
2988
  };
2910
- if (parallel) {
2911
- const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
2912
- if (failures.length) {
2913
- const err = failures[0].reason;
2914
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2915
- throw err;
2916
- }
2917
- } else for (const relation of relations) await cascadeDeleteMany(relation);
2989
+ await runCascades(relations, cascadeBulk, parallel);
2918
2990
  });
2919
2991
  }
2920
2992
  };
2921
2993
  }
2994
+ /**
2995
+ * Repo-routed cascade for a single parent delete.
2996
+ * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
2997
+ * hooks (multi-tenant, audit, target's own soft-delete plugin).
2998
+ */
2999
+ async function cascadeViaRepoBulk(relation, parentId, ctx) {
3000
+ const targetRepo = relation.repo;
3001
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Wire batchOperationsPlugin() + methodRegistryPlugin() on the target repo.`);
3002
+ await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
3003
+ ...ctx.scopeForward,
3004
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
3005
+ session: ctx.session
3006
+ });
3007
+ }
3008
+ /**
3009
+ * Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
3010
+ */
3011
+ async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
3012
+ const targetRepo = relation.repo;
3013
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Wire batchOperationsPlugin() + methodRegistryPlugin() on the target repo.`);
3014
+ await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
3015
+ ...ctx.scopeForward,
3016
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
3017
+ session: ctx.session
3018
+ });
3019
+ }
3020
+ /**
3021
+ * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
3022
+ * Retained for backwards compatibility with existing cascadePlugin consumers.
3023
+ */
3024
+ async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
3025
+ const RelatedModel = mongoose.models[relation.model];
3026
+ if (!RelatedModel) {
3027
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
3028
+ return;
3029
+ }
3030
+ const query = { [relation.foreignKey]: parentId };
3031
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
3032
+ deletedAt: /* @__PURE__ */ new Date(),
3033
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
3034
+ }, { session: ctx.session });
3035
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
3036
+ }
3037
+ async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
3038
+ const RelatedModel = mongoose.models[relation.model];
3039
+ if (!RelatedModel) {
3040
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
3041
+ return;
3042
+ }
3043
+ const query = { [relation.foreignKey]: { $in: parentIds } };
3044
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
3045
+ deletedAt: /* @__PURE__ */ new Date(),
3046
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
3047
+ }, { session: ctx.session });
3048
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
3049
+ }
3050
+ /**
3051
+ * Execute a list of cascade operations, honoring the `parallel` flag.
3052
+ * Uses `allSettled` so one failure doesn't abort siblings; throws the first
3053
+ * rejection (with a composite message if several failed) after all complete.
3054
+ */
3055
+ async function runCascades(relations, fn, parallel) {
3056
+ if (parallel) {
3057
+ const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
3058
+ if (failures.length) {
3059
+ const err = failures[0].reason;
3060
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
3061
+ throw err;
3062
+ }
3063
+ } else for (const relation of relations) await fn(relation);
3064
+ }
2922
3065
  //#endregion
2923
3066
  //#region src/plugins/custom-id.plugin.ts
2924
3067
  /**
@@ -3813,6 +3956,7 @@ function softDeletePlugin(options = {}) {
3813
3956
  });
3814
3957
  }
3815
3958
  repo.on("before:delete", async (context) => {
3959
+ if (context.deleteMode === "hard") return;
3816
3960
  if (options.soft !== false) {
3817
3961
  const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
3818
3962
  if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
@@ -3872,6 +4016,7 @@ function softDeletePlugin(options = {}) {
3872
4016
  }
3873
4017
  }, { priority: HOOK_PRIORITY.POLICY });
3874
4018
  repo.on("before:deleteMany", async (context) => {
4019
+ if (context.deleteMode === "hard") return;
3875
4020
  if (options.soft !== false) {
3876
4021
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
3877
4022
  const finalQuery = {
@@ -3924,9 +4069,8 @@ function softDeletePlugin(options = {}) {
3924
4069
  throw error;
3925
4070
  }
3926
4071
  await this.emitAsync("after:restore", {
3927
- id,
3928
- result,
3929
- context
4072
+ context,
4073
+ result
3930
4074
  });
3931
4075
  return result;
3932
4076
  };
@@ -4230,4 +4374,4 @@ function uniqueField(field, errorMessage) {
4230
4374
  };
4231
4375
  }
4232
4376
  //#endregion
4233
- export { aggregateHelpersPlugin as A, HOOK_PRIORITY as C, AuditTrailQuery as D, batchOperationsPlugin as E, auditTrailPlugin as O, cachePlugin as S, AggregationBuilder 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 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 };
4377
+ 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 };
@@ -1,4 +1,4 @@
1
- import { I as Logger, J as Plugin, St as ValidatorDefinition, T as FieldPreset, U as OffsetPaginationResult, V as ObjectId, Z as PopulateSpec, bt as ValidationChainOptions, et as RepositoryContext, f as CascadeOptions, l as CacheOptions, nt as RepositoryInstance, pt as SortSpec, st as SelectSpec, u as CacheStats, ut as SoftDeleteOptions } from "./types-CWUL0yiw.mjs";
1
+ import { I as Logger, J as Plugin, St as ValidatorDefinition, T as FieldPreset, U as OffsetPaginationResult, V as ObjectId, Z as PopulateSpec, bt as ValidationChainOptions, et as RepositoryContext, f as CascadeOptions, l as CacheOptions, nt as RepositoryInstance, pt as SortSpec, st as SelectSpec, u as CacheStats, ut as SoftDeleteOptions } from "./types-DiQfDesD.mjs";
2
2
  import mongoose, { ClientSession } from "mongoose";
3
3
 
4
4
  //#region src/plugins/aggregate-helpers.plugin.d.ts
@@ -307,12 +307,21 @@ interface BatchOperationsMethods {
307
307
  upsertedId: unknown;
308
308
  }>;
309
309
  /**
310
- * Delete multiple documents matching the query
311
- * @param query - Query to match documents
312
- * @param options - Operation options
310
+ * Delete multiple documents matching the query.
311
+ *
312
+ * Defaults to soft delete when `softDeletePlugin` is wired, physical delete
313
+ * otherwise. Pass `{ mode: 'hard' }` for GDPR / admin cleanup — multi-tenant
314
+ * scoping and audit hooks still fire.
315
+ *
316
+ * @param query - Query to match documents (must be non-empty)
317
+ * @param options - Operation options (session, mode, tenant keys)
313
318
  * @returns Delete result with deletedCount
314
319
  */
315
- deleteMany(query: Record<string, unknown>, options?: Record<string, unknown>): Promise<{
320
+ deleteMany(query: Record<string, unknown>, options?: {
321
+ session?: ClientSession;
322
+ mode?: 'hard' | 'soft';
323
+ [key: string]: unknown;
324
+ }): Promise<{
316
325
  acknowledged: boolean;
317
326
  deletedCount: number;
318
327
  }>;
@@ -398,13 +407,7 @@ interface CacheMethods {
398
407
  //#endregion
399
408
  //#region src/plugins/cascade.plugin.d.ts
400
409
  /**
401
- * Cascade delete plugin
402
- *
403
- * Deletes related documents after the parent document is deleted.
404
- * Works with both hard delete and soft delete scenarios.
405
- *
406
- * @param options - Cascade configuration
407
- * @returns Plugin
410
+ * Cascade delete plugin.
408
411
  */
409
412
  declare function cascadePlugin(options: CascadeOptions): Plugin;
410
413
  //#endregion