@classytic/mongokit 3.15.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.
- package/README.md +193 -319
- package/dist/{PaginationEngine-CfQRM1yz.mjs → PaginationEngine-0XXTW-9b.mjs} +2 -2
- package/dist/{Repository-BGL1Pq8w.mjs → Repository-DE7D2QeS.mjs} +240 -19
- package/dist/actions/index.d.mts +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/ai/index.mjs +14 -5
- package/dist/better-auth/index.d.mts +15 -0
- package/dist/better-auth/index.mjs +1 -1
- package/dist/{index-Dccha9dw.d.mts → index-CV5vv-8E.d.mts} +1 -1
- package/dist/index.d.mts +1182 -1079
- package/dist/index.mjs +32 -19
- package/dist/{mongooseToJsonSchema-BYbG894y.mjs → mongooseToJsonSchema-Bi0q0pYW.mjs} +1 -1
- package/dist/{mongooseToJsonSchema-BgRAaXX-.d.mts → mongooseToJsonSchema-GhvZUjRO.d.mts} +1 -1
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/{types-BbaPZleN.d.mts → types-BWZILovv.d.mts} +26 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +3 -3
- package/dist/{validation-chain.plugin-CWhvBRej.mjs → validation-chain.plugin-Crl5nQAY.mjs} +1172 -1137
- package/dist/{validation-chain.plugin-CAPYCj_T.d.mts → validation-chain.plugin-Iee2_puD.d.mts} +368 -344
- package/package.json +6 -6
- /package/dist/{cursor-J84rR6wG.mjs → cursor-4LMYa4ga.mjs} +0 -0
- /package/dist/{field-selection-Br3WPOEb.mjs → field-selection-CQW3WBiK.mjs} +0 -0
- /package/dist/{id-resolution-B0Odvvou.mjs → id-resolution-DduoO4LI.mjs} +0 -0
- /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-
|
|
5
|
-
import { r as getFieldsForUser } from "./field-selection-
|
|
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/
|
|
568
|
+
//#region src/plugins/custom-id.plugin.ts
|
|
632
569
|
/**
|
|
633
|
-
*
|
|
570
|
+
* Custom ID Plugin
|
|
634
571
|
*
|
|
635
|
-
*
|
|
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
|
-
*
|
|
576
|
+
* Uses MongoDB's atomic `findOneAndUpdate` with `$inc` on a dedicated
|
|
577
|
+
* counters collection — guaranteeing no duplicate IDs under concurrency.
|
|
638
578
|
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
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
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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
|
-
* @
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
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
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
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
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
*
|
|
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
|
|
695
|
-
const {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
*
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|
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
|
-
*
|
|
858
|
-
*
|
|
793
|
+
* @param options - Configuration for ID generation
|
|
794
|
+
* @returns Plugin instance
|
|
859
795
|
*
|
|
860
|
-
* @example
|
|
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
|
-
*
|
|
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
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
*
|
|
973
|
-
* Produces IDs like `INV-0001`, `INV-0002`, etc.
|
|
879
|
+
* Multi-Tenant Plugin
|
|
974
880
|
*
|
|
975
|
-
*
|
|
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
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
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
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
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
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
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
|
|
1015
|
-
const {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
*
|
|
1043
|
-
* Does NOT require a database round-trip — purely in-memory.
|
|
1468
|
+
* Cascade Delete Plugin
|
|
1044
1469
|
*
|
|
1045
|
-
*
|
|
1470
|
+
* Automatically deletes related documents when a parent document is deleted.
|
|
1046
1471
|
*
|
|
1047
|
-
*
|
|
1048
|
-
* Not suitable for sequential numbering.
|
|
1472
|
+
* Two routing modes per relation:
|
|
1049
1473
|
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
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
|
-
*
|
|
1075
|
-
*
|
|
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
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
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
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
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
|
-
*
|
|
1089
|
-
*
|
|
1504
|
+
* @example Model-routed (legacy)
|
|
1505
|
+
* ```ts
|
|
1506
|
+
* cascadePlugin({
|
|
1507
|
+
* relations: [
|
|
1508
|
+
* { model: 'StockEntry', foreignKey: 'product' },
|
|
1509
|
+
* ],
|
|
1510
|
+
* });
|
|
1090
1511
|
* ```
|
|
1091
1512
|
*/
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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: "
|
|
1537
|
+
name: "cascade",
|
|
1097
1538
|
apply(repo) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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: "
|
|
2133
|
+
name: "observability",
|
|
1930
2134
|
apply(repo) {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
-
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|