@classytic/mongokit 3.14.0 → 3.16.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 (28) hide show
  1. package/README.md +193 -319
  2. package/dist/{PaginationEngine-CfQRM1yz.mjs → PaginationEngine-0XXTW-9b.mjs} +2 -2
  3. package/dist/{Repository-BGL1Pq8w.mjs → Repository-DE7D2QeS.mjs} +240 -19
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/ai/index.mjs +14 -5
  7. package/dist/better-auth/index.d.mts +55 -1
  8. package/dist/better-auth/index.mjs +32 -2
  9. package/dist/{index-Dccha9dw.d.mts → index-CV5vv-8E.d.mts} +1 -1
  10. package/dist/index.d.mts +1182 -1079
  11. package/dist/index.mjs +32 -19
  12. package/dist/{mongooseToJsonSchema-BYbG894y.mjs → mongooseToJsonSchema-Bi0q0pYW.mjs} +1 -1
  13. package/dist/{mongooseToJsonSchema-BgRAaXX-.d.mts → mongooseToJsonSchema-GhvZUjRO.d.mts} +1 -1
  14. package/dist/pagination/PaginationEngine.d.mts +1 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +1 -1
  17. package/dist/plugins/index.mjs +1 -1
  18. package/dist/query/primitives/coercion.mjs +1 -1
  19. package/dist/{types-BbaPZleN.d.mts → types-BWZILovv.d.mts} +26 -1
  20. package/dist/utils/index.d.mts +1 -1
  21. package/dist/utils/index.mjs +3 -3
  22. package/dist/{validation-chain.plugin-CWhvBRej.mjs → validation-chain.plugin-Crl5nQAY.mjs} +1172 -1137
  23. package/dist/{validation-chain.plugin-CAPYCj_T.d.mts → validation-chain.plugin-Iee2_puD.d.mts} +368 -344
  24. package/package.json +6 -6
  25. /package/dist/{cursor-J84rR6wG.mjs → cursor-4LMYa4ga.mjs} +0 -0
  26. /package/dist/{field-selection-Br3WPOEb.mjs → field-selection-CQW3WBiK.mjs} +0 -0
  27. /package/dist/{id-resolution-B0Odvvou.mjs → id-resolution-DduoO4LI.mjs} +0 -0
  28. /package/dist/{sort-CrZjTK25.mjs → sort-RPVz5u0w.mjs} +0 -0
@@ -1,76 +1,13 @@
1
1
  import { _ as upsert } from "./update-BXMnCvO6.mjs";
2
2
  import { n as warn } from "./logger-DprijP5h.mjs";
3
3
  import { t as createError } from "./error-B00pI7dV.mjs";
4
- import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-BGL1Pq8w.mjs";
5
- import { r as getFieldsForUser } from "./field-selection-Br3WPOEb.mjs";
4
+ import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-DE7D2QeS.mjs";
5
+ import { r as getFieldsForUser } from "./field-selection-CQW3WBiK.mjs";
6
6
  import mongoose from "mongoose";
7
7
  import { cachePlugin } from "@classytic/repo-core/cache";
8
8
  import { adminBypass, payloadHasTenantField } from "@classytic/repo-core/plugins";
9
+ import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
9
10
  import { AsyncLocalStorage } from "node:async_hooks";
10
- //#region src/plugins/aggregate-helpers.plugin.ts
11
- /**
12
- * Aggregate helpers plugin
13
- *
14
- * @example
15
- * const repo = new Repository(Model, [
16
- * methodRegistryPlugin(),
17
- * aggregateHelpersPlugin(),
18
- * ]);
19
- *
20
- * const groups = await repo.groupBy('category');
21
- * const total = await repo.sum('amount', { status: 'completed' });
22
- */
23
- function aggregateHelpersPlugin() {
24
- return {
25
- name: "aggregate-helpers",
26
- apply(repo) {
27
- if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
28
- /**
29
- * Group by field
30
- */
31
- repo.registerMethod("groupBy", async function(field, options = {}) {
32
- const pipeline = [{ $group: {
33
- _id: `$${field}`,
34
- count: { $sum: 1 }
35
- } }, { $sort: { count: -1 } }];
36
- if (options.limit) pipeline.push({ $limit: options.limit });
37
- return this.aggregatePipeline(pipeline, options);
38
- });
39
- const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
40
- const pipeline = [{ $match: query }, { $group: {
41
- _id: null,
42
- [resultKey]: { [operator]: `$${field}` }
43
- } }];
44
- return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
45
- };
46
- /**
47
- * Sum field values
48
- */
49
- repo.registerMethod("sum", async function(field, query = {}, options = {}) {
50
- return aggregateOperation.call(this, field, "$sum", "total", query, options);
51
- });
52
- /**
53
- * Average field values
54
- */
55
- repo.registerMethod("average", async function(field, query = {}, options = {}) {
56
- return aggregateOperation.call(this, field, "$avg", "avg", query, options);
57
- });
58
- /**
59
- * Get minimum value
60
- */
61
- repo.registerMethod("min", async function(field, query = {}, options = {}) {
62
- return aggregateOperation.call(this, field, "$min", "min", query, options);
63
- });
64
- /**
65
- * Get maximum value
66
- */
67
- repo.registerMethod("max", async function(field, query = {}, options = {}) {
68
- return aggregateOperation.call(this, field, "$max", "max", query, options);
69
- });
70
- }
71
- };
72
- }
73
- //#endregion
74
11
  //#region src/plugins/audit-log.plugin.ts
75
12
  /**
76
13
  * Audit log plugin that logs all repository operations
@@ -628,496 +565,1120 @@ function batchOperationsPlugin() {
628
565
  };
629
566
  }
630
567
  //#endregion
631
- //#region src/plugins/cascade.plugin.ts
568
+ //#region src/plugins/custom-id.plugin.ts
632
569
  /**
633
- * Cascade Delete Plugin
570
+ * Custom ID Plugin
634
571
  *
635
- * Automatically deletes related documents when a parent document is deleted.
572
+ * Generates custom document IDs using pluggable generators.
573
+ * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
574
+ * date-partitioned sequences, and fully custom generators.
636
575
  *
637
- * Two routing modes per relation:
576
+ * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
577
+ * counters collection — guaranteeing no duplicate IDs under concurrency.
638
578
  *
639
- * 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
640
- * Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
641
- * query, { mode })`, so the target's `before:delete` / `before:deleteMany`
642
- * hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
643
- * and the target's own `softDeletePlugin` (with its configured
644
- * `deletedField`) all run correctly.
579
+ * @example Basic sequential counter
580
+ * ```typescript
581
+ * const invoiceRepo = new Repository(InvoiceModel, [
582
+ * customIdPlugin({
583
+ * field: 'invoiceNumber',
584
+ * generator: sequentialId({
585
+ * prefix: 'INV',
586
+ * model: InvoiceModel,
587
+ * }),
588
+ * }),
589
+ * ]);
645
590
  *
646
- * 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
647
- * writes directly via `mongoose.models[name].updateMany / deleteMany`,
648
- * **bypassing** the target's hooks. Safe only for trivial targets with
649
- * no policy plugins. Retained for backwards compatibility — prefer the
650
- * repo-routed form for new code.
591
+ * const inv = await invoiceRepo.create({ amount: 100 });
592
+ * // inv.invoiceNumber → "INV-0001"
593
+ * ```
651
594
  *
652
- * The parent's delete mode propagates: a hard-deleted parent cascades hard,
653
- * a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
654
- * the decision per-relation.
595
+ * @example Date-partitioned counter (resets monthly)
596
+ * ```typescript
597
+ * const billRepo = new Repository(BillModel, [
598
+ * customIdPlugin({
599
+ * field: 'billNumber',
600
+ * generator: dateSequentialId({
601
+ * prefix: 'BILL',
602
+ * model: BillModel,
603
+ * partition: 'monthly',
604
+ * separator: '-',
605
+ * padding: 4,
606
+ * }),
607
+ * }),
608
+ * ]);
655
609
  *
656
- * @example Repo-routed (new)
657
- * ```ts
658
- * const productRepo = new Repository(Product, [
659
- * methodRegistryPlugin(),
660
- * cascadePlugin({
661
- * relations: [
662
- * { repo: stockEntryRepo, foreignKey: 'product' },
663
- * { repo: stockMovementRepo, foreignKey: 'product' },
664
- * ],
610
+ * const bill = await billRepo.create({ total: 250 });
611
+ * // bill.billNumber → "BILL-2026-02-0001"
612
+ * ```
613
+ *
614
+ * @example Custom generator function
615
+ * ```typescript
616
+ * const orderRepo = new Repository(OrderModel, [
617
+ * customIdPlugin({
618
+ * field: 'orderRef',
619
+ * generator: async (context) => {
620
+ * const region = context.data?.region || 'US';
621
+ * const seq = await getNextSequence('orders');
622
+ * return `ORD-${region}-${seq}`;
623
+ * },
665
624
  * }),
666
625
  * ]);
667
626
  * ```
627
+ */
628
+ /** Schema for the internal counters collection */
629
+ const counterSchema = new mongoose.Schema({
630
+ _id: {
631
+ type: String,
632
+ required: true
633
+ },
634
+ seq: {
635
+ type: Number,
636
+ default: 0
637
+ }
638
+ }, {
639
+ collection: "_mongokit_counters",
640
+ versionKey: false
641
+ });
642
+ /**
643
+ * Get or create the Counter model on the given connection.
644
+ * Falls back to the default mongoose connection if none is provided.
645
+ * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
646
+ */
647
+ function getCounterModel(connection) {
648
+ const conn = connection ?? mongoose.connection;
649
+ if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
650
+ return conn.model("_MongoKitCounter", counterSchema);
651
+ }
652
+ /**
653
+ * Atomically increment and return the next sequence value for a given key.
654
+ * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
655
+ * heavy concurrency.
668
656
  *
669
- * @example Model-routed (legacy)
670
- * ```ts
671
- * cascadePlugin({
672
- * relations: [
673
- * { model: 'StockEntry', foreignKey: 'product' },
674
- * ],
657
+ * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
658
+ * @param increment - Value to increment by (default: 1)
659
+ * @param connection - Mongoose connection to use (defaults to the default connection)
660
+ * @param session - Optional client session. When provided, the counter bump
661
+ * participates in the caller's transaction — if the surrounding transaction
662
+ * aborts, the counter does NOT advance. Without a session, the bump commits
663
+ * immediately and is not rolled back if a sibling write fails.
664
+ * @returns The next sequence number (after increment)
665
+ *
666
+ * @example
667
+ * const seq = await getNextSequence('invoices');
668
+ * // First call → 1, second → 2, ...
669
+ *
670
+ * @example Batch increment for createMany
671
+ * const startSeq = await getNextSequence('invoices', 5);
672
+ * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
673
+ *
674
+ * @example Transactional counter bump
675
+ * await withTransaction(conn, async (session) => {
676
+ * const seq = await getNextSequence('invoices', 1, conn, session);
677
+ * await invoiceRepo.create({ invoiceNumber: `INV-${seq}` }, { session });
678
+ * // if this throws, the counter is rolled back with the insert
675
679
  * });
676
- * ```
677
680
  */
681
+ async function getNextSequence(counterKey, increment = 1, connection, session) {
682
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
683
+ upsert: true,
684
+ returnDocument: "after",
685
+ session
686
+ });
687
+ if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
688
+ return result.seq;
689
+ }
678
690
  /**
679
- * Collect top-level parent-context fields that a target plugin (multi-tenant,
680
- * audit) will likely need. We can't know the target's contextKey, so we
681
- * forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
682
- * These are the conventions enforced by the built-in plugins.
691
+ * Generator: Simple sequential counter.
692
+ * Produces IDs like `INV-0001`, `INV-0002`, etc.
693
+ *
694
+ * Uses atomic MongoDB counters — safe under concurrency.
695
+ *
696
+ * @example
697
+ * ```typescript
698
+ * customIdPlugin({
699
+ * field: 'invoiceNumber',
700
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
701
+ * })
702
+ * ```
683
703
  */
684
- function collectScopeForward(context) {
685
- const out = {};
686
- if (context.organizationId !== void 0) out.organizationId = context.organizationId;
687
- if (context.tenantId !== void 0) out.tenantId = context.tenantId;
688
- if (context.user !== void 0) out.user = context.user;
689
- return out;
704
+ function sequentialId(options) {
705
+ const { prefix, model, padding = 4, separator = "-", counterKey } = options;
706
+ const key = counterKey || model.modelName;
707
+ return async (context) => {
708
+ const seq = await getNextSequence(key, 1, context._counterConnection, context.session);
709
+ return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
710
+ };
690
711
  }
691
712
  /**
692
- * Cascade delete plugin.
713
+ * Generator: Date-partitioned sequential counter.
714
+ * Counter resets per period — great for invoice/bill numbering.
715
+ *
716
+ * Produces IDs like:
717
+ * - yearly: `BILL-2026-0001`
718
+ * - monthly: `BILL-2026-02-0001`
719
+ * - daily: `BILL-2026-02-20-0001`
720
+ *
721
+ * @example
722
+ * ```typescript
723
+ * customIdPlugin({
724
+ * field: 'billNumber',
725
+ * generator: dateSequentialId({
726
+ * prefix: 'BILL',
727
+ * model: BillModel,
728
+ * partition: 'monthly',
729
+ * }),
730
+ * })
731
+ * ```
693
732
  */
694
- function cascadePlugin(options) {
695
- const { relations, parallel = true, logger } = options;
696
- if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
697
- for (const rel of relations) {
698
- if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
699
- if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
700
- }
701
- return {
702
- name: "cascade",
703
- apply(repo) {
704
- repo.on("after:delete", async (payload) => {
705
- const { context } = payload;
706
- const deletedId = context.id;
707
- if (!deletedId) {
708
- logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
709
- return;
710
- }
711
- const isSoftParent = context.softDeleted === true;
712
- const scopeForward = collectScopeForward(context);
713
- const cascadeOne = async (relation) => {
714
- const shouldSoftDelete = relation.softDelete ?? isSoftParent;
715
- const cascadeCtx = {
716
- targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
717
- session: context.session,
718
- shouldSoftDelete,
719
- user: context.user,
720
- scopeForward
721
- };
722
- try {
723
- if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
724
- else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
725
- } catch (error) {
726
- logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
727
- parentModel: context.model,
728
- parentId: String(deletedId),
729
- relatedModel: cascadeCtx.targetModelName,
730
- foreignKey: relation.foreignKey,
731
- error: error.message
732
- });
733
- throw error;
734
- }
735
- };
736
- await runCascades(relations, cascadeOne, parallel);
737
- });
738
- repo.on("before:deleteMany", async (context) => {
739
- const query = context.query;
740
- if (!query || Object.keys(query).length === 0) return;
741
- const idField = repo.idField || "_id";
742
- context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
743
- });
744
- repo.on("after:deleteMany", async (payload) => {
745
- const { context } = payload;
746
- const ids = context._cascadeIds;
747
- if (!ids || ids.length === 0) return;
748
- const isSoftParent = context.softDeleted === true;
749
- const scopeForward = collectScopeForward(context);
750
- const cascadeBulk = async (relation) => {
751
- const shouldSoftDelete = relation.softDelete ?? isSoftParent;
752
- const cascadeCtx = {
753
- targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
754
- session: context.session,
755
- shouldSoftDelete,
756
- user: context.user,
757
- scopeForward
758
- };
759
- try {
760
- if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
761
- else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
762
- } catch (error) {
763
- logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
764
- parentModel: context.model,
765
- relatedModel: cascadeCtx.targetModelName,
766
- foreignKey: relation.foreignKey,
767
- error: error.message
768
- });
769
- throw error;
770
- }
771
- };
772
- await runCascades(relations, cascadeBulk, parallel);
773
- });
733
+ function dateSequentialId(options) {
734
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
735
+ return async (context) => {
736
+ const now = /* @__PURE__ */ new Date();
737
+ const year = String(now.getFullYear());
738
+ const month = String(now.getMonth() + 1).padStart(2, "0");
739
+ const day = String(now.getDate()).padStart(2, "0");
740
+ let datePart;
741
+ let counterKey;
742
+ switch (partition) {
743
+ case "yearly":
744
+ datePart = year;
745
+ counterKey = `${model.modelName}:${year}`;
746
+ break;
747
+ case "daily":
748
+ datePart = `${year}${separator}${month}${separator}${day}`;
749
+ counterKey = `${model.modelName}:${year}-${month}-${day}`;
750
+ break;
751
+ default:
752
+ datePart = `${year}${separator}${month}`;
753
+ counterKey = `${model.modelName}:${year}-${month}`;
754
+ break;
774
755
  }
756
+ const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
757
+ return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
775
758
  };
776
759
  }
777
760
  /**
778
- * Repo-routed cascade for a single parent delete.
779
- * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
780
- * hooks (multi-tenant, audit, target's own soft-delete plugin).
781
- */
782
- async function cascadeViaRepoBulk(relation, parentId, ctx) {
783
- const targetRepo = relation.repo;
784
- if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
785
- await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
786
- ...ctx.scopeForward,
787
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
788
- session: ctx.session
789
- });
790
- }
791
- /**
792
- * Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
793
- */
794
- async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
795
- const targetRepo = relation.repo;
796
- if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
797
- await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
798
- ...ctx.scopeForward,
799
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
800
- session: ctx.session
801
- });
802
- }
803
- /**
804
- * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
805
- * Retained for backwards compatibility with existing cascadePlugin consumers.
806
- */
807
- async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
808
- const RelatedModel = mongoose.models[relation.model];
809
- if (!RelatedModel) {
810
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
811
- return;
812
- }
813
- const query = { [relation.foreignKey]: parentId };
814
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
815
- deletedAt: /* @__PURE__ */ new Date(),
816
- ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
817
- }, { session: ctx.session });
818
- else await RelatedModel.deleteMany(query, { session: ctx.session });
819
- }
820
- async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
821
- const RelatedModel = mongoose.models[relation.model];
822
- if (!RelatedModel) {
823
- logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
824
- return;
825
- }
826
- const query = { [relation.foreignKey]: { $in: parentIds } };
827
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
828
- deletedAt: /* @__PURE__ */ new Date(),
829
- ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
830
- }, { session: ctx.session });
831
- else await RelatedModel.deleteMany(query, { session: ctx.session });
832
- }
833
- /**
834
- * Execute a list of cascade operations, honoring the `parallel` flag.
835
- * Uses `allSettled` so one failure doesn't abort siblings; throws the first
836
- * rejection (with a composite message if several failed) after all complete.
761
+ * Generator: Prefix + random alphanumeric suffix.
762
+ * Does NOT require a database round-trip — purely in-memory.
763
+ *
764
+ * Produces IDs like: `USR_a7b3xk9m2p1q`
765
+ *
766
+ * Good for: user-facing IDs where ordering doesn't matter.
767
+ * Not suitable for sequential numbering.
768
+ *
769
+ * @example
770
+ * ```typescript
771
+ * customIdPlugin({
772
+ * field: 'publicId',
773
+ * generator: prefixedId({ prefix: 'USR', length: 10 }),
774
+ * })
775
+ * ```
837
776
  */
838
- async function runCascades(relations, fn, parallel) {
839
- if (parallel) {
840
- const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
841
- if (failures.length) {
842
- const err = failures[0].reason;
843
- if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
844
- throw err;
845
- }
846
- } else for (const relation of relations) await fn(relation);
777
+ function prefixedId(options) {
778
+ const { prefix, separator = "_", length = 12 } = options;
779
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
780
+ return (_context) => {
781
+ let result = "";
782
+ const bytes = new Uint8Array(length);
783
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
784
+ globalThis.crypto.getRandomValues(bytes);
785
+ for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
786
+ } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
787
+ return `${prefix}${separator}${result}`;
788
+ };
847
789
  }
848
- //#endregion
849
- //#region src/plugins/custom-id.plugin.ts
850
790
  /**
851
- * Custom ID Plugin
852
- *
853
- * Generates custom document IDs using pluggable generators.
854
- * Supports atomic counters for sequential IDs (e.g., INV-2026-0001),
855
- * date-partitioned sequences, and fully custom generators.
791
+ * Custom ID plugin — injects generated IDs into documents before creation.
856
792
  *
857
- * Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
858
- * counters collection — guaranteeing no duplicate IDs under concurrency.
793
+ * @param options - Configuration for ID generation
794
+ * @returns Plugin instance
859
795
  *
860
- * @example Basic sequential counter
796
+ * @example
861
797
  * ```typescript
798
+ * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
799
+ *
862
800
  * const invoiceRepo = new Repository(InvoiceModel, [
863
801
  * customIdPlugin({
864
802
  * field: 'invoiceNumber',
865
- * generator: sequentialId({
866
- * prefix: 'INV',
867
- * model: InvoiceModel,
868
- * }),
803
+ * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
869
804
  * }),
870
805
  * ]);
871
806
  *
872
807
  * const inv = await invoiceRepo.create({ amount: 100 });
873
- * // inv.invoiceNumber → "INV-0001"
874
- * ```
875
- *
876
- * @example Date-partitioned counter (resets monthly)
877
- * ```typescript
878
- * const billRepo = new Repository(BillModel, [
879
- * customIdPlugin({
880
- * field: 'billNumber',
881
- * generator: dateSequentialId({
882
- * prefix: 'BILL',
883
- * model: BillModel,
884
- * partition: 'monthly',
885
- * separator: '-',
886
- * padding: 4,
887
- * }),
888
- * }),
889
- * ]);
890
- *
891
- * const bill = await billRepo.create({ total: 250 });
892
- * // bill.billNumber → "BILL-2026-02-0001"
893
- * ```
894
- *
895
- * @example Custom generator function
896
- * ```typescript
897
- * const orderRepo = new Repository(OrderModel, [
898
- * customIdPlugin({
899
- * field: 'orderRef',
900
- * generator: async (context) => {
901
- * const region = context.data?.region || 'US';
902
- * const seq = await getNextSequence('orders');
903
- * return `ORD-${region}-${seq}`;
904
- * },
905
- * }),
906
- * ]);
808
+ * console.log(inv.invoiceNumber); // "INV-0001"
907
809
  * ```
908
810
  */
909
- /** Schema for the internal counters collection */
910
- const counterSchema = new mongoose.Schema({
911
- _id: {
912
- type: String,
913
- required: true
914
- },
915
- seq: {
916
- type: Number,
917
- default: 0
918
- }
919
- }, {
920
- collection: "_mongokit_counters",
921
- versionKey: false
922
- });
923
- /**
924
- * Get or create the Counter model on the given connection.
925
- * Falls back to the default mongoose connection if none is provided.
926
- * Lazy-init to avoid model registration errors if mongoose isn't connected yet.
927
- */
928
- function getCounterModel(connection) {
929
- const conn = connection ?? mongoose.connection;
930
- if (conn.models._MongoKitCounter) return conn.models._MongoKitCounter;
931
- return conn.model("_MongoKitCounter", counterSchema);
811
+ function customIdPlugin(options) {
812
+ const fieldName = options.field || "customId";
813
+ const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
814
+ return {
815
+ name: "custom-id",
816
+ apply(repo) {
817
+ const repoConnection = repo.Model.db;
818
+ repo.on("before:create", async (context) => {
819
+ if (!context.data) return;
820
+ if (generateOnlyIfEmpty && context.data[fieldName]) return;
821
+ context._counterConnection = repoConnection;
822
+ context.data[fieldName] = await options.generator(context);
823
+ });
824
+ repo.on("before:createMany", async (context) => {
825
+ if (!context.dataArray) return;
826
+ context._counterConnection = repoConnection;
827
+ const docsNeedingIds = [];
828
+ for (const doc of context.dataArray) {
829
+ if (generateOnlyIfEmpty && doc[fieldName]) continue;
830
+ docsNeedingIds.push(doc);
831
+ }
832
+ if (docsNeedingIds.length === 0) return;
833
+ for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
834
+ ...context,
835
+ data: doc
836
+ });
837
+ });
838
+ }
839
+ };
932
840
  }
841
+ //#endregion
842
+ //#region src/plugins/method-registry.plugin.ts
933
843
  /**
934
- * Atomically increment and return the next sequence value for a given key.
935
- * Uses `findOneAndUpdate` with `upsert` + `$inc` — fully atomic even under
936
- * heavy concurrency.
937
- *
938
- * @param counterKey - Unique key identifying this counter (e.g., "Invoice" or "Invoice:2026-02")
939
- * @param increment - Value to increment by (default: 1)
940
- * @param connection - Mongoose connection to use (defaults to the default connection)
941
- * @param session - Optional client session. When provided, the counter bump
942
- * participates in the caller's transaction — if the surrounding transaction
943
- * aborts, the counter does NOT advance. Without a session, the bump commits
944
- * immediately and is not rolled back if a sibling write fails.
945
- * @returns The next sequence number (after increment)
946
- *
947
- * @example
948
- * const seq = await getNextSequence('invoices');
949
- * // First call → 1, second → 2, ...
950
- *
951
- * @example Batch increment for createMany
952
- * const startSeq = await getNextSequence('invoices', 5);
953
- * // If current was 10, returns 15 (you use 11, 12, 13, 14, 15)
954
- *
955
- * @example Transactional counter bump
956
- * await withTransaction(conn, async (session) => {
957
- * const seq = await getNextSequence('invoices', 1, conn, session);
958
- * await invoiceRepo.create({ invoiceNumber: `INV-${seq}` }, { session });
959
- * // if this throws, the counter is rolled back with the insert
960
- * });
844
+ * Method registry plugin that enables dynamic method registration
961
845
  */
962
- async function getNextSequence(counterKey, increment = 1, connection, session) {
963
- const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $inc: { seq: increment } }, {
964
- upsert: true,
965
- returnDocument: "after",
966
- session
967
- });
968
- if (!result) throw new Error(`Failed to increment counter '${counterKey}'`);
969
- return result.seq;
846
+ function methodRegistryPlugin() {
847
+ return {
848
+ name: "method-registry",
849
+ apply(repo) {
850
+ const registeredMethods = [];
851
+ /**
852
+ * Register a new method on the repository instance
853
+ */
854
+ repo.registerMethod = (name, fn) => {
855
+ if (repo[name]) throw new Error(`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`);
856
+ if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
857
+ if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
858
+ repo[name] = fn.bind(repo);
859
+ registeredMethods.push(name);
860
+ repo.emit("method:registered", {
861
+ name,
862
+ fn
863
+ });
864
+ };
865
+ /**
866
+ * Check if a method is registered
867
+ */
868
+ repo.hasMethod = (name) => typeof repo[name] === "function";
869
+ /**
870
+ * Get list of all dynamically registered methods
871
+ */
872
+ repo.getRegisteredMethods = () => [...registeredMethods];
873
+ }
874
+ };
970
875
  }
876
+ //#endregion
877
+ //#region src/plugins/multi-tenant.plugin.ts
971
878
  /**
972
- * Generator: Simple sequential counter.
973
- * Produces IDs like `INV-0001`, `INV-0002`, etc.
879
+ * Multi-Tenant Plugin
974
880
  *
975
- * Uses atomic MongoDB counters — safe under concurrency.
881
+ * Automatically injects tenant isolation filters into all queries.
882
+ * Ensures data isolation by adding organizationId (or custom tenant field)
883
+ * to every read and write operation.
884
+ *
885
+ * Uses HOOK_PRIORITY.POLICY (100) to ensure tenant filters are injected
886
+ * BEFORE cache keys are computed (HOOK_PRIORITY.CACHE = 200).
976
887
  *
977
888
  * @example
978
889
  * ```typescript
979
- * customIdPlugin({
980
- * field: 'invoiceNumber',
981
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
982
- * })
983
- * ```
984
- */
985
- function sequentialId(options) {
986
- const { prefix, model, padding = 4, separator = "-", counterKey } = options;
987
- const key = counterKey || model.modelName;
988
- return async (context) => {
989
- const seq = await getNextSequence(key, 1, context._counterConnection, context.session);
990
- return `${prefix}${separator}${String(seq).padStart(padding, "0")}`;
991
- };
992
- }
993
- /**
994
- * Generator: Date-partitioned sequential counter.
995
- * Counter resets per period — great for invoice/bill numbering.
890
+ * // Basic — scopes every operation by organizationId from context
891
+ * const repo = new Repository(Invoice, [
892
+ * multiTenantPlugin({ tenantField: 'organizationId' }),
893
+ * ]);
996
894
  *
997
- * Produces IDs like:
998
- * - yearly: `BILL-2026-0001`
999
- * - monthly: `BILL-2026-02-0001`
1000
- * - daily: `BILL-2026-02-20-0001`
895
+ * const invoices = await repo.getAll(
896
+ * { filters: { status: 'paid' } },
897
+ * { organizationId: 'org_123' }
898
+ * );
899
+ * // Actual query: { status: 'paid', organizationId: 'org_123' }
1001
900
  *
1002
- * @example
1003
- * ```typescript
1004
- * customIdPlugin({
1005
- * field: 'billNumber',
1006
- * generator: dateSequentialId({
1007
- * prefix: 'BILL',
1008
- * model: BillModel,
1009
- * partition: 'monthly',
901
+ * // Super admin bypass — skip scoping based on context
902
+ * const repo = new Repository(Invoice, [
903
+ * multiTenantPlugin({
904
+ * tenantField: 'organizationId',
905
+ * skipWhen: (context) => context.role === 'superadmin',
1010
906
  * }),
1011
- * })
907
+ * ]);
908
+ *
909
+ * // Admin sees all orgs
910
+ * await repo.getAll({ page: 1, limit: 10 }, { role: 'superadmin' });
911
+ *
912
+ * // Automatic context — resolve tenant from AsyncLocalStorage
913
+ * const repo = new Repository(Invoice, [
914
+ * multiTenantPlugin({
915
+ * tenantField: 'organizationId',
916
+ * resolveContext: () => asyncLocalStorage.getStore()?.tenantId,
917
+ * }),
918
+ * ]);
1012
919
  * ```
1013
920
  */
1014
- function dateSequentialId(options) {
1015
- const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
1016
- return async (context) => {
1017
- const now = /* @__PURE__ */ new Date();
1018
- const year = String(now.getFullYear());
1019
- const month = String(now.getMonth() + 1).padStart(2, "0");
1020
- const day = String(now.getDate()).padStart(2, "0");
1021
- let datePart;
1022
- let counterKey;
1023
- switch (partition) {
1024
- case "yearly":
1025
- datePart = year;
1026
- counterKey = `${model.modelName}:${year}`;
1027
- break;
1028
- case "daily":
1029
- datePart = `${year}${separator}${month}${separator}${day}`;
1030
- counterKey = `${model.modelName}:${year}-${month}-${day}`;
1031
- break;
1032
- default:
1033
- datePart = `${year}${separator}${month}`;
1034
- counterKey = `${model.modelName}:${year}-${month}`;
1035
- break;
921
+ function multiTenantPlugin(options = {}) {
922
+ const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string", allowDataInjection = false, onMismatch = "throw" } = options;
923
+ const extraOps = [{
924
+ op: "getDeleted",
925
+ policyKey: "filters"
926
+ }];
927
+ return {
928
+ name: "multi-tenant",
929
+ apply(repo) {
930
+ const allOps = [...ALL_OPERATIONS.map((op) => ({
931
+ op,
932
+ policyKey: OP_REGISTRY[op].policyKey
933
+ })), ...extraOps];
934
+ for (const { op, policyKey } of allOps) {
935
+ if (skipOperations.includes(op)) continue;
936
+ if (policyKey === "none") continue;
937
+ repo.on(`before:${op}`, (context) => {
938
+ const perCallBypass = context.bypassTenant === true;
939
+ const callbackBypass = !perCallBypass && skipWhen?.(context, op) === true;
940
+ if (perCallBypass || callbackBypass) {
941
+ repo.emit("after:tenant-bypass", {
942
+ context,
943
+ operation: op,
944
+ reason: perCallBypass ? "option" : "callback"
945
+ });
946
+ return;
947
+ }
948
+ let tenantId = context[contextKey];
949
+ if (!tenantId && resolveContext) {
950
+ tenantId = resolveContext();
951
+ if (tenantId) context[contextKey] = tenantId;
952
+ }
953
+ if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
954
+ if (!tenantId && required) throw new Error([
955
+ `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
956
+ "",
957
+ "Three ways to provide it — pick the one that fits the call site:",
958
+ "",
959
+ ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
960
+ " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
961
+ " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
962
+ " then every repo call inside the closure inherits the",
963
+ " tenant with no options-bag plumbing. See",
964
+ " `createTenantContext` in @classytic/mongokit.",
965
+ " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
966
+ " support), or `skipWhen: adminBypass({...})` at plugin",
967
+ " construction for an always-on role-based bypass.",
968
+ "",
969
+ "Or set `required: false` if this collection is genuinely not tenant-scoped."
970
+ ].join("\n"));
971
+ if (!tenantId) return;
972
+ const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
973
+ const assertTenantValue = (supplied, where) => {
974
+ if (onMismatch === "overwrite") return;
975
+ if (supplied === void 0 || supplied === null) return;
976
+ if ((typeof supplied !== "object" || supplied instanceof mongoose.Types.ObjectId) && String(supplied) === String(castId)) return;
977
+ throw new Error(`[mongokit] Multi-tenant: '${tenantField}' in ${where} for '${op}' does not match the resolved tenant scope ('${String(tenantId)}'). Caller-supplied cross-tenant values are rejected (fail-closed). For legitimate cross-tenant access use bypassTenant: true (per-call) or skipWhen (plugin-level) — both emit 'after:tenant-bypass' for audit. To restore the legacy silent-overwrite behavior, construct the plugin with onMismatch: 'overwrite'.`);
978
+ };
979
+ switch (policyKey) {
980
+ case "filters":
981
+ assertTenantValue(context.filters?.[tenantField], "filters");
982
+ context.filters = {
983
+ ...context.filters,
984
+ [tenantField]: castId
985
+ };
986
+ break;
987
+ case "query":
988
+ assertTenantValue(context.query?.[tenantField], "query");
989
+ context.query = {
990
+ ...context.query,
991
+ [tenantField]: castId
992
+ };
993
+ break;
994
+ case "data":
995
+ if (context.data) {
996
+ assertTenantValue(context.data[tenantField], "data");
997
+ context.data[tenantField] = castId;
998
+ }
999
+ break;
1000
+ case "dataArray":
1001
+ if (context.dataArray) {
1002
+ for (const doc of context.dataArray) if (doc && typeof doc === "object") {
1003
+ assertTenantValue(doc[tenantField], "dataArray");
1004
+ doc[tenantField] = castId;
1005
+ }
1006
+ }
1007
+ break;
1008
+ case "operations":
1009
+ if (context.operations) {
1010
+ const ops = context.operations;
1011
+ for (const subOp of ops) {
1012
+ for (const key of [
1013
+ "updateOne",
1014
+ "updateMany",
1015
+ "deleteOne",
1016
+ "deleteMany",
1017
+ "replaceOne"
1018
+ ]) {
1019
+ const opBody = subOp[key];
1020
+ if (opBody?.filter) {
1021
+ assertTenantValue(opBody.filter[tenantField], `bulkWrite ${key}.filter`);
1022
+ opBody.filter = {
1023
+ ...opBody.filter,
1024
+ [tenantField]: castId
1025
+ };
1026
+ }
1027
+ }
1028
+ const insertBody = subOp.insertOne;
1029
+ if (insertBody?.document) {
1030
+ assertTenantValue(insertBody.document[tenantField], "bulkWrite insertOne.document");
1031
+ insertBody.document[tenantField] = castId;
1032
+ }
1033
+ }
1034
+ }
1035
+ break;
1036
+ }
1037
+ }, { priority: HOOK_PRIORITY.POLICY });
1038
+ }
1039
+ }
1040
+ };
1041
+ }
1042
+ /**
1043
+ * Build the canonical `skipWhen` callback for role-based tenant
1044
+ * bypass — replaces the hand-rolled
1045
+ * `(ctx) => ctx.role === 'superadmin'` callback that ~6 packages
1046
+ * across the classytic codebase carry as boilerplate. Eliminates the
1047
+ * "I forgot to include 'platform_admin'" drift class — the role
1048
+ * vocabulary lives in one place.
1049
+ *
1050
+ * **Composes with `bypassTenant: true` per-call**, doesn't replace
1051
+ * it. `bypassTenant` is the deliberate per-call form for migration
1052
+ * scripts and one-off admin queries; `adminBypass` is the
1053
+ * always-on role-based bypass for users whose JWT / session carries
1054
+ * a privileged role.
1055
+ *
1056
+ * **NOT a substitute for authentication or RBAC.** This factory only
1057
+ * answers "should the tenant scope hook fire for this call?" — it
1058
+ * does NOT validate that the caller is authenticated, that their
1059
+ * role claim is genuine, or that the requested operation is
1060
+ * permitted. Always run auth + RBAC before the call reaches the
1061
+ * repository layer; this is the LAST gate, not the only one.
1062
+ *
1063
+ * @example Single role
1064
+ * ```ts
1065
+ * import { adminBypass, multiTenantPlugin } from '@classytic/mongokit';
1066
+ *
1067
+ * const repo = new Repository(InvoiceModel, [
1068
+ * multiTenantPlugin({
1069
+ * tenantField: 'organizationId',
1070
+ * skipWhen: adminBypass({ adminRoles: ['superadmin'] }),
1071
+ * }),
1072
+ * ]);
1073
+ *
1074
+ * await repo.getAll({}, { role: 'superadmin' }); // sees all orgs
1075
+ * await repo.getAll({}, { role: 'user', organizationId: 'org-1' }); // scoped
1076
+ * ```
1077
+ *
1078
+ * @example Multiple admin roles + custom field name
1079
+ * ```ts
1080
+ * skipWhen: adminBypass({
1081
+ * roleField: 'principalRole', // ctx.principalRole, not ctx.role
1082
+ * adminRoles: ['superadmin', 'platform_admin', 'support'],
1083
+ * }),
1084
+ * ```
1085
+ *
1086
+ * @example Composing with custom logic
1087
+ * ```ts
1088
+ * // adminBypass for the role check, then your own additional logic:
1089
+ * const adminCheck = adminBypass({ adminRoles: ['superadmin'] });
1090
+ * skipWhen: (ctx, op) => adminCheck(ctx, op) || ctx.internalSystemFlag === true,
1091
+ * ```
1092
+ *
1093
+ * @param options.roleField - Path on the context to read the role
1094
+ * from. Defaults to `'role'`. Top-level keys only — for nested
1095
+ * shapes (e.g. `ctx.user.role`), wrap your own `(ctx) => …` callback
1096
+ * instead.
1097
+ * @param options.adminRoles - Role values that grant the bypass. The
1098
+ * factory does an exact-match `includes` check — case-sensitive,
1099
+ * no fuzzy matching. Lowercase your role vocabulary upstream.
1100
+ * @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
1101
+ */
1102
+ function adminBypass$1(options) {
1103
+ return adminBypass(options);
1104
+ }
1105
+ //#endregion
1106
+ //#region src/plugins/soft-delete.plugin.ts
1107
+ /**
1108
+ * Build the deletion-state filter for either side of the soft-delete
1109
+ * boundary. One parameterized helper covers both directions so the two
1110
+ * predicates can't drift:
1111
+ *
1112
+ * - `'live'`: docs NOT soft-deleted — `null` mode matches
1113
+ * `{ field: null }` (works with `default: null` schemas), `'exists'`
1114
+ * mode matches `{ field: { $exists: false } }` (legacy behavior).
1115
+ * - `'deleted'`: docs that ARE soft-deleted — the exact complement of
1116
+ * the corresponding `'live'` predicate in each mode.
1117
+ */
1118
+ function buildDeletionStateFilter(deletedField, filterMode, state) {
1119
+ if (filterMode === "exists") return state === "live" ? { [deletedField]: { $exists: false } } : { [deletedField]: {
1120
+ $exists: true,
1121
+ $ne: null
1122
+ } };
1123
+ return state === "live" ? { [deletedField]: null } : { [deletedField]: { $ne: null } };
1124
+ }
1125
+ /**
1126
+ * Live-docs filter honoring the per-call `includeDeleted` escape hatch
1127
+ * (returns an empty filter — no deletion-state predicate at all).
1128
+ */
1129
+ function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
1130
+ if (includeDeleted) return {};
1131
+ return buildDeletionStateFilter(deletedField, filterMode, "live");
1132
+ }
1133
+ /**
1134
+ * Soft delete plugin
1135
+ *
1136
+ * @example Basic usage
1137
+ * ```typescript
1138
+ * const repo = new Repository(Model, [
1139
+ * softDeletePlugin({ deletedField: 'deletedAt' })
1140
+ * ]);
1141
+ *
1142
+ * // Delete (soft)
1143
+ * await repo.delete(id);
1144
+ *
1145
+ * // Restore
1146
+ * await repo.restore(id);
1147
+ *
1148
+ * // Get deleted documents
1149
+ * await repo.getDeleted({ page: 1, limit: 20 });
1150
+ * ```
1151
+ *
1152
+ * @example With null filter mode (for schemas with default: null)
1153
+ * ```typescript
1154
+ * // Schema: { deletedAt: { type: Date, default: null } }
1155
+ * const repo = new Repository(Model, [
1156
+ * softDeletePlugin({
1157
+ * deletedField: 'deletedAt',
1158
+ * filterMode: 'null', // default - works with default: null
1159
+ * })
1160
+ * ]);
1161
+ * ```
1162
+ *
1163
+ * @example With TTL for auto-cleanup
1164
+ * ```typescript
1165
+ * const repo = new Repository(Model, [
1166
+ * softDeletePlugin({
1167
+ * deletedField: 'deletedAt',
1168
+ * ttlDays: 30, // Auto-delete after 30 days
1169
+ * })
1170
+ * ]);
1171
+ * ```
1172
+ */
1173
+ function softDeletePlugin(options = {}) {
1174
+ const deletedField = options.deletedField || "deletedAt";
1175
+ const deletedByField = options.deletedByField || "deletedBy";
1176
+ const filterMode = options.filterMode || "null";
1177
+ const addRestoreMethod = options.addRestoreMethod !== false;
1178
+ const addGetDeletedMethod = options.addGetDeletedMethod !== false;
1179
+ const ttlDays = options.ttlDays;
1180
+ return {
1181
+ name: "softDelete",
1182
+ apply(repo) {
1183
+ try {
1184
+ const schemaPaths = repo.Model.schema.paths;
1185
+ for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
1186
+ if (pathName === "_id" || pathName === deletedField) continue;
1187
+ if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
1188
+ }
1189
+ } catch (err) {
1190
+ warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
1191
+ }
1192
+ if (ttlDays !== void 0 && ttlDays > 0) {
1193
+ const ttlSeconds = ttlDays * 24 * 60 * 60;
1194
+ repo.Model.collection.createIndex({ [deletedField]: 1 }, {
1195
+ expireAfterSeconds: ttlSeconds,
1196
+ partialFilterExpression: { [deletedField]: { $type: "date" } }
1197
+ }).catch((err) => {
1198
+ if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1199
+ });
1200
+ }
1201
+ repo.on("before:delete", async (context) => {
1202
+ if (context.deleteMode === "hard") return;
1203
+ if (options.soft !== false) {
1204
+ const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
1205
+ if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
1206
+ const deleteQuery = {
1207
+ [repo.idField || "_id"]: context.id,
1208
+ ...context.query || {}
1209
+ };
1210
+ const signal = context.signal;
1211
+ const retryPolicy = context.retryPolicy;
1212
+ throwIfAborted(signal);
1213
+ if (!await withRetry(() => repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session }), retryPolicy, signal)) {
1214
+ const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
1215
+ error.status = 404;
1216
+ throw error;
1217
+ }
1218
+ context.softDeleted = true;
1219
+ }
1220
+ }, { priority: HOOK_PRIORITY.POLICY });
1221
+ const injectDeleteFilterToFilters = (context) => {
1222
+ if (options.soft !== false) {
1223
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1224
+ if (Object.keys(deleteFilter).length > 0) context.filters = {
1225
+ ...context.filters || {},
1226
+ ...deleteFilter
1227
+ };
1228
+ }
1229
+ };
1230
+ const injectDeleteFilterToQuery = (context) => {
1231
+ if (options.soft !== false) {
1232
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1233
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1234
+ ...context.query || {},
1235
+ ...deleteFilter
1236
+ };
1237
+ }
1238
+ };
1239
+ const SOFT_DELETE_SPECIAL = new Set([
1240
+ "updateMany",
1241
+ "deleteMany",
1242
+ "bulkWrite"
1243
+ ]);
1244
+ for (const op of ALL_OPERATIONS) {
1245
+ if (SOFT_DELETE_SPECIAL.has(op)) continue;
1246
+ const desc = OP_REGISTRY[op];
1247
+ const isFilterableMutation = op === "findOneAndUpdate";
1248
+ if (desc.mutates && !isFilterableMutation) continue;
1249
+ if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
1250
+ else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
1251
+ }
1252
+ repo.on("before:updateMany", (context) => {
1253
+ if (options.soft !== false) {
1254
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1255
+ if (Object.keys(deleteFilter).length > 0) context.query = {
1256
+ ...context.query || {},
1257
+ ...deleteFilter
1258
+ };
1259
+ }
1260
+ }, { priority: HOOK_PRIORITY.POLICY });
1261
+ repo.on("before:deleteMany", async (context) => {
1262
+ if (context.deleteMode === "hard") return;
1263
+ if (options.soft !== false) {
1264
+ const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
1265
+ const finalQuery = {
1266
+ ...context.query || {},
1267
+ ...deleteFilter
1268
+ };
1269
+ const signal = context.signal;
1270
+ const retryPolicy = context.retryPolicy;
1271
+ throwIfAborted(signal);
1272
+ const updateRes = await withRetry(() => repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session }), retryPolicy, signal);
1273
+ context.softDeleted = true;
1274
+ context.softDeletedCount = updateRes?.modifiedCount ?? 0;
1275
+ }
1276
+ }, { priority: HOOK_PRIORITY.POLICY });
1277
+ if (addRestoreMethod) {
1278
+ const restoreMethod = async function(id, restoreOptions = {}) {
1279
+ const context = await this._buildContext("restore", {
1280
+ id,
1281
+ ...restoreOptions
1282
+ });
1283
+ const updateData = {
1284
+ [deletedField]: null,
1285
+ [deletedByField]: null
1286
+ };
1287
+ const restoreQuery = {
1288
+ _id: id,
1289
+ ...context.query || {}
1290
+ };
1291
+ const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
1292
+ returnDocument: "after",
1293
+ session: restoreOptions.session
1294
+ });
1295
+ if (!result) {
1296
+ const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
1297
+ error.status = 404;
1298
+ throw error;
1299
+ }
1300
+ await this.emitAsync("after:restore", {
1301
+ context,
1302
+ result
1303
+ });
1304
+ return result;
1305
+ };
1306
+ if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
1307
+ else repo.restore = restoreMethod.bind(repo);
1308
+ }
1309
+ if (addGetDeletedMethod) {
1310
+ const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
1311
+ const context = await this._buildContext("getDeleted", {
1312
+ ...params,
1313
+ ...getDeletedOptions
1314
+ });
1315
+ const deletedFilter = buildDeletionStateFilter(deletedField, filterMode, "deleted");
1316
+ const combinedFilters = {
1317
+ ...params.filters || {},
1318
+ ...deletedFilter,
1319
+ ...context.filters || {},
1320
+ ...context.query || {}
1321
+ };
1322
+ const page = params.page || 1;
1323
+ const limit = params.limit || 20;
1324
+ const skip = (page - 1) * limit;
1325
+ let sortSpec = { [deletedField]: -1 };
1326
+ if (params.sort) if (typeof params.sort === "string") {
1327
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
1328
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
1329
+ } else sortSpec = params.sort;
1330
+ let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
1331
+ if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
1332
+ if (getDeletedOptions.select) {
1333
+ const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
1334
+ query = query.select(selectValue);
1335
+ }
1336
+ if (getDeletedOptions.populate) {
1337
+ const populateSpec = getDeletedOptions.populate;
1338
+ if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
1339
+ else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
1340
+ else query = query.populate(populateSpec);
1341
+ }
1342
+ if (getDeletedOptions.lean !== false) query = query.lean();
1343
+ const [data, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
1344
+ const pages = Math.ceil(total / limit);
1345
+ return {
1346
+ method: "offset",
1347
+ data,
1348
+ page,
1349
+ limit,
1350
+ total,
1351
+ pages,
1352
+ hasNext: page < pages,
1353
+ hasPrev: page > 1
1354
+ };
1355
+ };
1356
+ if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
1357
+ else repo.getDeleted = getDeletedMethod.bind(repo);
1358
+ }
1359
+ }
1360
+ };
1361
+ }
1362
+ //#endregion
1363
+ //#region src/plugins/timestamp.plugin.ts
1364
+ /**
1365
+ * Timestamp plugin that auto-injects timestamps
1366
+ *
1367
+ * @example
1368
+ * const repo = new Repository(Model, [timestampPlugin()]);
1369
+ */
1370
+ function timestampPlugin() {
1371
+ return {
1372
+ name: "timestamp",
1373
+ apply(repo) {
1374
+ repo.on("before:create", (context) => {
1375
+ if (!context.data) return;
1376
+ const now = /* @__PURE__ */ new Date();
1377
+ if (!context.data.createdAt) context.data.createdAt = now;
1378
+ if (!context.data.updatedAt) context.data.updatedAt = now;
1379
+ });
1380
+ repo.on("before:update", (context) => {
1381
+ if (!context.data) return;
1382
+ context.data.updatedAt = /* @__PURE__ */ new Date();
1383
+ });
1384
+ repo.on("before:findOneAndUpdate", (context) => {
1385
+ if (!context.data) return;
1386
+ const now = /* @__PURE__ */ new Date();
1387
+ if (Array.isArray(context.data)) return;
1388
+ const update = context.data;
1389
+ if (Object.keys(update).some((k) => k.startsWith("$"))) {
1390
+ const set = update.$set || {};
1391
+ set.updatedAt = now;
1392
+ update.$set = set;
1393
+ const setOnInsert = update.$setOnInsert || {};
1394
+ if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
1395
+ update.$setOnInsert = setOnInsert;
1396
+ } else update.updatedAt = now;
1397
+ });
1398
+ }
1399
+ };
1400
+ }
1401
+ //#endregion
1402
+ //#region src/plugins/aggregate-helpers.plugin.ts
1403
+ /**
1404
+ * Aggregate helpers plugin
1405
+ *
1406
+ * @example
1407
+ * const repo = new Repository(Model, [
1408
+ * methodRegistryPlugin(),
1409
+ * aggregateHelpersPlugin(),
1410
+ * ]);
1411
+ *
1412
+ * const groups = await repo.groupBy('category');
1413
+ * const total = await repo.sum('amount', { status: 'completed' });
1414
+ */
1415
+ function aggregateHelpersPlugin() {
1416
+ return {
1417
+ name: "aggregate-helpers",
1418
+ apply(repo) {
1419
+ if (!repo.registerMethod) throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
1420
+ /**
1421
+ * Group by field
1422
+ */
1423
+ repo.registerMethod("groupBy", async function(field, options = {}) {
1424
+ const pipeline = [{ $group: {
1425
+ _id: `$${field}`,
1426
+ count: { $sum: 1 }
1427
+ } }, { $sort: { count: -1 } }];
1428
+ if (options.limit) pipeline.push({ $limit: options.limit });
1429
+ return this.aggregatePipeline(pipeline, options);
1430
+ });
1431
+ const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
1432
+ const pipeline = [{ $match: query }, { $group: {
1433
+ _id: null,
1434
+ [resultKey]: { [operator]: `$${field}` }
1435
+ } }];
1436
+ return (await this.aggregatePipeline(pipeline, options))[0]?.[resultKey] || 0;
1437
+ };
1438
+ /**
1439
+ * Sum field values
1440
+ */
1441
+ repo.registerMethod("sum", async function(field, query = {}, options = {}) {
1442
+ return aggregateOperation.call(this, field, "$sum", "total", query, options);
1443
+ });
1444
+ /**
1445
+ * Average field values
1446
+ */
1447
+ repo.registerMethod("average", async function(field, query = {}, options = {}) {
1448
+ return aggregateOperation.call(this, field, "$avg", "avg", query, options);
1449
+ });
1450
+ /**
1451
+ * Get minimum value
1452
+ */
1453
+ repo.registerMethod("min", async function(field, query = {}, options = {}) {
1454
+ return aggregateOperation.call(this, field, "$min", "min", query, options);
1455
+ });
1456
+ /**
1457
+ * Get maximum value
1458
+ */
1459
+ repo.registerMethod("max", async function(field, query = {}, options = {}) {
1460
+ return aggregateOperation.call(this, field, "$max", "max", query, options);
1461
+ });
1036
1462
  }
1037
- const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
1038
- return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
1039
1463
  };
1040
1464
  }
1465
+ //#endregion
1466
+ //#region src/plugins/cascade.plugin.ts
1041
1467
  /**
1042
- * Generator: Prefix + random alphanumeric suffix.
1043
- * Does NOT require a database round-trip — purely in-memory.
1468
+ * Cascade Delete Plugin
1044
1469
  *
1045
- * Produces IDs like: `USR_a7b3xk9m2p1q`
1470
+ * Automatically deletes related documents when a parent document is deleted.
1046
1471
  *
1047
- * Good for: user-facing IDs where ordering doesn't matter.
1048
- * Not suitable for sequential numbering.
1472
+ * Two routing modes per relation:
1049
1473
  *
1050
- * @example
1051
- * ```typescript
1052
- * customIdPlugin({
1053
- * field: 'publicId',
1054
- * generator: prefixedId({ prefix: 'USR', length: 10 }),
1055
- * })
1056
- * ```
1057
- */
1058
- function prefixedId(options) {
1059
- const { prefix, separator = "_", length = 12 } = options;
1060
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
1061
- return (_context) => {
1062
- let result = "";
1063
- const bytes = new Uint8Array(length);
1064
- if (typeof globalThis.crypto?.getRandomValues === "function") {
1065
- globalThis.crypto.getRandomValues(bytes);
1066
- for (let i = 0; i < length; i++) result += chars[bytes[i] % 36];
1067
- } else for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * 36)];
1068
- return `${prefix}${separator}${result}`;
1069
- };
1070
- }
1071
- /**
1072
- * Custom ID plugin — injects generated IDs into documents before creation.
1474
+ * 1. **Repo-routed (preferred)** — pass `repo: targetRepo` on the relation.
1475
+ * Cascade calls `targetRepo.delete(id, { mode })` / `targetRepo.deleteMany(
1476
+ * query, { mode })`, so the target's `before:delete` / `before:deleteMany`
1477
+ * hooks fire. Multi-tenant scoping, audit logging, cache invalidation,
1478
+ * and the target's own `softDeletePlugin` (with its configured
1479
+ * `deletedField`) all run correctly.
1073
1480
  *
1074
- * @param options - Configuration for ID generation
1075
- * @returns Plugin instance
1481
+ * 2. **Model-routed (legacy)** — pass `model: 'TargetModelName'`. Cascade
1482
+ * writes directly via `mongoose.models[name].updateMany / deleteMany`,
1483
+ * **bypassing** the target's hooks. Safe only for trivial targets with
1484
+ * no policy plugins. Retained for backwards compatibility — prefer the
1485
+ * repo-routed form for new code.
1076
1486
  *
1077
- * @example
1078
- * ```typescript
1079
- * import { Repository, customIdPlugin, sequentialId } from '@classytic/mongokit';
1487
+ * The parent's delete mode propagates: a hard-deleted parent cascades hard,
1488
+ * a soft-deleted parent cascades soft — unless `relation.softDelete` overrides
1489
+ * the decision per-relation.
1080
1490
  *
1081
- * const invoiceRepo = new Repository(InvoiceModel, [
1082
- * customIdPlugin({
1083
- * field: 'invoiceNumber',
1084
- * generator: sequentialId({ prefix: 'INV', model: InvoiceModel }),
1491
+ * @example Repo-routed (new)
1492
+ * ```ts
1493
+ * const productRepo = new Repository(Product, [
1494
+ * methodRegistryPlugin(),
1495
+ * cascadePlugin({
1496
+ * relations: [
1497
+ * { repo: stockEntryRepo, foreignKey: 'product' },
1498
+ * { repo: stockMovementRepo, foreignKey: 'product' },
1499
+ * ],
1085
1500
  * }),
1086
1501
  * ]);
1502
+ * ```
1087
1503
  *
1088
- * const inv = await invoiceRepo.create({ amount: 100 });
1089
- * console.log(inv.invoiceNumber); // "INV-0001"
1504
+ * @example Model-routed (legacy)
1505
+ * ```ts
1506
+ * cascadePlugin({
1507
+ * relations: [
1508
+ * { model: 'StockEntry', foreignKey: 'product' },
1509
+ * ],
1510
+ * });
1090
1511
  * ```
1091
1512
  */
1092
- function customIdPlugin(options) {
1093
- const fieldName = options.field || "customId";
1094
- const generateOnlyIfEmpty = options.generateOnlyIfEmpty !== false;
1513
+ /**
1514
+ * Collect top-level parent-context fields that a target plugin (multi-tenant,
1515
+ * audit) will likely need. We can't know the target's contextKey, so we
1516
+ * forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
1517
+ * These are the conventions enforced by the built-in plugins.
1518
+ */
1519
+ function collectScopeForward(context) {
1520
+ const out = {};
1521
+ if (context.organizationId !== void 0) out.organizationId = context.organizationId;
1522
+ if (context.tenantId !== void 0) out.tenantId = context.tenantId;
1523
+ if (context.user !== void 0) out.user = context.user;
1524
+ return out;
1525
+ }
1526
+ /**
1527
+ * Cascade delete plugin.
1528
+ */
1529
+ function cascadePlugin(options) {
1530
+ const { relations, parallel = true, logger } = options;
1531
+ if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
1532
+ for (const rel of relations) {
1533
+ if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
1534
+ if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
1535
+ }
1095
1536
  return {
1096
- name: "custom-id",
1537
+ name: "cascade",
1097
1538
  apply(repo) {
1098
- const repoConnection = repo.Model.db;
1099
- repo.on("before:create", async (context) => {
1100
- if (!context.data) return;
1101
- if (generateOnlyIfEmpty && context.data[fieldName]) return;
1102
- context._counterConnection = repoConnection;
1103
- context.data[fieldName] = await options.generator(context);
1104
- });
1105
- repo.on("before:createMany", async (context) => {
1106
- if (!context.dataArray) return;
1107
- context._counterConnection = repoConnection;
1108
- const docsNeedingIds = [];
1109
- for (const doc of context.dataArray) {
1110
- if (generateOnlyIfEmpty && doc[fieldName]) continue;
1111
- docsNeedingIds.push(doc);
1539
+ repo.on("after:delete", async (payload) => {
1540
+ const { context } = payload;
1541
+ const deletedId = context.id;
1542
+ if (!deletedId) {
1543
+ logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
1544
+ return;
1112
1545
  }
1113
- if (docsNeedingIds.length === 0) return;
1114
- for (const doc of docsNeedingIds) doc[fieldName] = await options.generator({
1115
- ...context,
1116
- data: doc
1117
- });
1546
+ const isSoftParent = context.softDeleted === true;
1547
+ const scopeForward = collectScopeForward(context);
1548
+ const cascadeOne = async (relation) => {
1549
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
1550
+ const cascadeCtx = {
1551
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
1552
+ session: context.session,
1553
+ shouldSoftDelete,
1554
+ user: context.user,
1555
+ scopeForward
1556
+ };
1557
+ try {
1558
+ if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
1559
+ else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
1560
+ } catch (error) {
1561
+ logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
1562
+ parentModel: context.model,
1563
+ parentId: String(deletedId),
1564
+ relatedModel: cascadeCtx.targetModelName,
1565
+ foreignKey: relation.foreignKey,
1566
+ error: error.message
1567
+ });
1568
+ throw error;
1569
+ }
1570
+ };
1571
+ await runCascades(relations, cascadeOne, parallel);
1572
+ });
1573
+ repo.on("before:deleteMany", async (context) => {
1574
+ const query = context.query;
1575
+ if (!query || Object.keys(query).length === 0) return;
1576
+ const idField = repo.idField || "_id";
1577
+ context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
1578
+ });
1579
+ repo.on("after:deleteMany", async (payload) => {
1580
+ const { context } = payload;
1581
+ const ids = context._cascadeIds;
1582
+ if (!ids || ids.length === 0) return;
1583
+ const isSoftParent = context.softDeleted === true;
1584
+ const scopeForward = collectScopeForward(context);
1585
+ const cascadeBulk = async (relation) => {
1586
+ const shouldSoftDelete = relation.softDelete ?? isSoftParent;
1587
+ const cascadeCtx = {
1588
+ targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
1589
+ session: context.session,
1590
+ shouldSoftDelete,
1591
+ user: context.user,
1592
+ scopeForward
1593
+ };
1594
+ try {
1595
+ if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
1596
+ else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
1597
+ } catch (error) {
1598
+ logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
1599
+ parentModel: context.model,
1600
+ relatedModel: cascadeCtx.targetModelName,
1601
+ foreignKey: relation.foreignKey,
1602
+ error: error.message
1603
+ });
1604
+ throw error;
1605
+ }
1606
+ };
1607
+ await runCascades(relations, cascadeBulk, parallel);
1118
1608
  });
1119
1609
  }
1120
- };
1610
+ };
1611
+ }
1612
+ /**
1613
+ * Repo-routed cascade for a single parent delete.
1614
+ * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
1615
+ * hooks (multi-tenant, audit, target's own soft-delete plugin).
1616
+ */
1617
+ async function cascadeViaRepoBulk(relation, parentId, ctx) {
1618
+ const targetRepo = relation.repo;
1619
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
1620
+ await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
1621
+ ...ctx.scopeForward,
1622
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
1623
+ session: ctx.session
1624
+ });
1625
+ }
1626
+ /**
1627
+ * Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids.
1628
+ */
1629
+ async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
1630
+ const targetRepo = relation.repo;
1631
+ if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
1632
+ await targetRepo.deleteMany({ [relation.foreignKey]: { $in: parentIds } }, {
1633
+ ...ctx.scopeForward,
1634
+ mode: ctx.shouldSoftDelete ? "soft" : "hard",
1635
+ session: ctx.session
1636
+ });
1637
+ }
1638
+ /**
1639
+ * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
1640
+ * Retained for backwards compatibility with existing cascadePlugin consumers.
1641
+ */
1642
+ async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
1643
+ const RelatedModel = mongoose.models[relation.model];
1644
+ if (!RelatedModel) {
1645
+ logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
1646
+ return;
1647
+ }
1648
+ const query = { [relation.foreignKey]: parentId };
1649
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
1650
+ deletedAt: /* @__PURE__ */ new Date(),
1651
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
1652
+ }, { session: ctx.session });
1653
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
1654
+ }
1655
+ async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
1656
+ const RelatedModel = mongoose.models[relation.model];
1657
+ if (!RelatedModel) {
1658
+ logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
1659
+ return;
1660
+ }
1661
+ const query = { [relation.foreignKey]: { $in: parentIds } };
1662
+ if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
1663
+ deletedAt: /* @__PURE__ */ new Date(),
1664
+ ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
1665
+ }, { session: ctx.session });
1666
+ else await RelatedModel.deleteMany(query, { session: ctx.session });
1667
+ }
1668
+ /**
1669
+ * Execute a list of cascade operations, honoring the `parallel` flag.
1670
+ * Uses `allSettled` so one failure doesn't abort siblings; throws the first
1671
+ * rejection (with a composite message if several failed) after all complete.
1672
+ */
1673
+ async function runCascades(relations, fn, parallel) {
1674
+ if (parallel) {
1675
+ const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
1676
+ if (failures.length) {
1677
+ const err = failures[0].reason;
1678
+ if (failures.length > 1) err.message = `${failures.length} cascade deletes failed. First: ${err.message}`;
1679
+ throw err;
1680
+ }
1681
+ } else for (const relation of relations) await fn(relation);
1121
1682
  }
1122
1683
  //#endregion
1123
1684
  //#region src/plugins/elastic.plugin.ts
@@ -1301,41 +1862,6 @@ function leasePlugin(options = {}) {
1301
1862
  };
1302
1863
  }
1303
1864
  //#endregion
1304
- //#region src/plugins/method-registry.plugin.ts
1305
- /**
1306
- * Method registry plugin that enables dynamic method registration
1307
- */
1308
- function methodRegistryPlugin() {
1309
- return {
1310
- name: "method-registry",
1311
- apply(repo) {
1312
- const registeredMethods = [];
1313
- /**
1314
- * Register a new method on the repository instance
1315
- */
1316
- repo.registerMethod = (name, fn) => {
1317
- if (repo[name]) throw new Error(`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`);
1318
- if (!name || typeof name !== "string") throw new Error("Method name must be a non-empty string");
1319
- if (typeof fn !== "function") throw new Error(`Method '${name}' must be a function`);
1320
- repo[name] = fn.bind(repo);
1321
- registeredMethods.push(name);
1322
- repo.emit("method:registered", {
1323
- name,
1324
- fn
1325
- });
1326
- };
1327
- /**
1328
- * Check if a method is registered
1329
- */
1330
- repo.hasMethod = (name) => typeof repo[name] === "function";
1331
- /**
1332
- * Get list of all dynamically registered methods
1333
- */
1334
- repo.getRegisteredMethods = () => [...registeredMethods];
1335
- }
1336
- };
1337
- }
1338
- //#endregion
1339
1865
  //#region src/plugins/mongo-operations.plugin.ts
1340
1866
  /**
1341
1867
  * MongoDB Operations Plugin
@@ -1497,291 +2023,82 @@ function mongoOperationsPlugin() {
1497
2023
  * maxConcurrent,
1498
2024
  * );
1499
2025
  * if (!claimed) return; // already at limit; another caller won
1500
- * ```
1501
- */
1502
- repo.registerMethod("incrementIfBelow", async function(id, field, ceiling, by = 1, options = {}) {
1503
- if (typeof ceiling !== "number") throw createError(400, "incrementIfBelow ceiling must be a number");
1504
- if (typeof by !== "number") throw createError(400, "incrementIfBelow `by` must be a number");
1505
- if (typeof field !== "string" || field.length === 0) throw createError(400, "incrementIfBelow field must be a non-empty string");
1506
- for (const segment of field.split(".")) {
1507
- if (segment.length === 0) throw createError(400, `incrementIfBelow field has an empty segment ("${field}")`);
1508
- if (segment.startsWith("$")) throw createError(400, `incrementIfBelow field segment cannot start with '$' ("${segment}") — Mongo would treat it as an operator`);
1509
- if (segment === "__proto__" || segment === "constructor" || segment === "prototype") throw createError(400, `incrementIfBelow field segment "${segment}" is reserved (prototype-pollution guard)`);
1510
- }
1511
- const idField = this.idField || "_id";
1512
- return this.findOneAndUpdate({
1513
- [idField]: id,
1514
- [field]: { $lt: ceiling }
1515
- }, { $inc: { [field]: by } }, {
1516
- ...options,
1517
- returnDocument: "after"
1518
- });
1519
- });
1520
- /**
1521
- * Atomic update with multiple MongoDB operators in a single call
1522
- *
1523
- * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
1524
- * into one atomic database operation.
1525
- *
1526
- * @example
1527
- * // Combine $inc + $set in one atomic call
1528
- * await repo.atomicUpdate(id, {
1529
- * $inc: { views: 1, commentCount: 1 },
1530
- * $set: { lastActiveAt: new Date() }
1531
- * });
1532
- *
1533
- * // Multiple operators: $inc + $set + $push
1534
- * await repo.atomicUpdate(id, {
1535
- * $inc: { 'metrics.total': 1 },
1536
- * $set: { updatedAt: new Date() },
1537
- * $push: { history: { action: 'update', at: new Date() } }
1538
- * });
1539
- *
1540
- * // $push with $each modifier
1541
- * await repo.atomicUpdate(id, {
1542
- * $push: { tags: { $each: ['featured', 'popular'] } },
1543
- * $inc: { tagCount: 2 }
1544
- * });
1545
- *
1546
- * // With arrayFilters for positional updates
1547
- * await repo.atomicUpdate(id, {
1548
- * $set: { 'items.$[elem].quantity': 5 }
1549
- * }, { arrayFilters: [{ 'elem._id': itemId }] });
1550
- */
1551
- repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
1552
- const validOperators = new Set([
1553
- "$inc",
1554
- "$set",
1555
- "$unset",
1556
- "$push",
1557
- "$pull",
1558
- "$addToSet",
1559
- "$pop",
1560
- "$rename",
1561
- "$min",
1562
- "$max",
1563
- "$mul",
1564
- "$setOnInsert",
1565
- "$bit",
1566
- "$currentDate"
1567
- ]);
1568
- const keys = Object.keys(operators);
1569
- if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
1570
- for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
1571
- return this.update(id, operators, options);
1572
- });
1573
- }
1574
- };
1575
- }
1576
- //#endregion
1577
- //#region src/plugins/multi-tenant.plugin.ts
1578
- /**
1579
- * Multi-Tenant Plugin
1580
- *
1581
- * Automatically injects tenant isolation filters into all queries.
1582
- * Ensures data isolation by adding organizationId (or custom tenant field)
1583
- * to every read and write operation.
1584
- *
1585
- * Uses HOOK_PRIORITY.POLICY (100) to ensure tenant filters are injected
1586
- * BEFORE cache keys are computed (HOOK_PRIORITY.CACHE = 200).
1587
- *
1588
- * @example
1589
- * ```typescript
1590
- * // Basic — scopes every operation by organizationId from context
1591
- * const repo = new Repository(Invoice, [
1592
- * multiTenantPlugin({ tenantField: 'organizationId' }),
1593
- * ]);
1594
- *
1595
- * const invoices = await repo.getAll(
1596
- * { filters: { status: 'paid' } },
1597
- * { organizationId: 'org_123' }
1598
- * );
1599
- * // Actual query: { status: 'paid', organizationId: 'org_123' }
1600
- *
1601
- * // Super admin bypass — skip scoping based on context
1602
- * const repo = new Repository(Invoice, [
1603
- * multiTenantPlugin({
1604
- * tenantField: 'organizationId',
1605
- * skipWhen: (context) => context.role === 'superadmin',
1606
- * }),
1607
- * ]);
1608
- *
1609
- * // Admin sees all orgs
1610
- * await repo.getAll({ page: 1, limit: 10 }, { role: 'superadmin' });
1611
- *
1612
- * // Automatic context — resolve tenant from AsyncLocalStorage
1613
- * const repo = new Repository(Invoice, [
1614
- * multiTenantPlugin({
1615
- * tenantField: 'organizationId',
1616
- * resolveContext: () => asyncLocalStorage.getStore()?.tenantId,
1617
- * }),
1618
- * ]);
1619
- * ```
1620
- */
1621
- function multiTenantPlugin(options = {}) {
1622
- const { tenantField = "organizationId", contextKey = "organizationId", required = true, skipOperations = [], skipWhen, resolveContext, fieldType = "string", allowDataInjection = false } = options;
1623
- const extraOps = [{
1624
- op: "getDeleted",
1625
- policyKey: "filters"
1626
- }];
1627
- return {
1628
- name: "multi-tenant",
1629
- apply(repo) {
1630
- const allOps = [...ALL_OPERATIONS.map((op) => ({
1631
- op,
1632
- policyKey: OP_REGISTRY[op].policyKey
1633
- })), ...extraOps];
1634
- for (const { op, policyKey } of allOps) {
1635
- if (skipOperations.includes(op)) continue;
1636
- if (policyKey === "none") continue;
1637
- repo.on(`before:${op}`, (context) => {
1638
- const perCallBypass = context.bypassTenant === true;
1639
- const callbackBypass = !perCallBypass && skipWhen?.(context, op) === true;
1640
- if (perCallBypass || callbackBypass) {
1641
- repo.emit("after:tenant-bypass", {
1642
- context,
1643
- operation: op,
1644
- reason: perCallBypass ? "option" : "callback"
1645
- });
1646
- return;
1647
- }
1648
- let tenantId = context[contextKey];
1649
- if (!tenantId && resolveContext) {
1650
- tenantId = resolveContext();
1651
- if (tenantId) context[contextKey] = tenantId;
1652
- }
1653
- if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
1654
- if (!tenantId && required) throw new Error([
1655
- `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1656
- "",
1657
- "Three ways to provide it — pick the one that fits the call site:",
1658
- "",
1659
- ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1660
- " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1661
- " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1662
- " then every repo call inside the closure inherits the",
1663
- " tenant with no options-bag plumbing. See",
1664
- " `createTenantContext` in @classytic/mongokit.",
1665
- " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1666
- " support), or `skipWhen: adminBypass({...})` at plugin",
1667
- " construction for an always-on role-based bypass.",
1668
- "",
1669
- "Or set `required: false` if this collection is genuinely not tenant-scoped."
1670
- ].join("\n"));
1671
- if (!tenantId) return;
1672
- const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1673
- switch (policyKey) {
1674
- case "filters":
1675
- context.filters = {
1676
- ...context.filters,
1677
- [tenantField]: castId
1678
- };
1679
- break;
1680
- case "query":
1681
- context.query = {
1682
- ...context.query,
1683
- [tenantField]: castId
1684
- };
1685
- break;
1686
- case "data":
1687
- if (context.data) context.data[tenantField] = castId;
1688
- break;
1689
- case "dataArray":
1690
- if (context.dataArray) {
1691
- for (const doc of context.dataArray) if (doc && typeof doc === "object") doc[tenantField] = castId;
1692
- }
1693
- break;
1694
- case "operations":
1695
- if (context.operations) {
1696
- const ops = context.operations;
1697
- for (const subOp of ops) {
1698
- for (const key of [
1699
- "updateOne",
1700
- "updateMany",
1701
- "deleteOne",
1702
- "deleteMany",
1703
- "replaceOne"
1704
- ]) {
1705
- const opBody = subOp[key];
1706
- if (opBody?.filter) opBody.filter = {
1707
- ...opBody.filter,
1708
- [tenantField]: castId
1709
- };
1710
- }
1711
- const insertBody = subOp.insertOne;
1712
- if (insertBody?.document) insertBody.document[tenantField] = castId;
1713
- }
1714
- }
1715
- break;
1716
- }
1717
- }, { priority: HOOK_PRIORITY.POLICY });
1718
- }
2026
+ * ```
2027
+ */
2028
+ repo.registerMethod("incrementIfBelow", async function(id, field, ceiling, by = 1, options = {}) {
2029
+ if (typeof ceiling !== "number") throw createError(400, "incrementIfBelow ceiling must be a number");
2030
+ if (typeof by !== "number") throw createError(400, "incrementIfBelow `by` must be a number");
2031
+ if (typeof field !== "string" || field.length === 0) throw createError(400, "incrementIfBelow field must be a non-empty string");
2032
+ for (const segment of field.split(".")) {
2033
+ if (segment.length === 0) throw createError(400, `incrementIfBelow field has an empty segment ("${field}")`);
2034
+ if (segment.startsWith("$")) throw createError(400, `incrementIfBelow field segment cannot start with '$' ("${segment}") — Mongo would treat it as an operator`);
2035
+ if (segment === "__proto__" || segment === "constructor" || segment === "prototype") throw createError(400, `incrementIfBelow field segment "${segment}" is reserved (prototype-pollution guard)`);
2036
+ }
2037
+ const idField = this.idField || "_id";
2038
+ return this.findOneAndUpdate({
2039
+ [idField]: id,
2040
+ [field]: { $lt: ceiling }
2041
+ }, { $inc: { [field]: by } }, {
2042
+ ...options,
2043
+ returnDocument: "after"
2044
+ });
2045
+ });
2046
+ /**
2047
+ * Atomic update with multiple MongoDB operators in a single call
2048
+ *
2049
+ * Combines $inc, $set, $push, $pull, $addToSet, $unset, $setOnInsert, $min, $max, $mul, $rename
2050
+ * into one atomic database operation.
2051
+ *
2052
+ * @example
2053
+ * // Combine $inc + $set in one atomic call
2054
+ * await repo.atomicUpdate(id, {
2055
+ * $inc: { views: 1, commentCount: 1 },
2056
+ * $set: { lastActiveAt: new Date() }
2057
+ * });
2058
+ *
2059
+ * // Multiple operators: $inc + $set + $push
2060
+ * await repo.atomicUpdate(id, {
2061
+ * $inc: { 'metrics.total': 1 },
2062
+ * $set: { updatedAt: new Date() },
2063
+ * $push: { history: { action: 'update', at: new Date() } }
2064
+ * });
2065
+ *
2066
+ * // $push with $each modifier
2067
+ * await repo.atomicUpdate(id, {
2068
+ * $push: { tags: { $each: ['featured', 'popular'] } },
2069
+ * $inc: { tagCount: 2 }
2070
+ * });
2071
+ *
2072
+ * // With arrayFilters for positional updates
2073
+ * await repo.atomicUpdate(id, {
2074
+ * $set: { 'items.$[elem].quantity': 5 }
2075
+ * }, { arrayFilters: [{ 'elem._id': itemId }] });
2076
+ */
2077
+ repo.registerMethod("atomicUpdate", async function(id, operators, options = {}) {
2078
+ const validOperators = new Set([
2079
+ "$inc",
2080
+ "$set",
2081
+ "$unset",
2082
+ "$push",
2083
+ "$pull",
2084
+ "$addToSet",
2085
+ "$pop",
2086
+ "$rename",
2087
+ "$min",
2088
+ "$max",
2089
+ "$mul",
2090
+ "$setOnInsert",
2091
+ "$bit",
2092
+ "$currentDate"
2093
+ ]);
2094
+ const keys = Object.keys(operators);
2095
+ if (keys.length === 0) throw createError(400, "atomicUpdate requires at least one operator");
2096
+ for (const key of keys) if (!validOperators.has(key)) throw createError(400, `Invalid update operator: '${key}'. Valid operators: ${[...validOperators].join(", ")}`);
2097
+ return this.update(id, operators, options);
2098
+ });
1719
2099
  }
1720
2100
  };
1721
2101
  }
1722
- /**
1723
- * Build the canonical `skipWhen` callback for role-based tenant
1724
- * bypass — replaces the hand-rolled
1725
- * `(ctx) => ctx.role === 'superadmin'` callback that ~6 packages
1726
- * across the classytic codebase carry as boilerplate. Eliminates the
1727
- * "I forgot to include 'platform_admin'" drift class — the role
1728
- * vocabulary lives in one place.
1729
- *
1730
- * **Composes with `bypassTenant: true` per-call**, doesn't replace
1731
- * it. `bypassTenant` is the deliberate per-call form for migration
1732
- * scripts and one-off admin queries; `adminBypass` is the
1733
- * always-on role-based bypass for users whose JWT / session carries
1734
- * a privileged role.
1735
- *
1736
- * **NOT a substitute for authentication or RBAC.** This factory only
1737
- * answers "should the tenant scope hook fire for this call?" — it
1738
- * does NOT validate that the caller is authenticated, that their
1739
- * role claim is genuine, or that the requested operation is
1740
- * permitted. Always run auth + RBAC before the call reaches the
1741
- * repository layer; this is the LAST gate, not the only one.
1742
- *
1743
- * @example Single role
1744
- * ```ts
1745
- * import { adminBypass, multiTenantPlugin } from '@classytic/mongokit';
1746
- *
1747
- * const repo = new Repository(InvoiceModel, [
1748
- * multiTenantPlugin({
1749
- * tenantField: 'organizationId',
1750
- * skipWhen: adminBypass({ adminRoles: ['superadmin'] }),
1751
- * }),
1752
- * ]);
1753
- *
1754
- * await repo.getAll({}, { role: 'superadmin' }); // sees all orgs
1755
- * await repo.getAll({}, { role: 'user', organizationId: 'org-1' }); // scoped
1756
- * ```
1757
- *
1758
- * @example Multiple admin roles + custom field name
1759
- * ```ts
1760
- * skipWhen: adminBypass({
1761
- * roleField: 'principalRole', // ctx.principalRole, not ctx.role
1762
- * adminRoles: ['superadmin', 'platform_admin', 'support'],
1763
- * }),
1764
- * ```
1765
- *
1766
- * @example Composing with custom logic
1767
- * ```ts
1768
- * // adminBypass for the role check, then your own additional logic:
1769
- * const adminCheck = adminBypass({ adminRoles: ['superadmin'] });
1770
- * skipWhen: (ctx, op) => adminCheck(ctx, op) || ctx.internalSystemFlag === true,
1771
- * ```
1772
- *
1773
- * @param options.roleField - Path on the context to read the role
1774
- * from. Defaults to `'role'`. Top-level keys only — for nested
1775
- * shapes (e.g. `ctx.user.role`), wrap your own `(ctx) => …` callback
1776
- * instead.
1777
- * @param options.adminRoles - Role values that grant the bypass. The
1778
- * factory does an exact-match `includes` check — case-sensitive,
1779
- * no fuzzy matching. Lowercase your role vocabulary upstream.
1780
- * @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
1781
- */
1782
- function adminBypass$1(options) {
1783
- return adminBypass(options);
1784
- }
1785
2102
  //#endregion
1786
2103
  //#region src/plugins/observability.plugin.ts
1787
2104
  /**
@@ -1798,305 +2115,62 @@ function adminBypass$1(options) {
1798
2115
  * // Send to DataDog, New Relic, OpenTelemetry, or console
1799
2116
  * console.log(`${metric.operation} took ${metric.durationMs}ms`);
1800
2117
  * statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
1801
- * },
1802
- * }),
1803
- * ]);
1804
- * ```
1805
- */
1806
- const DEFAULT_OPS = ALL_OPERATIONS;
1807
- const timers = /* @__PURE__ */ new WeakMap();
1808
- function observabilityPlugin(options) {
1809
- const { onMetric, slowThresholdMs } = options;
1810
- const ops = options.operations ?? DEFAULT_OPS;
1811
- const isSkipped = (context) => {
1812
- const list = context.skipPlugins;
1813
- return Array.isArray(list) && list.includes("observability");
1814
- };
1815
- return {
1816
- name: "observability",
1817
- apply(repo) {
1818
- for (const op of ops) {
1819
- repo.on(`before:${op}`, (context) => {
1820
- if (isSkipped(context)) return;
1821
- timers.set(context, performance.now());
1822
- }, { priority: 300 });
1823
- repo.on(`after:${op}`, ({ context }) => {
1824
- if (isSkipped(context)) return;
1825
- const start = timers.get(context);
1826
- if (start == null) return;
1827
- const durationMs = Math.round((performance.now() - start) * 100) / 100;
1828
- timers.delete(context);
1829
- if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
1830
- onMetric({
1831
- operation: op,
1832
- model: context.model || repo.model,
1833
- durationMs,
1834
- success: true,
1835
- startedAt: new Date(Date.now() - durationMs),
1836
- userId: context.user?._id?.toString() || context.user?.id?.toString(),
1837
- organizationId: context.organizationId?.toString()
1838
- });
1839
- });
1840
- repo.on(`error:${op}`, ({ context, error }) => {
1841
- if (isSkipped(context)) return;
1842
- const start = timers.get(context);
1843
- if (start == null) return;
1844
- const durationMs = Math.round((performance.now() - start) * 100) / 100;
1845
- timers.delete(context);
1846
- onMetric({
1847
- operation: op,
1848
- model: context.model || repo.model,
1849
- durationMs,
1850
- success: false,
1851
- error: error.message,
1852
- startedAt: new Date(Date.now() - durationMs),
1853
- userId: context.user?._id?.toString() || context.user?.id?.toString(),
1854
- organizationId: context.organizationId?.toString()
1855
- });
1856
- });
1857
- }
1858
- }
1859
- };
1860
- }
1861
- //#endregion
1862
- //#region src/plugins/soft-delete.plugin.ts
1863
- /**
1864
- * Build filter condition based on filter mode
1865
- */
1866
- function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
1867
- if (includeDeleted) return {};
1868
- if (filterMode === "exists") return { [deletedField]: { $exists: false } };
1869
- return { [deletedField]: null };
1870
- }
1871
- /**
1872
- * Build filter condition for finding deleted documents
1873
- */
1874
- function buildGetDeletedFilter(deletedField, filterMode) {
1875
- if (filterMode === "exists") return { [deletedField]: {
1876
- $exists: true,
1877
- $ne: null
1878
- } };
1879
- return { [deletedField]: { $ne: null } };
1880
- }
1881
- /**
1882
- * Soft delete plugin
1883
- *
1884
- * @example Basic usage
1885
- * ```typescript
1886
- * const repo = new Repository(Model, [
1887
- * softDeletePlugin({ deletedField: 'deletedAt' })
1888
- * ]);
1889
- *
1890
- * // Delete (soft)
1891
- * await repo.delete(id);
1892
- *
1893
- * // Restore
1894
- * await repo.restore(id);
1895
- *
1896
- * // Get deleted documents
1897
- * await repo.getDeleted({ page: 1, limit: 20 });
1898
- * ```
1899
- *
1900
- * @example With null filter mode (for schemas with default: null)
1901
- * ```typescript
1902
- * // Schema: { deletedAt: { type: Date, default: null } }
1903
- * const repo = new Repository(Model, [
1904
- * softDeletePlugin({
1905
- * deletedField: 'deletedAt',
1906
- * filterMode: 'null', // default - works with default: null
1907
- * })
1908
- * ]);
1909
- * ```
1910
- *
1911
- * @example With TTL for auto-cleanup
1912
- * ```typescript
1913
- * const repo = new Repository(Model, [
1914
- * softDeletePlugin({
1915
- * deletedField: 'deletedAt',
1916
- * ttlDays: 30, // Auto-delete after 30 days
1917
- * })
1918
- * ]);
1919
- * ```
1920
- */
1921
- function softDeletePlugin(options = {}) {
1922
- const deletedField = options.deletedField || "deletedAt";
1923
- const deletedByField = options.deletedByField || "deletedBy";
1924
- const filterMode = options.filterMode || "null";
1925
- const addRestoreMethod = options.addRestoreMethod !== false;
1926
- const addGetDeletedMethod = options.addGetDeletedMethod !== false;
1927
- const ttlDays = options.ttlDays;
2118
+ * },
2119
+ * }),
2120
+ * ]);
2121
+ * ```
2122
+ */
2123
+ const DEFAULT_OPS = ALL_OPERATIONS;
2124
+ const timers = /* @__PURE__ */ new WeakMap();
2125
+ function observabilityPlugin(options) {
2126
+ const { onMetric, slowThresholdMs } = options;
2127
+ const ops = options.operations ?? DEFAULT_OPS;
2128
+ const isSkipped = (context) => {
2129
+ const list = context.skipPlugins;
2130
+ return Array.isArray(list) && list.includes("observability");
2131
+ };
1928
2132
  return {
1929
- name: "softDelete",
2133
+ name: "observability",
1930
2134
  apply(repo) {
1931
- try {
1932
- const schemaPaths = repo.Model.schema.paths;
1933
- for (const [pathName, schemaType] of Object.entries(schemaPaths)) {
1934
- if (pathName === "_id" || pathName === deletedField) continue;
1935
- if (schemaType.options?.unique) warn(`[softDeletePlugin] Field '${pathName}' on model '${repo.Model.modelName}' has a unique index. With soft-delete enabled, deleted documents will block new documents with the same '${pathName}'. Fix: change to a compound partial index — { ${pathName}: 1 }, { unique: true, partialFilterExpression: { ${deletedField}: null } }`);
1936
- }
1937
- } catch (err) {
1938
- warn(`[softDeletePlugin] Schema introspection failed for ${repo.Model.modelName}: ${err instanceof Error ? err.message : String(err)}`);
1939
- }
1940
- if (ttlDays !== void 0 && ttlDays > 0) {
1941
- const ttlSeconds = ttlDays * 24 * 60 * 60;
1942
- repo.Model.collection.createIndex({ [deletedField]: 1 }, {
1943
- expireAfterSeconds: ttlSeconds,
1944
- partialFilterExpression: { [deletedField]: { $type: "date" } }
1945
- }).catch((err) => {
1946
- if (err.code !== 85 && err.code !== 86 && !err.message.includes("already exists")) warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
1947
- });
1948
- }
1949
- repo.on("before:delete", async (context) => {
1950
- if (context.deleteMode === "hard") return;
1951
- if (options.soft !== false) {
1952
- const updateData = { [deletedField]: /* @__PURE__ */ new Date() };
1953
- if (context.user) updateData[deletedByField] = context.user._id || context.user.id;
1954
- const deleteQuery = {
1955
- [repo.idField || "_id"]: context.id,
1956
- ...context.query || {}
1957
- };
1958
- if (!await repo.Model.findOneAndUpdate(deleteQuery, updateData, { session: context.session })) {
1959
- const error = /* @__PURE__ */ new Error(`Document with id '${context.id}' not found`);
1960
- error.status = 404;
1961
- throw error;
1962
- }
1963
- context.softDeleted = true;
1964
- }
1965
- }, { priority: HOOK_PRIORITY.POLICY });
1966
- const injectDeleteFilterToFilters = (context) => {
1967
- if (options.soft !== false) {
1968
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1969
- if (Object.keys(deleteFilter).length > 0) context.filters = {
1970
- ...context.filters || {},
1971
- ...deleteFilter
1972
- };
1973
- }
1974
- };
1975
- const injectDeleteFilterToQuery = (context) => {
1976
- if (options.soft !== false) {
1977
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
1978
- if (Object.keys(deleteFilter).length > 0) context.query = {
1979
- ...context.query || {},
1980
- ...deleteFilter
1981
- };
1982
- }
1983
- };
1984
- const SOFT_DELETE_SPECIAL = new Set([
1985
- "updateMany",
1986
- "deleteMany",
1987
- "bulkWrite"
1988
- ]);
1989
- for (const op of ALL_OPERATIONS) {
1990
- if (SOFT_DELETE_SPECIAL.has(op)) continue;
1991
- const desc = OP_REGISTRY[op];
1992
- const isFilterableMutation = op === "findOneAndUpdate";
1993
- if (desc.mutates && !isFilterableMutation) continue;
1994
- if (desc.policyKey === "filters") repo.on(`before:${op}`, injectDeleteFilterToFilters, { priority: HOOK_PRIORITY.POLICY });
1995
- else if (desc.policyKey === "query") repo.on(`before:${op}`, injectDeleteFilterToQuery, { priority: HOOK_PRIORITY.POLICY });
1996
- }
1997
- repo.on("before:updateMany", (context) => {
1998
- if (options.soft !== false) {
1999
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
2000
- if (Object.keys(deleteFilter).length > 0) context.query = {
2001
- ...context.query || {},
2002
- ...deleteFilter
2003
- };
2004
- }
2005
- }, { priority: HOOK_PRIORITY.POLICY });
2006
- repo.on("before:deleteMany", async (context) => {
2007
- if (context.deleteMode === "hard") return;
2008
- if (options.soft !== false) {
2009
- const deleteFilter = buildDeletedFilter(deletedField, filterMode, false);
2010
- const finalQuery = {
2011
- ...context.query || {},
2012
- ...deleteFilter
2013
- };
2014
- const updateRes = await repo.Model.updateMany(finalQuery, { $set: { [deletedField]: /* @__PURE__ */ new Date() } }, { session: context.session });
2015
- context.softDeleted = true;
2016
- context.softDeletedCount = updateRes?.modifiedCount ?? 0;
2017
- }
2018
- }, { priority: HOOK_PRIORITY.POLICY });
2019
- if (addRestoreMethod) {
2020
- const restoreMethod = async function(id, restoreOptions = {}) {
2021
- const context = await this._buildContext("restore", {
2022
- id,
2023
- ...restoreOptions
2024
- });
2025
- const updateData = {
2026
- [deletedField]: null,
2027
- [deletedByField]: null
2028
- };
2029
- const restoreQuery = {
2030
- _id: id,
2031
- ...context.query || {}
2032
- };
2033
- const result = await this.Model.findOneAndUpdate(restoreQuery, { $set: updateData }, {
2034
- returnDocument: "after",
2035
- session: restoreOptions.session
2036
- });
2037
- if (!result) {
2038
- const error = /* @__PURE__ */ new Error(`Document with id '${id}' not found`);
2039
- error.status = 404;
2040
- throw error;
2041
- }
2042
- await this.emitAsync("after:restore", {
2043
- context,
2044
- result
2135
+ for (const op of ops) {
2136
+ repo.on(`before:${op}`, (context) => {
2137
+ if (isSkipped(context)) return;
2138
+ timers.set(context, performance.now());
2139
+ }, { priority: 300 });
2140
+ repo.on(`after:${op}`, ({ context }) => {
2141
+ if (isSkipped(context)) return;
2142
+ const start = timers.get(context);
2143
+ if (start == null) return;
2144
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
2145
+ timers.delete(context);
2146
+ if (slowThresholdMs != null && durationMs < slowThresholdMs) return;
2147
+ onMetric({
2148
+ operation: op,
2149
+ model: context.model || repo.model,
2150
+ durationMs,
2151
+ success: true,
2152
+ startedAt: new Date(Date.now() - durationMs),
2153
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
2154
+ organizationId: context.organizationId?.toString()
2045
2155
  });
2046
- return result;
2047
- };
2048
- if (typeof repo.registerMethod === "function") repo.registerMethod("restore", restoreMethod);
2049
- else repo.restore = restoreMethod.bind(repo);
2050
- }
2051
- if (addGetDeletedMethod) {
2052
- const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
2053
- const context = await this._buildContext("getDeleted", {
2054
- ...params,
2055
- ...getDeletedOptions
2156
+ });
2157
+ repo.on(`error:${op}`, ({ context, error }) => {
2158
+ if (isSkipped(context)) return;
2159
+ const start = timers.get(context);
2160
+ if (start == null) return;
2161
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
2162
+ timers.delete(context);
2163
+ onMetric({
2164
+ operation: op,
2165
+ model: context.model || repo.model,
2166
+ durationMs,
2167
+ success: false,
2168
+ error: error.message,
2169
+ startedAt: new Date(Date.now() - durationMs),
2170
+ userId: context.user?._id?.toString() || context.user?.id?.toString(),
2171
+ organizationId: context.organizationId?.toString()
2056
2172
  });
2057
- const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
2058
- const combinedFilters = {
2059
- ...params.filters || {},
2060
- ...deletedFilter,
2061
- ...context.filters || {},
2062
- ...context.query || {}
2063
- };
2064
- const page = params.page || 1;
2065
- const limit = params.limit || 20;
2066
- const skip = (page - 1) * limit;
2067
- let sortSpec = { [deletedField]: -1 };
2068
- if (params.sort) if (typeof params.sort === "string") {
2069
- const sortOrder = params.sort.startsWith("-") ? -1 : 1;
2070
- sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
2071
- } else sortSpec = params.sort;
2072
- let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
2073
- if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
2074
- if (getDeletedOptions.select) {
2075
- const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
2076
- query = query.select(selectValue);
2077
- }
2078
- if (getDeletedOptions.populate) {
2079
- const populateSpec = getDeletedOptions.populate;
2080
- if (typeof populateSpec === "string") query = query.populate(populateSpec.split(",").map((p) => p.trim()));
2081
- else if (Array.isArray(populateSpec)) query = query.populate(populateSpec);
2082
- else query = query.populate(populateSpec);
2083
- }
2084
- if (getDeletedOptions.lean !== false) query = query.lean();
2085
- const [data, total] = await Promise.all([query.exec(), this.Model.countDocuments(combinedFilters)]);
2086
- const pages = Math.ceil(total / limit);
2087
- return {
2088
- method: "offset",
2089
- data,
2090
- page,
2091
- limit,
2092
- total,
2093
- pages,
2094
- hasNext: page < pages,
2095
- hasPrev: page > 1
2096
- };
2097
- };
2098
- if (typeof repo.registerMethod === "function") repo.registerMethod("getDeleted", getDeletedMethod);
2099
- else repo.getDeleted = getDeletedMethod.bind(repo);
2173
+ });
2100
2174
  }
2101
2175
  }
2102
2176
  };
@@ -2223,45 +2297,6 @@ function createTenantContext() {
2223
2297
  };
2224
2298
  }
2225
2299
  //#endregion
2226
- //#region src/plugins/timestamp.plugin.ts
2227
- /**
2228
- * Timestamp plugin that auto-injects timestamps
2229
- *
2230
- * @example
2231
- * const repo = new Repository(Model, [timestampPlugin()]);
2232
- */
2233
- function timestampPlugin() {
2234
- return {
2235
- name: "timestamp",
2236
- apply(repo) {
2237
- repo.on("before:create", (context) => {
2238
- if (!context.data) return;
2239
- const now = /* @__PURE__ */ new Date();
2240
- if (!context.data.createdAt) context.data.createdAt = now;
2241
- if (!context.data.updatedAt) context.data.updatedAt = now;
2242
- });
2243
- repo.on("before:update", (context) => {
2244
- if (!context.data) return;
2245
- context.data.updatedAt = /* @__PURE__ */ new Date();
2246
- });
2247
- repo.on("before:findOneAndUpdate", (context) => {
2248
- if (!context.data) return;
2249
- const now = /* @__PURE__ */ new Date();
2250
- if (Array.isArray(context.data)) return;
2251
- const update = context.data;
2252
- if (Object.keys(update).some((k) => k.startsWith("$"))) {
2253
- const set = update.$set || {};
2254
- set.updatedAt = now;
2255
- update.$set = set;
2256
- const setOnInsert = update.$setOnInsert || {};
2257
- if (!("createdAt" in setOnInsert)) setOnInsert.createdAt = now;
2258
- update.$setOnInsert = setOnInsert;
2259
- } else update.updatedAt = now;
2260
- });
2261
- }
2262
- };
2263
- }
2264
- //#endregion
2265
2300
  //#region src/plugins/validation-chain.plugin.ts
2266
2301
  /**
2267
2302
  * Validation chain plugin
@@ -2412,4 +2447,4 @@ function uniqueField(field, errorMessage) {
2412
2447
  };
2413
2448
  }
2414
2449
  //#endregion
2415
- export { aggregateHelpersPlugin as A, sequentialId as C, AuditTrailQuery as D, batchOperationsPlugin as E, auditTrailPlugin as O, prefixedId as S, cachePlugin as T, fieldFilterPlugin as _, uniqueField as a, dateSequentialId as b, createTenantContext as c, observabilityPlugin as d, adminBypass$1 as f, leasePlugin as g, methodRegistryPlugin as h, requireField as i, auditLogPlugin as k, subdocumentPlugin as l, mongoOperationsPlugin as m, blockIf as n, validationChainPlugin as o, multiTenantPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, elasticSearchPlugin as v, cascadePlugin as w, getNextSequence as x, customIdPlugin as y };
2450
+ export { auditLogPlugin as A, getNextSequence as C, batchOperationsPlugin as D, cachePlugin as E, AuditTrailQuery as O, dateSequentialId as S, sequentialId as T, softDeletePlugin as _, uniqueField as a, methodRegistryPlugin as b, subdocumentPlugin as c, leasePlugin as d, fieldFilterPlugin as f, timestampPlugin as g, aggregateHelpersPlugin as h, requireField as i, auditTrailPlugin as k, observabilityPlugin as l, cascadePlugin as m, blockIf as n, validationChainPlugin as o, elasticSearchPlugin as p, immutableField as r, createTenantContext as s, autoInject as t, mongoOperationsPlugin as u, adminBypass$1 as v, prefixedId as w, customIdPlugin as x, multiTenantPlugin as y };