@classytic/mongokit 3.5.6 → 3.6.1

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,6 +1,6 @@
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
5
  import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-LZ-cCwR5.mjs";
6
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";
@@ -583,12 +583,23 @@ function batchOperationsPlugin() {
583
583
  }
584
584
  });
585
585
  /**
586
- * 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.
587
595
  */
588
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;
589
599
  const context = await this._buildContext("deleteMany", {
590
600
  query,
591
- ...options
601
+ ...options,
602
+ ...mode ? { deleteMode: mode } : {}
592
603
  });
593
604
  try {
594
605
  if (context.softDeleted) {
@@ -603,7 +614,7 @@ function batchOperationsPlugin() {
603
614
  return result;
604
615
  }
605
616
  const finalQuery = context.query || query;
606
- 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.");
607
618
  const result = await this.Model.deleteMany(finalQuery, { session: options.session }).exec();
608
619
  await this.emitAsync("after:deleteMany", {
609
620
  context,
@@ -1154,6 +1165,60 @@ var AggregationBuilder = class AggregationBuilder {
1154
1165
  * ```
1155
1166
  */
1156
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
1157
1222
  //#region src/Repository.ts
1158
1223
  function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1159
1224
  if (!projection || !sort) return projection;
@@ -1770,12 +1835,31 @@ var Repository = class {
1770
1835
  }
1771
1836
  }
1772
1837
  /**
1773
- * 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
+ * ```
1774
1857
  */
1775
1858
  async delete(id, options = {}) {
1776
1859
  const context = await this._buildContext("delete", {
1777
1860
  id,
1778
- ...options
1861
+ ...options,
1862
+ ...options.mode ? { deleteMode: options.mode } : {}
1779
1863
  });
1780
1864
  try {
1781
1865
  if (context.softDeleted) {
@@ -2150,25 +2234,7 @@ var Repository = class {
2150
2234
  * ```
2151
2235
  */
2152
2236
  async withTransaction(callback, options = {}) {
2153
- const session = await this.Model.db.startSession();
2154
- try {
2155
- return await session.withTransaction(() => callback(session), options.transactionOptions);
2156
- } catch (error) {
2157
- const err = error;
2158
- if (options.allowFallback && this._isTransactionUnsupported(err)) {
2159
- options.onFallback?.(err);
2160
- return await callback(session);
2161
- }
2162
- throw err;
2163
- } finally {
2164
- await session.endSession();
2165
- }
2166
- }
2167
- _isTransactionUnsupported(error) {
2168
- const code = error.code;
2169
- if (code === 263 || code === 20) return true;
2170
- const message = (error.message || "").toLowerCase();
2171
- 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);
2172
2238
  }
2173
2239
  /**
2174
2240
  * Execute custom query with event emission
@@ -2782,39 +2848,73 @@ function cachePlugin(options) {
2782
2848
  //#region src/plugins/cascade.plugin.ts
2783
2849
  /**
2784
2850
  * Cascade Delete Plugin
2785
- * Automatically deletes related documents when a parent document is deleted
2786
2851
  *
2787
- * @example
2788
- * ```typescript
2789
- * import mongoose from 'mongoose';
2790
- * 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.
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.
2791
2872
  *
2873
+ * @example Repo-routed (new)
2874
+ * ```ts
2792
2875
  * const productRepo = new Repository(Product, [
2793
2876
  * methodRegistryPlugin(),
2794
2877
  * cascadePlugin({
2795
2878
  * relations: [
2796
- * { model: 'StockEntry', foreignKey: 'product' },
2797
- * { model: 'StockMovement', foreignKey: 'product' },
2798
- * ]
2799
- * })
2879
+ * { repo: stockEntryRepo, foreignKey: 'product' },
2880
+ * { repo: stockMovementRepo, foreignKey: 'product' },
2881
+ * ],
2882
+ * }),
2800
2883
  * ]);
2884
+ * ```
2801
2885
  *
2802
- * // When a product is deleted, all related StockEntry and StockMovement docs are also deleted
2803
- * await productRepo.delete(productId);
2886
+ * @example Model-routed (legacy)
2887
+ * ```ts
2888
+ * cascadePlugin({
2889
+ * relations: [
2890
+ * { model: 'StockEntry', foreignKey: 'product' },
2891
+ * ],
2892
+ * });
2804
2893
  * ```
2805
2894
  */
2806
2895
  /**
2807
- * Cascade delete plugin
2808
- *
2809
- * Deletes related documents after the parent document is deleted.
2810
- * Works with both hard delete and soft delete scenarios.
2811
- *
2812
- * @param options - Cascade configuration
2813
- * @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.
2814
2910
  */
2815
2911
  function cascadePlugin(options) {
2816
2912
  const { relations, parallel = true, logger } = options;
2817
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
+ }
2818
2918
  return {
2819
2919
  name: "cascade",
2820
2920
  apply(repo) {
@@ -2825,59 +2925,32 @@ function cascadePlugin(options) {
2825
2925
  logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
2826
2926
  return;
2827
2927
  }
2828
- const isSoftDelete = context.softDeleted === true;
2829
- const cascadeDelete = async (relation) => {
2830
- const RelatedModel = mongoose.models[relation.model];
2831
- if (!RelatedModel) {
2832
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
2833
- parentModel: context.model,
2834
- parentId: String(deletedId)
2835
- });
2836
- return;
2837
- }
2838
- 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
+ };
2839
2939
  try {
2840
- if (relation.softDelete ?? isSoftDelete) {
2841
- const updateResult = await RelatedModel.updateMany(query, {
2842
- deletedAt: /* @__PURE__ */ new Date(),
2843
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2844
- }, { session: context.session });
2845
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
2846
- parentModel: context.model,
2847
- parentId: String(deletedId),
2848
- relatedModel: relation.model,
2849
- foreignKey: relation.foreignKey,
2850
- count: updateResult.modifiedCount
2851
- });
2852
- } else {
2853
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2854
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
2855
- parentModel: context.model,
2856
- parentId: String(deletedId),
2857
- relatedModel: relation.model,
2858
- foreignKey: relation.foreignKey,
2859
- count: deleteResult.deletedCount
2860
- });
2861
- }
2940
+ if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
2941
+ else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
2862
2942
  } catch (error) {
2863
- logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
2943
+ logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
2864
2944
  parentModel: context.model,
2865
2945
  parentId: String(deletedId),
2866
- relatedModel: relation.model,
2946
+ relatedModel: cascadeCtx.targetModelName,
2867
2947
  foreignKey: relation.foreignKey,
2868
2948
  error: error.message
2869
2949
  });
2870
2950
  throw error;
2871
2951
  }
2872
2952
  };
2873
- if (parallel) {
2874
- const failures = (await Promise.allSettled(relations.map(cascadeDelete))).filter((r) => r.status === "rejected");
2875
- if (failures.length) {
2876
- const err = failures[0].reason;
2877
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2878
- throw err;
2879
- }
2880
- } else for (const relation of relations) await cascadeDelete(relation);
2953
+ await runCascades(relations, cascadeOne, parallel);
2881
2954
  });
2882
2955
  repo.on("before:deleteMany", async (context) => {
2883
2956
  const query = context.query;
@@ -2889,60 +2962,106 @@ function cascadePlugin(options) {
2889
2962
  const { context } = payload;
2890
2963
  const ids = context._cascadeIds;
2891
2964
  if (!ids || ids.length === 0) return;
2892
- const isSoftDelete = context.softDeleted === true;
2893
- const cascadeDeleteMany = async (relation) => {
2894
- const RelatedModel = mongoose.models[relation.model];
2895
- if (!RelatedModel) {
2896
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, { parentModel: context.model });
2897
- return;
2898
- }
2899
- const query = { [relation.foreignKey]: { $in: ids } };
2900
- 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
+ };
2901
2976
  try {
2902
- if (shouldSoftDelete) {
2903
- const updateResult = await RelatedModel.updateMany(query, {
2904
- deletedAt: /* @__PURE__ */ new Date(),
2905
- ...context.user ? { deletedBy: context.user._id || context.user.id } : {}
2906
- }, { session: context.session });
2907
- logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
2908
- parentModel: context.model,
2909
- parentCount: ids.length,
2910
- relatedModel: relation.model,
2911
- foreignKey: relation.foreignKey,
2912
- count: updateResult.modifiedCount
2913
- });
2914
- } else {
2915
- const deleteResult = await RelatedModel.deleteMany(query, { session: context.session });
2916
- logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
2917
- parentModel: context.model,
2918
- parentCount: ids.length,
2919
- relatedModel: relation.model,
2920
- foreignKey: relation.foreignKey,
2921
- count: deleteResult.deletedCount
2922
- });
2923
- }
2977
+ if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
2978
+ else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
2924
2979
  } catch (error) {
2925
- logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
2980
+ logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
2926
2981
  parentModel: context.model,
2927
- relatedModel: relation.model,
2982
+ relatedModel: cascadeCtx.targetModelName,
2928
2983
  foreignKey: relation.foreignKey,
2929
2984
  error: error.message
2930
2985
  });
2931
2986
  throw error;
2932
2987
  }
2933
2988
  };
2934
- if (parallel) {
2935
- const failures = (await Promise.allSettled(relations.map(cascadeDeleteMany))).filter((r) => r.status === "rejected");
2936
- if (failures.length) {
2937
- const err = failures[0].reason;
2938
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
2939
- throw err;
2940
- }
2941
- } else for (const relation of relations) await cascadeDeleteMany(relation);
2989
+ await runCascades(relations, cascadeBulk, parallel);
2942
2990
  });
2943
2991
  }
2944
2992
  };
2945
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
+ }
2946
3065
  //#endregion
2947
3066
  //#region src/plugins/custom-id.plugin.ts
2948
3067
  /**
@@ -3587,7 +3706,7 @@ function mongoOperationsPlugin() {
3587
3706
  * ```
3588
3707
  */
3589
3708
  function multiTenantPlugin(options = {}) {
3590
- const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext } = options;
3709
+ const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string" } = options;
3591
3710
  const filterOps = [
3592
3711
  "getAll",
3593
3712
  "findAll",
@@ -3636,25 +3755,26 @@ function multiTenantPlugin(options = {}) {
3636
3755
  }
3637
3756
  if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false.`);
3638
3757
  if (!tenantId) return;
3758
+ const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
3639
3759
  if (filterOps.includes(op) || filterReadOps.includes(op)) context.filters = {
3640
3760
  ...context.filters,
3641
- [tenantField]: tenantId
3761
+ [tenantField]: castId
3642
3762
  };
3643
3763
  if (queryReadOps.includes(op)) context.query = {
3644
3764
  ...context.query,
3645
- [tenantField]: tenantId
3765
+ [tenantField]: castId
3646
3766
  };
3647
- if (op === "create" && context.data) context.data[tenantField] = tenantId;
3767
+ if (op === "create" && context.data) context.data[tenantField] = castId;
3648
3768
  if (op === "createMany" && context.dataArray) {
3649
- for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = tenantId;
3769
+ for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
3650
3770
  }
3651
3771
  if (constrainedWriteOps.includes(op)) context.query = {
3652
3772
  ...context.query,
3653
- [tenantField]: tenantId
3773
+ [tenantField]: castId
3654
3774
  };
3655
3775
  if (batchQueryOps.includes(op)) context.query = {
3656
3776
  ...context.query,
3657
- [tenantField]: tenantId
3777
+ [tenantField]: castId
3658
3778
  };
3659
3779
  if (op === "bulkWrite" && context.operations) {
3660
3780
  const ops = context.operations;
@@ -3669,11 +3789,11 @@ function multiTenantPlugin(options = {}) {
3669
3789
  const opBody = subOp[key];
3670
3790
  if (opBody?.filter) opBody.filter = {
3671
3791
  ...opBody.filter,
3672
- [tenantField]: tenantId
3792
+ [tenantField]: castId
3673
3793
  };
3674
3794
  }
3675
3795
  const insertBody = subOp.insertOne;
3676
- if (insertBody?.document) insertBody.document[tenantField] = tenantId;
3796
+ if (insertBody?.document) insertBody.document[tenantField] = castId;
3677
3797
  }
3678
3798
  }
3679
3799
  }, { priority: HOOK_PRIORITY.POLICY });
@@ -3837,6 +3957,7 @@ function softDeletePlugin(options = {}) {
3837
3957
  });
3838
3958
  }
3839
3959
  repo.on("before:delete", async (context) => {
3960
+ if (context.deleteMode === "hard") return;
3840
3961
  if (options.soft !== false) {
3841
3962
  const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
3842
3963
  if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
@@ -3896,6 +4017,7 @@ function softDeletePlugin(options = {}) {
3896
4017
  }
3897
4018
  }, { priority: HOOK_PRIORITY.POLICY });
3898
4019
  repo.on("before:deleteMany", async (context) => {
4020
+ if (context.deleteMode === "hard") return;
3899
4021
  if (options.soft !== false) {
3900
4022
  const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
3901
4023
  const finalQuery = {
@@ -3948,9 +4070,8 @@ function softDeletePlugin(options = {}) {
3948
4070
  throw error;
3949
4071
  }
3950
4072
  await this.emitAsync("after:restore", {
3951
- id,
3952
- result,
3953
- context
4073
+ context,
4074
+ result
3954
4075
  });
3955
4076
  return result;
3956
4077
  };
@@ -4254,4 +4375,4 @@ function uniqueField(field, errorMessage) {
4254
4375
  };
4255
4376
  }
4256
4377
  //#endregion
4257
- 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 };
4378
+ 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-BwO4u3JN.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
@@ -832,6 +835,17 @@ interface MultiTenantOptions {
832
835
  * ```
833
836
  */
834
837
  resolveContext?: () => string | undefined;
838
+ /**
839
+ * How to store/query the tenant ID.
840
+ *
841
+ * - `'string'` (default): inject the tenant ID as-is (raw string).
842
+ * - `'objectId'`: cast to `mongoose.Types.ObjectId` before injecting.
843
+ *
844
+ * Choose `'objectId'` when the schema declares the tenant field as
845
+ * `Schema.Types.ObjectId` (e.g., Flow, accounting). This enables
846
+ * `$lookup` and `.populate()` against the referenced collection.
847
+ */
848
+ fieldType?: 'string' | 'objectId';
835
849
  }
836
850
  declare function multiTenantPlugin(options?: MultiTenantOptions): Plugin;
837
851
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.5.6",
3
+ "version": "3.6.1",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,