@classytic/mongokit 3.18.0 → 3.19.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 +66 -0
- package/dist/PaginationEngine-4SjzfBQE.d.mts +103 -0
- package/dist/actions/index.d.mts +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +1 -1
- package/dist/create-repository-D5YX0wGs.mjs +29 -0
- package/dist/create-repository-K0yft2ht.d.mts +1663 -0
- package/dist/{index-fi91ycQc.d.mts → index-BgSBwlWu.d.mts} +1 -1
- package/dist/index.d.mts +11 -1665
- package/dist/index.mjs +5 -29
- package/dist/{mongooseToJsonSchema-BdQJU39w.d.mts → mongooseToJsonSchema-Bz47aMfH.d.mts} +1 -1
- package/dist/multi-tenant.plugin-D6lw3BsI.d.mts +487 -0
- package/dist/pagination/PaginationEngine.d.mts +1 -102
- package/dist/plugins/index.d.mts +3 -2
- package/dist/plugins/index.mjs +2 -1
- package/dist/query/primitives/coercion.d.mts +1 -1
- package/dist/query/primitives/indexes.d.mts +1 -1
- package/dist/testkit/index.d.mts +129 -0
- package/dist/testkit/index.mjs +139 -0
- package/dist/{validation-chain.plugin-DJtC0KJ-.mjs → timestamp.plugin-B1A9U-P7.mjs} +1 -1052
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +2 -2
- package/dist/{validation-chain.plugin-BpMfy18N.d.mts → validation-chain.plugin-CYOPfFGF.d.mts} +2 -486
- package/dist/validation-chain.plugin-DHZbpb4t.mjs +1056 -0
- package/package.json +9 -1
- /package/dist/{adapter-DYEMkLwK.mjs → adapter-BSxhT0Ip.mjs} +0 -0
- /package/dist/{coercion-TRMmR49h.d.mts → coercion-BxxHyLjP.d.mts} +0 -0
- /package/dist/{field-selection-CQW3WBiK.mjs → field-selection-d1qdRiMr.mjs} +0 -0
- /package/dist/{indexes-CgOF7Wjd.d.mts → indexes-3eHBWNZ2.d.mts} +0 -0
- /package/dist/{mongooseToJsonSchema-CBEQmeEa.mjs → mongooseToJsonSchema-ChtXSrLT.mjs} +0 -0
- /package/dist/{types-CU3SEDbD.d.mts → types-BA8oXjFe.d.mts} +0 -0
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-B01VW_fU.mjs";
|
|
2
|
-
import { _ as upsert } from "./update-CvCMv5Cl.mjs";
|
|
3
2
|
import { n as warn } from "./logger-B8wf-Mtb.mjs";
|
|
4
3
|
import { t as createError } from "./error-BTeepSNL.mjs";
|
|
5
|
-
import { r as getFieldsForUser } from "./field-selection-CQW3WBiK.mjs";
|
|
6
4
|
import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
|
|
7
5
|
import mongoose from "mongoose";
|
|
8
6
|
import { cachePlugin } from "@classytic/repo-core/cache";
|
|
9
7
|
import { adminBypass, payloadHasTenantField } from "@classytic/repo-core/plugins";
|
|
10
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
8
|
//#region src/plugins/audit-log.plugin.ts
|
|
12
9
|
/**
|
|
13
10
|
* Audit log plugin that logs all repository operations
|
|
@@ -1399,1052 +1396,4 @@ function timestampPlugin() {
|
|
|
1399
1396
|
};
|
|
1400
1397
|
}
|
|
1401
1398
|
//#endregion
|
|
1402
|
-
|
|
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
|
-
});
|
|
1462
|
-
}
|
|
1463
|
-
};
|
|
1464
|
-
}
|
|
1465
|
-
//#endregion
|
|
1466
|
-
//#region src/plugins/cascade.plugin.ts
|
|
1467
|
-
/**
|
|
1468
|
-
* Cascade Delete Plugin
|
|
1469
|
-
*
|
|
1470
|
-
* Automatically deletes related documents when a parent document is deleted.
|
|
1471
|
-
*
|
|
1472
|
-
* Two routing modes per relation:
|
|
1473
|
-
*
|
|
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.
|
|
1480
|
-
*
|
|
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.
|
|
1486
|
-
*
|
|
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.
|
|
1490
|
-
*
|
|
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
|
-
* ],
|
|
1500
|
-
* }),
|
|
1501
|
-
* ]);
|
|
1502
|
-
* ```
|
|
1503
|
-
*
|
|
1504
|
-
* @example Model-routed (legacy)
|
|
1505
|
-
* ```ts
|
|
1506
|
-
* cascadePlugin({
|
|
1507
|
-
* relations: [
|
|
1508
|
-
* { model: 'StockEntry', foreignKey: 'product' },
|
|
1509
|
-
* ],
|
|
1510
|
-
* });
|
|
1511
|
-
* ```
|
|
1512
|
-
*/
|
|
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
|
-
}
|
|
1536
|
-
return {
|
|
1537
|
-
name: "cascade",
|
|
1538
|
-
apply(repo) {
|
|
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;
|
|
1545
|
-
}
|
|
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);
|
|
1608
|
-
});
|
|
1609
|
-
}
|
|
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);
|
|
1682
|
-
}
|
|
1683
|
-
//#endregion
|
|
1684
|
-
//#region src/plugins/elastic.plugin.ts
|
|
1685
|
-
function elasticSearchPlugin(options) {
|
|
1686
|
-
return {
|
|
1687
|
-
name: "elastic-search",
|
|
1688
|
-
apply(repo) {
|
|
1689
|
-
if (!repo.registerMethod) throw new Error("[mongokit] elasticSearchPlugin requires methodRegistryPlugin to be registered first. Add methodRegistryPlugin() before elasticSearchPlugin() in your repository plugins array.");
|
|
1690
|
-
repo.registerMethod("search", async function(searchQuery, searchOptions = {}) {
|
|
1691
|
-
const { client, index, idField = "_id" } = options;
|
|
1692
|
-
const limit = Math.min(Math.max(searchOptions.limit || 20, 1), 1e3);
|
|
1693
|
-
const from = Math.max(searchOptions.from || 0, 0);
|
|
1694
|
-
const esResponse = await client.search({
|
|
1695
|
-
index,
|
|
1696
|
-
body: {
|
|
1697
|
-
query: searchQuery,
|
|
1698
|
-
size: limit,
|
|
1699
|
-
from
|
|
1700
|
-
}
|
|
1701
|
-
});
|
|
1702
|
-
const hits = esResponse.hits?.hits || esResponse.body?.hits?.hits || [];
|
|
1703
|
-
if (hits.length === 0) return {
|
|
1704
|
-
data: [],
|
|
1705
|
-
total: 0,
|
|
1706
|
-
limit,
|
|
1707
|
-
from
|
|
1708
|
-
};
|
|
1709
|
-
const totalValue = esResponse.hits?.total?.value ?? esResponse.hits?.total ?? esResponse.body?.hits?.total?.value ?? esResponse.body?.hits?.total ?? 0;
|
|
1710
|
-
const total = typeof totalValue === "number" ? totalValue : 0;
|
|
1711
|
-
const docsOrder = /* @__PURE__ */ new Map();
|
|
1712
|
-
const scores = /* @__PURE__ */ new Map();
|
|
1713
|
-
const ids = [];
|
|
1714
|
-
hits.forEach((hit, idx) => {
|
|
1715
|
-
const docId = hit._source?.[idField] || hit[idField] || hit._id;
|
|
1716
|
-
if (docId) {
|
|
1717
|
-
const strId = String(docId);
|
|
1718
|
-
docsOrder.set(strId, idx);
|
|
1719
|
-
if (hit._score !== void 0) scores.set(strId, hit._score);
|
|
1720
|
-
ids.push(strId);
|
|
1721
|
-
}
|
|
1722
|
-
});
|
|
1723
|
-
if (ids.length === 0) return {
|
|
1724
|
-
data: [],
|
|
1725
|
-
total,
|
|
1726
|
-
limit,
|
|
1727
|
-
from
|
|
1728
|
-
};
|
|
1729
|
-
const mongoIdField = repo.idField || "_id";
|
|
1730
|
-
const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
|
|
1731
|
-
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
1732
|
-
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
1733
|
-
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
1734
|
-
return {
|
|
1735
|
-
data: (await mongoQuery.exec()).sort((a, b) => {
|
|
1736
|
-
const aId = String(a[mongoIdField]);
|
|
1737
|
-
const bId = String(b[mongoIdField]);
|
|
1738
|
-
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
1739
|
-
}).map((doc) => {
|
|
1740
|
-
const strId = String(doc[mongoIdField]);
|
|
1741
|
-
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
1742
|
-
...doc,
|
|
1743
|
-
_score: scores.get(strId)
|
|
1744
|
-
};
|
|
1745
|
-
return doc;
|
|
1746
|
-
}),
|
|
1747
|
-
total,
|
|
1748
|
-
limit,
|
|
1749
|
-
from
|
|
1750
|
-
};
|
|
1751
|
-
});
|
|
1752
|
-
}
|
|
1753
|
-
};
|
|
1754
|
-
}
|
|
1755
|
-
//#endregion
|
|
1756
|
-
//#region src/plugins/field-filter.plugin.ts
|
|
1757
|
-
/**
|
|
1758
|
-
* Field filter plugin that restricts fields based on user context
|
|
1759
|
-
*
|
|
1760
|
-
* @example
|
|
1761
|
-
* const fieldPreset = {
|
|
1762
|
-
* public: ['id', 'name'],
|
|
1763
|
-
* authenticated: ['email'],
|
|
1764
|
-
* admin: ['createdAt', 'internalNotes']
|
|
1765
|
-
* };
|
|
1766
|
-
*
|
|
1767
|
-
* const repo = new Repository(Model, [fieldFilterPlugin(fieldPreset)]);
|
|
1768
|
-
*/
|
|
1769
|
-
function fieldFilterPlugin(fieldPreset) {
|
|
1770
|
-
return {
|
|
1771
|
-
name: "fieldFilter",
|
|
1772
|
-
apply(repo) {
|
|
1773
|
-
const applyFieldFiltering = (context) => {
|
|
1774
|
-
if (!fieldPreset) return;
|
|
1775
|
-
const presetSelect = getFieldsForUser(context.context?.user || context.user, fieldPreset).join(" ");
|
|
1776
|
-
if (context.select) context.select = `${presetSelect} ${context.select}`;
|
|
1777
|
-
else context.select = presetSelect;
|
|
1778
|
-
};
|
|
1779
|
-
repo.on("before:getAll", applyFieldFiltering);
|
|
1780
|
-
repo.on("before:findAll", applyFieldFiltering);
|
|
1781
|
-
repo.on("before:getById", applyFieldFiltering);
|
|
1782
|
-
repo.on("before:getByQuery", applyFieldFiltering);
|
|
1783
|
-
repo.on("before:getOne", applyFieldFiltering);
|
|
1784
|
-
}
|
|
1785
|
-
};
|
|
1786
|
-
}
|
|
1787
|
-
//#endregion
|
|
1788
|
-
//#region src/plugins/lease.plugin.ts
|
|
1789
|
-
function leasePlugin(options = {}) {
|
|
1790
|
-
const statusField = options.statusField ?? "status";
|
|
1791
|
-
const leasedByField = options.leasedByField ?? "leasedBy";
|
|
1792
|
-
const leaseExpiresAtField = options.leaseExpiresAtField ?? "leaseExpiresAt";
|
|
1793
|
-
const pendingStatus = options.pendingStatus ?? "pending";
|
|
1794
|
-
const processingStatus = options.processingStatus ?? "processing";
|
|
1795
|
-
const doneStatus = options.doneStatus ?? "done";
|
|
1796
|
-
const sort = options.sort ?? { createdAt: 1 };
|
|
1797
|
-
return {
|
|
1798
|
-
name: "lease",
|
|
1799
|
-
apply(repo) {
|
|
1800
|
-
if (!repo.registerMethod) throw new Error("leasePlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before leasePlugin().");
|
|
1801
|
-
/**
|
|
1802
|
-
* Atomic FIFO lease claim — the canonical
|
|
1803
|
-
* findOneAndUpdate({ status: pending, OR leaseExpiresAt < now },
|
|
1804
|
-
* { $set: { status: processing, leasedBy, leaseExpiresAt } },
|
|
1805
|
-
* { sort })
|
|
1806
|
-
* pattern, distilled.
|
|
1807
|
-
*/
|
|
1808
|
-
repo.registerMethod("lease", async function(opts) {
|
|
1809
|
-
if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.lease: leaseFor must be a positive number (ms)");
|
|
1810
|
-
if (typeof opts.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.lease: leasedBy must be a non-empty string");
|
|
1811
|
-
const now = /* @__PURE__ */ new Date();
|
|
1812
|
-
const leaseUntil = new Date(now.getTime() + opts.leaseFor);
|
|
1813
|
-
const claimFilter = {
|
|
1814
|
-
...opts.filter ?? {},
|
|
1815
|
-
$or: [{ [statusField]: pendingStatus }, { [leaseExpiresAtField]: { $lt: now } }]
|
|
1816
|
-
};
|
|
1817
|
-
return this.findOneAndUpdate(claimFilter, { $set: {
|
|
1818
|
-
[statusField]: processingStatus,
|
|
1819
|
-
[leasedByField]: opts.leasedBy,
|
|
1820
|
-
[leaseExpiresAtField]: leaseUntil
|
|
1821
|
-
} }, {
|
|
1822
|
-
...opts.options ?? {},
|
|
1823
|
-
sort,
|
|
1824
|
-
returnDocument: "after"
|
|
1825
|
-
});
|
|
1826
|
-
});
|
|
1827
|
-
repo.registerMethod("extend", async function(id, opts) {
|
|
1828
|
-
if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.extend: leaseFor must be a positive number (ms)");
|
|
1829
|
-
const now = /* @__PURE__ */ new Date();
|
|
1830
|
-
const leaseUntil = new Date(now.getTime() + opts.leaseFor);
|
|
1831
|
-
const idField = this.idField || "_id";
|
|
1832
|
-
return this.findOneAndUpdate({
|
|
1833
|
-
[idField]: id,
|
|
1834
|
-
[leasedByField]: opts.leasedBy,
|
|
1835
|
-
[leaseExpiresAtField]: { $gt: now }
|
|
1836
|
-
}, { $set: { [leaseExpiresAtField]: leaseUntil } }, {
|
|
1837
|
-
...opts.options ?? {},
|
|
1838
|
-
returnDocument: "after"
|
|
1839
|
-
});
|
|
1840
|
-
});
|
|
1841
|
-
repo.registerMethod("release", async function(id, opts) {
|
|
1842
|
-
if (typeof opts?.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.release: leasedBy must be a non-empty string (the holder finalising the lease)");
|
|
1843
|
-
const finalStatus = opts.finalStatus ?? doneStatus;
|
|
1844
|
-
const now = /* @__PURE__ */ new Date();
|
|
1845
|
-
const idField = this.idField || "_id";
|
|
1846
|
-
return this.findOneAndUpdate({
|
|
1847
|
-
[idField]: id,
|
|
1848
|
-
[leasedByField]: opts.leasedBy,
|
|
1849
|
-
[leaseExpiresAtField]: { $gt: now }
|
|
1850
|
-
}, {
|
|
1851
|
-
$set: { [statusField]: finalStatus },
|
|
1852
|
-
$unset: {
|
|
1853
|
-
[leasedByField]: "",
|
|
1854
|
-
[leaseExpiresAtField]: ""
|
|
1855
|
-
}
|
|
1856
|
-
}, {
|
|
1857
|
-
...opts.options ?? {},
|
|
1858
|
-
returnDocument: "after"
|
|
1859
|
-
});
|
|
1860
|
-
});
|
|
1861
|
-
}
|
|
1862
|
-
};
|
|
1863
|
-
}
|
|
1864
|
-
//#endregion
|
|
1865
|
-
//#region src/plugins/mongo-operations.plugin.ts
|
|
1866
|
-
/**
|
|
1867
|
-
* MongoDB Operations Plugin
|
|
1868
|
-
*
|
|
1869
|
-
* Adds MongoDB-specific operations to repositories.
|
|
1870
|
-
* Requires method-registry.plugin.js to be loaded first.
|
|
1871
|
-
*/
|
|
1872
|
-
/**
|
|
1873
|
-
* MongoDB operations plugin
|
|
1874
|
-
*
|
|
1875
|
-
* Adds MongoDB-specific atomic operations to repositories:
|
|
1876
|
-
* - upsert: Create or update document
|
|
1877
|
-
* - increment/decrement: Atomic numeric operations
|
|
1878
|
-
* - pushToArray/pullFromArray/addToSet: Array operations
|
|
1879
|
-
* - setField/unsetField/renameField: Field operations
|
|
1880
|
-
* - multiplyField: Multiply numeric field
|
|
1881
|
-
* - setMin/setMax: Conditional min/max updates
|
|
1882
|
-
*
|
|
1883
|
-
* @example Basic usage (no TypeScript autocomplete)
|
|
1884
|
-
* ```typescript
|
|
1885
|
-
* const repo = new Repository(ProductModel, [
|
|
1886
|
-
* methodRegistryPlugin(),
|
|
1887
|
-
* mongoOperationsPlugin(),
|
|
1888
|
-
* ]);
|
|
1889
|
-
*
|
|
1890
|
-
* // Works at runtime but TypeScript doesn't know about these methods
|
|
1891
|
-
* await (repo as any).increment(productId, 'views', 1);
|
|
1892
|
-
* await (repo as any).pushToArray(productId, 'tags', 'featured');
|
|
1893
|
-
* ```
|
|
1894
|
-
*
|
|
1895
|
-
* @example With TypeScript type safety (recommended)
|
|
1896
|
-
* ```typescript
|
|
1897
|
-
* import { Repository, mongoOperationsPlugin, methodRegistryPlugin } from '@classytic/mongokit';
|
|
1898
|
-
* import type { MongoOperationsMethods } from '@classytic/mongokit';
|
|
1899
|
-
*
|
|
1900
|
-
* class ProductRepo extends Repository<IProduct> {
|
|
1901
|
-
* // Add your custom methods here
|
|
1902
|
-
* }
|
|
1903
|
-
*
|
|
1904
|
-
* // Create with type assertion to get autocomplete for plugin methods
|
|
1905
|
-
* type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
|
|
1906
|
-
*
|
|
1907
|
-
* const repo = new ProductRepo(ProductModel, [
|
|
1908
|
-
* methodRegistryPlugin(),
|
|
1909
|
-
* mongoOperationsPlugin(),
|
|
1910
|
-
* ]) as ProductRepoWithPlugins;
|
|
1911
|
-
*
|
|
1912
|
-
* // Now TypeScript provides autocomplete and type checking!
|
|
1913
|
-
* await repo.increment(productId, 'views', 1);
|
|
1914
|
-
* await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });
|
|
1915
|
-
* await repo.pushToArray(productId, 'tags', 'featured');
|
|
1916
|
-
* ```
|
|
1917
|
-
*/
|
|
1918
|
-
function mongoOperationsPlugin() {
|
|
1919
|
-
return {
|
|
1920
|
-
name: "mongo-operations",
|
|
1921
|
-
apply(repo) {
|
|
1922
|
-
if (!repo.registerMethod) throw new Error("mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array.");
|
|
1923
|
-
/**
|
|
1924
|
-
* Update existing document or insert new one
|
|
1925
|
-
*/
|
|
1926
|
-
repo.registerMethod("upsert", async function(query, data, options = {}) {
|
|
1927
|
-
return upsert(this.Model, query, data, options);
|
|
1928
|
-
});
|
|
1929
|
-
const validateAndUpdateNumeric = async function(id, field, value, operator, operationName, options) {
|
|
1930
|
-
if (typeof value !== "number") throw createError(400, `${operationName} value must be a number`);
|
|
1931
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
1932
|
-
};
|
|
1933
|
-
/**
|
|
1934
|
-
* Atomically increment numeric field
|
|
1935
|
-
*/
|
|
1936
|
-
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
1937
|
-
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
1938
|
-
});
|
|
1939
|
-
/**
|
|
1940
|
-
* Atomically decrement numeric field
|
|
1941
|
-
*/
|
|
1942
|
-
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
1943
|
-
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
1944
|
-
});
|
|
1945
|
-
const applyOperator = function(id, field, value, operator, options) {
|
|
1946
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
1947
|
-
};
|
|
1948
|
-
/**
|
|
1949
|
-
* Push value to array field
|
|
1950
|
-
*/
|
|
1951
|
-
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
1952
|
-
return applyOperator.call(this, id, field, value, "$push", options);
|
|
1953
|
-
});
|
|
1954
|
-
/**
|
|
1955
|
-
* Remove value from array field
|
|
1956
|
-
*/
|
|
1957
|
-
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
1958
|
-
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
1959
|
-
});
|
|
1960
|
-
/**
|
|
1961
|
-
* Add value to array only if not already present (unique)
|
|
1962
|
-
*/
|
|
1963
|
-
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
1964
|
-
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
1965
|
-
});
|
|
1966
|
-
/**
|
|
1967
|
-
* Set field value (alias for update with $set)
|
|
1968
|
-
*/
|
|
1969
|
-
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
1970
|
-
return applyOperator.call(this, id, field, value, "$set", options);
|
|
1971
|
-
});
|
|
1972
|
-
/**
|
|
1973
|
-
* Unset (remove) field from document
|
|
1974
|
-
*/
|
|
1975
|
-
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
1976
|
-
const unsetObj = (Array.isArray(fields) ? fields : [fields]).reduce((acc, field) => {
|
|
1977
|
-
acc[field] = "";
|
|
1978
|
-
return acc;
|
|
1979
|
-
}, {});
|
|
1980
|
-
return this.update(id, { $unset: unsetObj }, options);
|
|
1981
|
-
});
|
|
1982
|
-
/**
|
|
1983
|
-
* Rename field in document
|
|
1984
|
-
*/
|
|
1985
|
-
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
1986
|
-
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
1987
|
-
});
|
|
1988
|
-
/**
|
|
1989
|
-
* Multiply numeric field by value
|
|
1990
|
-
*/
|
|
1991
|
-
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
1992
|
-
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
1993
|
-
});
|
|
1994
|
-
/**
|
|
1995
|
-
* Set field to minimum value (only if current value is greater)
|
|
1996
|
-
*/
|
|
1997
|
-
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
1998
|
-
return applyOperator.call(this, id, field, value, "$min", options);
|
|
1999
|
-
});
|
|
2000
|
-
/**
|
|
2001
|
-
* Set field to maximum value (only if current value is less)
|
|
2002
|
-
*/
|
|
2003
|
-
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
2004
|
-
return applyOperator.call(this, id, field, value, "$max", options);
|
|
2005
|
-
});
|
|
2006
|
-
/**
|
|
2007
|
-
* Atomic conditional increment — only increments when the current
|
|
2008
|
-
* value is strictly below `ceiling`. Returns the updated document
|
|
2009
|
-
* on success, or `null` when the row is already at/above the
|
|
2010
|
-
* ceiling (or the id doesn't match).
|
|
2011
|
-
*
|
|
2012
|
-
* Single round-trip via `findOneAndUpdate({ _id, [field]: { $lt:
|
|
2013
|
-
* ceiling } }, { $inc: { [field]: by } })` — race-free across
|
|
2014
|
-
* concurrent callers. The standard pattern for per-document
|
|
2015
|
-
* concurrency limits / quotas / capacity caps that would
|
|
2016
|
-
* otherwise need a 150-line lease loop.
|
|
2017
|
-
*
|
|
2018
|
-
* @example streamline strict-concurrency: claim a worker slot
|
|
2019
|
-
* ```ts
|
|
2020
|
-
* const claimed = await repo.incrementIfBelow(
|
|
2021
|
-
* tenantId,
|
|
2022
|
-
* 'activeRuns',
|
|
2023
|
-
* maxConcurrent,
|
|
2024
|
-
* );
|
|
2025
|
-
* if (!claimed) return; // already at limit; another caller won
|
|
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
|
-
});
|
|
2099
|
-
}
|
|
2100
|
-
};
|
|
2101
|
-
}
|
|
2102
|
-
//#endregion
|
|
2103
|
-
//#region src/plugins/observability.plugin.ts
|
|
2104
|
-
/**
|
|
2105
|
-
* Observability Plugin
|
|
2106
|
-
*
|
|
2107
|
-
* Adds operation timing, structured metrics, and APM hook points.
|
|
2108
|
-
* Works with any monitoring system — just provide an onMetric callback.
|
|
2109
|
-
*
|
|
2110
|
-
* @example
|
|
2111
|
-
* ```typescript
|
|
2112
|
-
* const repo = new Repository(User, [
|
|
2113
|
-
* observabilityPlugin({
|
|
2114
|
-
* onMetric: (metric) => {
|
|
2115
|
-
* // Send to DataDog, New Relic, OpenTelemetry, or console
|
|
2116
|
-
* console.log(`${metric.operation} took ${metric.durationMs}ms`);
|
|
2117
|
-
* statsd.histogram('mongokit.operation', metric.durationMs, { op: metric.operation });
|
|
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
|
-
};
|
|
2132
|
-
return {
|
|
2133
|
-
name: "observability",
|
|
2134
|
-
apply(repo) {
|
|
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()
|
|
2155
|
-
});
|
|
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()
|
|
2172
|
-
});
|
|
2173
|
-
});
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
};
|
|
2177
|
-
}
|
|
2178
|
-
//#endregion
|
|
2179
|
-
//#region src/plugins/subdocument.plugin.ts
|
|
2180
|
-
/**
|
|
2181
|
-
* Subdocument plugin for managing nested arrays
|
|
2182
|
-
*
|
|
2183
|
-
* @example
|
|
2184
|
-
* const repo = new Repository(Model, [
|
|
2185
|
-
* methodRegistryPlugin(),
|
|
2186
|
-
* subdocumentPlugin(),
|
|
2187
|
-
* ]);
|
|
2188
|
-
*
|
|
2189
|
-
* await repo.addSubdocument(parentId, 'items', { name: 'Item 1' });
|
|
2190
|
-
* await repo.updateSubdocument(parentId, 'items', itemId, { name: 'Updated Item' });
|
|
2191
|
-
*/
|
|
2192
|
-
function subdocumentPlugin() {
|
|
2193
|
-
return {
|
|
2194
|
-
name: "subdocument",
|
|
2195
|
-
apply(repo) {
|
|
2196
|
-
if (!repo.registerMethod) throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
2197
|
-
/**
|
|
2198
|
-
* Add subdocument to array
|
|
2199
|
-
*/
|
|
2200
|
-
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
2201
|
-
return this.update(parentId, { $push: { [arrayPath]: subData } }, options);
|
|
2202
|
-
});
|
|
2203
|
-
/**
|
|
2204
|
-
* Get subdocument from array
|
|
2205
|
-
*/
|
|
2206
|
-
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
2207
|
-
return this._executeQuery(async (Model) => {
|
|
2208
|
-
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
2209
|
-
if (!parent) throw createError(404, "Parent not found");
|
|
2210
|
-
const arrayField = parent[arrayPath];
|
|
2211
|
-
if (!arrayField || typeof arrayField.id !== "function") throw createError(404, "Array field not found");
|
|
2212
|
-
const sub = arrayField.id(subId);
|
|
2213
|
-
if (!sub) throw createError(404, "Subdocument not found");
|
|
2214
|
-
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
2215
|
-
});
|
|
2216
|
-
});
|
|
2217
|
-
/**
|
|
2218
|
-
* Update subdocument in array
|
|
2219
|
-
*/
|
|
2220
|
-
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
2221
|
-
return this._executeQuery(async (Model) => {
|
|
2222
|
-
const query = {
|
|
2223
|
-
_id: parentId,
|
|
2224
|
-
[`${arrayPath}._id`]: subId
|
|
2225
|
-
};
|
|
2226
|
-
const update = { $set: { [`${arrayPath}.$`]: {
|
|
2227
|
-
...updateData,
|
|
2228
|
-
_id: subId
|
|
2229
|
-
} } };
|
|
2230
|
-
const result = await Model.findOneAndUpdate(query, update, {
|
|
2231
|
-
returnDocument: "after",
|
|
2232
|
-
runValidators: true,
|
|
2233
|
-
session: options.session
|
|
2234
|
-
}).exec();
|
|
2235
|
-
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
2236
|
-
return result;
|
|
2237
|
-
});
|
|
2238
|
-
});
|
|
2239
|
-
/**
|
|
2240
|
-
* Delete subdocument from array
|
|
2241
|
-
*/
|
|
2242
|
-
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
2243
|
-
return this.update(parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
2244
|
-
});
|
|
2245
|
-
}
|
|
2246
|
-
};
|
|
2247
|
-
}
|
|
2248
|
-
//#endregion
|
|
2249
|
-
//#region src/plugins/tenant-context.ts
|
|
2250
|
-
/**
|
|
2251
|
-
* Request-scoped tenant context via AsyncLocalStorage.
|
|
2252
|
-
*
|
|
2253
|
-
* The multi-tenant plugin already accepts a `resolveContext` callback for
|
|
2254
|
-
* reading tenant ID from an external store. This helper wraps Node's
|
|
2255
|
-
* `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
|
|
2256
|
-
* to wire the plumbing by hand — a common source of cross-tenant leaks.
|
|
2257
|
-
*
|
|
2258
|
-
* @example
|
|
2259
|
-
* import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
|
|
2260
|
-
*
|
|
2261
|
-
* const tenantContext = createTenantContext();
|
|
2262
|
-
*
|
|
2263
|
-
* // In your HTTP middleware (Express / Fastify / NestJS):
|
|
2264
|
-
* app.use((req, _res, next) => {
|
|
2265
|
-
* tenantContext.run({ tenantId: req.auth.organizationId }, next);
|
|
2266
|
-
* });
|
|
2267
|
-
*
|
|
2268
|
-
* // In your repository definition:
|
|
2269
|
-
* const repo = new Repository(Invoice, [
|
|
2270
|
-
* multiTenantPlugin({
|
|
2271
|
-
* tenantField: 'organizationId',
|
|
2272
|
-
* resolveContext: () => tenantContext.getTenantId(),
|
|
2273
|
-
* }),
|
|
2274
|
-
* ]);
|
|
2275
|
-
*
|
|
2276
|
-
* // In your handler — no need to pass organizationId manually:
|
|
2277
|
-
* await repo.getAll({ filters: { status: 'paid' } });
|
|
2278
|
-
*/
|
|
2279
|
-
function createTenantContext() {
|
|
2280
|
-
const storage = new AsyncLocalStorage();
|
|
2281
|
-
return {
|
|
2282
|
-
storage,
|
|
2283
|
-
run(store, fn) {
|
|
2284
|
-
return storage.run(store, fn);
|
|
2285
|
-
},
|
|
2286
|
-
getStore() {
|
|
2287
|
-
return storage.getStore();
|
|
2288
|
-
},
|
|
2289
|
-
getTenantId() {
|
|
2290
|
-
return storage.getStore()?.tenantId;
|
|
2291
|
-
},
|
|
2292
|
-
requireTenantId() {
|
|
2293
|
-
const id = storage.getStore()?.tenantId;
|
|
2294
|
-
if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
|
|
2295
|
-
return id;
|
|
2296
|
-
}
|
|
2297
|
-
};
|
|
2298
|
-
}
|
|
2299
|
-
//#endregion
|
|
2300
|
-
//#region src/plugins/validation-chain.plugin.ts
|
|
2301
|
-
/**
|
|
2302
|
-
* Validation chain plugin
|
|
2303
|
-
*
|
|
2304
|
-
* @example
|
|
2305
|
-
* const repo = new Repository(Model, [
|
|
2306
|
-
* validationChainPlugin([
|
|
2307
|
-
* requireField('email'),
|
|
2308
|
-
* uniqueField('email', 'Email already exists'),
|
|
2309
|
-
* blockIf('no-delete-admin', ['delete'], ctx => ctx.data?.role === 'admin', 'Cannot delete admin'),
|
|
2310
|
-
* ])
|
|
2311
|
-
* ]);
|
|
2312
|
-
*/
|
|
2313
|
-
function validationChainPlugin(validators = [], options = {}) {
|
|
2314
|
-
const { stopOnFirstError = true } = options;
|
|
2315
|
-
validators.forEach((v, idx) => {
|
|
2316
|
-
if (!v.name || typeof v.name !== "string") throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
2317
|
-
if (typeof v.validate !== "function") throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
2318
|
-
});
|
|
2319
|
-
const validatorsByOperation = {
|
|
2320
|
-
create: [],
|
|
2321
|
-
update: [],
|
|
2322
|
-
findOneAndUpdate: [],
|
|
2323
|
-
delete: [],
|
|
2324
|
-
createMany: []
|
|
2325
|
-
};
|
|
2326
|
-
const allOperationsValidators = [];
|
|
2327
|
-
validators.forEach((v) => {
|
|
2328
|
-
if (!v.operations || v.operations.length === 0) allOperationsValidators.push(v);
|
|
2329
|
-
else v.operations.forEach((op) => {
|
|
2330
|
-
if (validatorsByOperation[op]) validatorsByOperation[op].push(v);
|
|
2331
|
-
});
|
|
2332
|
-
});
|
|
2333
|
-
return {
|
|
2334
|
-
name: "validation-chain",
|
|
2335
|
-
apply(repo) {
|
|
2336
|
-
const getValidatorsForOperation = (operation) => {
|
|
2337
|
-
const specific = validatorsByOperation[operation] || [];
|
|
2338
|
-
return [...allOperationsValidators, ...specific];
|
|
2339
|
-
};
|
|
2340
|
-
const runValidators = async (operation, context) => {
|
|
2341
|
-
const operationValidators = getValidatorsForOperation(operation);
|
|
2342
|
-
const errors = [];
|
|
2343
|
-
for (const validator of operationValidators) try {
|
|
2344
|
-
await validator.validate(context, repo);
|
|
2345
|
-
} catch (error) {
|
|
2346
|
-
if (stopOnFirstError) throw error;
|
|
2347
|
-
errors.push({
|
|
2348
|
-
validator: validator.name,
|
|
2349
|
-
error: error.message || String(error)
|
|
2350
|
-
});
|
|
2351
|
-
}
|
|
2352
|
-
if (errors.length > 0) {
|
|
2353
|
-
const err = createError(400, `Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`);
|
|
2354
|
-
err.validationErrors = errors;
|
|
2355
|
-
throw err;
|
|
2356
|
-
}
|
|
2357
|
-
};
|
|
2358
|
-
repo.on("before:create", async (context) => runValidators("create", context));
|
|
2359
|
-
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
2360
|
-
repo.on("before:update", async (context) => runValidators("update", context));
|
|
2361
|
-
repo.on("before:findOneAndUpdate", async (context) => runValidators("findOneAndUpdate", context));
|
|
2362
|
-
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
2363
|
-
}
|
|
2364
|
-
};
|
|
2365
|
-
}
|
|
2366
|
-
/**
|
|
2367
|
-
* Block operation if condition is true
|
|
2368
|
-
*
|
|
2369
|
-
* @example
|
|
2370
|
-
* blockIf('block-library', ['delete'], ctx => ctx.data?.managed, 'Cannot delete managed records')
|
|
2371
|
-
*/
|
|
2372
|
-
function blockIf(name, operations, condition, errorMessage) {
|
|
2373
|
-
return {
|
|
2374
|
-
name,
|
|
2375
|
-
operations,
|
|
2376
|
-
validate: (context) => {
|
|
2377
|
-
if (condition(context)) throw createError(403, errorMessage);
|
|
2378
|
-
}
|
|
2379
|
-
};
|
|
2380
|
-
}
|
|
2381
|
-
/**
|
|
2382
|
-
* Require a field to be present
|
|
2383
|
-
*/
|
|
2384
|
-
function requireField(field, operations = ["create"]) {
|
|
2385
|
-
return {
|
|
2386
|
-
name: `require-${field}`,
|
|
2387
|
-
operations,
|
|
2388
|
-
validate: (context) => {
|
|
2389
|
-
if (!context.data || context.data[field] === void 0 || context.data[field] === null) throw createError(400, `Field '${field}' is required`);
|
|
2390
|
-
}
|
|
2391
|
-
};
|
|
2392
|
-
}
|
|
2393
|
-
/**
|
|
2394
|
-
* Auto-inject a value if not present
|
|
2395
|
-
*/
|
|
2396
|
-
function autoInject(field, getter, operations = ["create"]) {
|
|
2397
|
-
return {
|
|
2398
|
-
name: `auto-inject-${field}`,
|
|
2399
|
-
operations,
|
|
2400
|
-
validate: (context) => {
|
|
2401
|
-
if (context.data && !(field in context.data)) {
|
|
2402
|
-
const value = getter(context);
|
|
2403
|
-
if (value !== null && value !== void 0) context.data[field] = value;
|
|
2404
|
-
}
|
|
2405
|
-
}
|
|
2406
|
-
};
|
|
2407
|
-
}
|
|
2408
|
-
/**
|
|
2409
|
-
* Make a field immutable (cannot be updated)
|
|
2410
|
-
*/
|
|
2411
|
-
function immutableField(field) {
|
|
2412
|
-
return {
|
|
2413
|
-
name: `immutable-${field}`,
|
|
2414
|
-
operations: ["update"],
|
|
2415
|
-
validate: (context) => {
|
|
2416
|
-
if (context.data && field in context.data) throw createError(400, `Field '${field}' cannot be modified`);
|
|
2417
|
-
}
|
|
2418
|
-
};
|
|
2419
|
-
}
|
|
2420
|
-
/**
|
|
2421
|
-
* Ensure field value is unique
|
|
2422
|
-
*/
|
|
2423
|
-
function uniqueField(field, errorMessage) {
|
|
2424
|
-
return {
|
|
2425
|
-
name: `unique-${field}`,
|
|
2426
|
-
operations: ["create", "update"],
|
|
2427
|
-
validate: async (context, repo) => {
|
|
2428
|
-
if (!context.data?.[field]) return;
|
|
2429
|
-
if (!repo) {
|
|
2430
|
-
warn(`[mongokit] uniqueField('${field}'): repo not available, skipping uniqueness check`);
|
|
2431
|
-
return;
|
|
2432
|
-
}
|
|
2433
|
-
const query = { [field]: context.data[field] };
|
|
2434
|
-
const getByQuery = repo.getByQuery;
|
|
2435
|
-
if (typeof getByQuery !== "function") {
|
|
2436
|
-
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
2437
|
-
return;
|
|
2438
|
-
}
|
|
2439
|
-
const idKey = repo.idField || "_id";
|
|
2440
|
-
const existing = await getByQuery.call(repo, query, {
|
|
2441
|
-
select: idKey,
|
|
2442
|
-
lean: true,
|
|
2443
|
-
throwOnNotFound: false
|
|
2444
|
-
});
|
|
2445
|
-
if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
2446
|
-
}
|
|
2447
|
-
};
|
|
2448
|
-
}
|
|
2449
|
-
//#endregion
|
|
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 };
|
|
1399
|
+
export { methodRegistryPlugin as a, getNextSequence as c, cachePlugin as d, batchOperationsPlugin as f, auditLogPlugin as h, multiTenantPlugin as i, prefixedId as l, auditTrailPlugin as m, softDeletePlugin as n, customIdPlugin as o, AuditTrailQuery as p, adminBypass$1 as r, dateSequentialId as s, timestampPlugin as t, sequentialId as u };
|