@classytic/mongokit 3.5.0 → 3.5.2
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 +21 -2
- package/dist/actions/index.mjs +1 -1
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +2 -2
- package/dist/plugins/index.mjs +1 -1
- package/dist/{update-DcWUpWBk.mjs → update-AVfKWNGt.mjs} +36 -0
- package/dist/{validation-chain.plugin-C4D1sc18.mjs → validation-chain.plugin-vxvcv1dg.mjs} +69 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
- **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
|
|
18
18
|
- **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
|
|
19
19
|
- **TypeScript first** - Full type safety with discriminated unions, typed events, and field autocomplete
|
|
20
|
-
- **
|
|
20
|
+
- **Extensively tested** — battle-tested and production-ready
|
|
21
21
|
|
|
22
22
|
## Installation
|
|
23
23
|
|
|
@@ -1423,6 +1423,25 @@ if (dupErr) {
|
|
|
1423
1423
|
}
|
|
1424
1424
|
```
|
|
1425
1425
|
|
|
1426
|
+
## Custom ID Field
|
|
1427
|
+
|
|
1428
|
+
Use `idField` when your documents use a custom identifier (slug, UUID, code) instead of `_id`:
|
|
1429
|
+
|
|
1430
|
+
```typescript
|
|
1431
|
+
// Repo-level: all calls use slug
|
|
1432
|
+
const productRepo = new Repository(ProductModel, [], {}, { idField: 'slug' });
|
|
1433
|
+
await productRepo.getById('laptop'); // { slug: 'laptop' }
|
|
1434
|
+
await productRepo.update('laptop', { price: 999 }); // { slug: 'laptop' }
|
|
1435
|
+
await productRepo.delete('laptop'); // { slug: 'laptop' }
|
|
1436
|
+
|
|
1437
|
+
// Per-call override: same repo, different lookups
|
|
1438
|
+
const repo = new Repository(ChatModel);
|
|
1439
|
+
await repo.getById('507f1f77bcf86cd799439011'); // by _id
|
|
1440
|
+
await repo.getById('my-chat-uuid', { idField: 'chatId' }); // by chatId
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
All plugins respect `idField`: soft-delete, cascade, audit-log, audit-trail, validation-chain, elastic, cache, observability.
|
|
1444
|
+
|
|
1426
1445
|
## No Breaking Changes
|
|
1427
1446
|
|
|
1428
1447
|
Extending Repository works exactly the same with Mongoose 8 and 9. The package:
|
|
@@ -1430,7 +1449,7 @@ Extending Repository works exactly the same with Mongoose 8 and 9. The package:
|
|
|
1430
1449
|
- Uses its own event system (not Mongoose middleware)
|
|
1431
1450
|
- Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
|
|
1432
1451
|
- Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
|
|
1433
|
-
-
|
|
1452
|
+
- Full test suite passes on Mongoose 9
|
|
1434
1453
|
|
|
1435
1454
|
## License
|
|
1436
1455
|
|
package/dist/actions/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as __exportAll } from "../chunk-CfYAbeIz.mjs";
|
|
2
|
-
import { _ as aggregate_exports, f as delete_exports, h as create_exports, l as read_exports, r as update_exports } from "../update-
|
|
2
|
+
import { _ as aggregate_exports, f as delete_exports, h as create_exports, l as read_exports, r as update_exports } from "../update-AVfKWNGt.mjs";
|
|
3
3
|
//#region src/actions/index.ts
|
|
4
4
|
var actions_exports = /* @__PURE__ */ __exportAll({
|
|
5
5
|
aggregate: () => aggregate_exports,
|
package/dist/index.d.mts
CHANGED
|
@@ -844,7 +844,9 @@ declare class Repository<TDoc = unknown> {
|
|
|
844
844
|
* const all = await repo.findAll({ status: 'active' });
|
|
845
845
|
* const allLean = await repo.findAll({}, { select: 'name email', lean: true });
|
|
846
846
|
*/
|
|
847
|
-
findAll(filters?: Record<string, unknown>, options?: OperationOptions
|
|
847
|
+
findAll(filters?: Record<string, unknown>, options?: OperationOptions & {
|
|
848
|
+
sort?: SortSpec | string;
|
|
849
|
+
}): Promise<TDoc[]>;
|
|
848
850
|
/**
|
|
849
851
|
* Unified pagination - auto-detects offset vs keyset based on params
|
|
850
852
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { a as warn, n as parseDuplicateKeyError, r as configureLogger, t as createError } from "./error-Bpbi_NKo.mjs";
|
|
2
|
-
import { y as LookupBuilder } from "./update-
|
|
2
|
+
import { y as LookupBuilder } from "./update-AVfKWNGt.mjs";
|
|
3
3
|
import { t as actions_exports } from "./actions/index.mjs";
|
|
4
4
|
import { t as PaginationEngine } from "./PaginationEngine-DCs-zKwZ.mjs";
|
|
5
|
-
import { A as aggregateHelpersPlugin, C as HOOK_PRIORITY, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as cachePlugin, T as AggregationBuilder, _ as dateSequentialId, a as uniqueField, b as sequentialId, c as subdocumentPlugin, d as multiTenantPlugin, f as mongoOperationsPlugin, g as customIdPlugin, h as elasticSearchPlugin, i as requireField, k as auditLogPlugin, l as softDeletePlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as methodRegistryPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as observabilityPlugin, v as getNextSequence, w as Repository, x as cascadePlugin, y as prefixedId } from "./validation-chain.plugin-
|
|
5
|
+
import { A as aggregateHelpersPlugin, C as HOOK_PRIORITY, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as cachePlugin, T as AggregationBuilder, _ as dateSequentialId, a as uniqueField, b as sequentialId, c as subdocumentPlugin, d as multiTenantPlugin, f as mongoOperationsPlugin, g as customIdPlugin, h as elasticSearchPlugin, i as requireField, k as auditLogPlugin, l as softDeletePlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as methodRegistryPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as observabilityPlugin, v as getNextSequence, w as Repository, x as cascadePlugin, y as prefixedId } from "./validation-chain.plugin-vxvcv1dg.mjs";
|
|
6
6
|
import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-reyDRzXf.mjs";
|
|
7
7
|
import { a as isFieldUpdateAllowed, i as getSystemManagedFields, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-B6Qyl8BK.mjs";
|
|
8
8
|
import mongoose from "mongoose";
|
package/dist/plugins/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as aggregateHelpersPlugin, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as cachePlugin, _ as dateSequentialId, a as uniqueField, b as sequentialId, c as subdocumentPlugin, d as multiTenantPlugin, f as mongoOperationsPlugin, g as customIdPlugin, h as elasticSearchPlugin, i as requireField, k as auditLogPlugin, l as softDeletePlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as methodRegistryPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as observabilityPlugin, v as getNextSequence, x as cascadePlugin, y as prefixedId } from "../validation-chain.plugin-
|
|
1
|
+
import { A as aggregateHelpersPlugin, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as cachePlugin, _ as dateSequentialId, a as uniqueField, b as sequentialId, c as subdocumentPlugin, d as multiTenantPlugin, f as mongoOperationsPlugin, g as customIdPlugin, h as elasticSearchPlugin, i as requireField, k as auditLogPlugin, l as softDeletePlugin, m as fieldFilterPlugin, n as blockIf, o as validationChainPlugin, p as methodRegistryPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as observabilityPlugin, v as getNextSequence, x as cascadePlugin, y as prefixedId } from "../validation-chain.plugin-vxvcv1dg.mjs";
|
|
2
2
|
export { AuditTrailQuery, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
|
|
@@ -279,6 +279,42 @@ var LookupBuilder = class LookupBuilder {
|
|
|
279
279
|
return sanitized;
|
|
280
280
|
}
|
|
281
281
|
};
|
|
282
|
+
/**
|
|
283
|
+
* Performance Guidelines for $lookup at Scale:
|
|
284
|
+
*
|
|
285
|
+
* 1. **Index Requirements** (Critical for millions of records):
|
|
286
|
+
* - localField should be indexed on source collection
|
|
287
|
+
* - foreignField should be indexed on target collection (unique index preferred)
|
|
288
|
+
*
|
|
289
|
+
* Example:
|
|
290
|
+
* ```typescript
|
|
291
|
+
* // Employee collection
|
|
292
|
+
* employeeSchema.index({ departmentSlug: 1 });
|
|
293
|
+
*
|
|
294
|
+
* // Department collection
|
|
295
|
+
* departmentSchema.index({ slug: 1 }, { unique: true });
|
|
296
|
+
* ```
|
|
297
|
+
*
|
|
298
|
+
* 2. **Query Performance**:
|
|
299
|
+
* - With proper indexes: O(log n) per lookup
|
|
300
|
+
* - Without indexes: O(n * m) - AVOID THIS!
|
|
301
|
+
* - Use explain() to verify index usage: IXSCAN (good) vs COLLSCAN (bad)
|
|
302
|
+
*
|
|
303
|
+
* 3. **Pipeline Optimization**:
|
|
304
|
+
* - Place $match stages as early as possible
|
|
305
|
+
* - Use $project to reduce field size before lookups
|
|
306
|
+
* - Limit joined results with pipeline: [{ $match: {...} }, { $limit: n }]
|
|
307
|
+
*
|
|
308
|
+
* 4. **Memory Considerations**:
|
|
309
|
+
* - Each lookup creates a new field in memory
|
|
310
|
+
* - Use $project after lookup to remove unnecessary fields
|
|
311
|
+
* - Consider allowDiskUse: true for very large datasets
|
|
312
|
+
*
|
|
313
|
+
* 5. **Alternative Approaches**:
|
|
314
|
+
* - For 1:1 relationships with high read frequency: Consider storing ObjectId + slug
|
|
315
|
+
* - For read-heavy workloads: Consider caching or materialized views
|
|
316
|
+
* - For real-time dashboards: Consider separate aggregation collections
|
|
317
|
+
*/
|
|
282
318
|
//#endregion
|
|
283
319
|
//#region src/actions/aggregate.ts
|
|
284
320
|
var aggregate_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as warn, i as debug, n as parseDuplicateKeyError, t as createError } from "./error-Bpbi_NKo.mjs";
|
|
2
|
-
import { a as exists, c as getOrCreate, d as deleteByQuery, g as upsert, i as count, m as createMany, n as updateByQuery, o as getById, p as create, s as getByQuery, t as update, u as deleteById, v as distinct, y as LookupBuilder } from "./update-
|
|
2
|
+
import { a as exists, c as getOrCreate, d as deleteByQuery, g as upsert, i as count, m as createMany, n as updateByQuery, o as getById, p as create, s as getByQuery, t as update, u as deleteById, v as distinct, y as LookupBuilder } from "./update-AVfKWNGt.mjs";
|
|
3
3
|
import { t as PaginationEngine } from "./PaginationEngine-DCs-zKwZ.mjs";
|
|
4
4
|
import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-reyDRzXf.mjs";
|
|
5
5
|
import mongoose from "mongoose";
|
|
@@ -79,9 +79,10 @@ function auditLogPlugin(logger) {
|
|
|
79
79
|
name: "auditLog",
|
|
80
80
|
apply(repo) {
|
|
81
81
|
repo.on("after:create", ({ context, result }) => {
|
|
82
|
+
const idKey = repo.idField || "_id";
|
|
82
83
|
logger?.info?.("Document created", {
|
|
83
84
|
model: context.model || repo.model,
|
|
84
|
-
id: result?.
|
|
85
|
+
id: result?.[idKey],
|
|
85
86
|
userId: context.user?._id || context.user?.id,
|
|
86
87
|
organizationId: context.organizationId
|
|
87
88
|
});
|
|
@@ -89,7 +90,7 @@ function auditLogPlugin(logger) {
|
|
|
89
90
|
repo.on("after:update", ({ context, result }) => {
|
|
90
91
|
logger?.info?.("Document updated", {
|
|
91
92
|
model: context.model || repo.model,
|
|
92
|
-
id: context.id || result?._id,
|
|
93
|
+
id: context.id || result?.[repo.idField || "_id"],
|
|
93
94
|
userId: context.user?._id || context.user?.id,
|
|
94
95
|
organizationId: context.organizationId
|
|
95
96
|
});
|
|
@@ -282,10 +283,11 @@ function auditTrailPlugin(options = {}) {
|
|
|
282
283
|
const AuditModel = getAuditModel(collectionName, ttlDays);
|
|
283
284
|
if (opsSet.has("create")) repo.on("after:create", ({ context, result }) => {
|
|
284
285
|
const doc = toPlainObject(result);
|
|
286
|
+
const idKey = repo.idField || "_id";
|
|
285
287
|
writeAudit(AuditModel, {
|
|
286
288
|
model: context.model || repo.model,
|
|
287
289
|
operation: "create",
|
|
288
|
-
documentId: doc?.
|
|
290
|
+
documentId: doc?.[idKey],
|
|
289
291
|
userId: getUserId(context),
|
|
290
292
|
orgId: context.organizationId,
|
|
291
293
|
document: trackDocument ? sanitizeDoc(doc, excludeFields) : void 0,
|
|
@@ -313,7 +315,7 @@ function auditTrailPlugin(options = {}) {
|
|
|
313
315
|
writeAudit(AuditModel, {
|
|
314
316
|
model: context.model || repo.model,
|
|
315
317
|
operation: "update",
|
|
316
|
-
documentId: context.id || doc?._id,
|
|
318
|
+
documentId: context.id || doc?.[repo.idField || "_id"],
|
|
317
319
|
userId: getUserId(context),
|
|
318
320
|
orgId: context.organizationId,
|
|
319
321
|
changes,
|
|
@@ -1103,6 +1105,52 @@ var AggregationBuilder = class AggregationBuilder {
|
|
|
1103
1105
|
return new AggregationBuilder().match(query);
|
|
1104
1106
|
}
|
|
1105
1107
|
};
|
|
1108
|
+
/**
|
|
1109
|
+
* Optimized Aggregation Patterns for Scale
|
|
1110
|
+
*
|
|
1111
|
+
* 1. **Early Filtering** - Always place $match as early as possible:
|
|
1112
|
+
* ```typescript
|
|
1113
|
+
* new AggregationBuilder()
|
|
1114
|
+
* .match({ status: 'active' }) // ✅ Filter first
|
|
1115
|
+
* .lookup(...) // Then join
|
|
1116
|
+
* .sort(...)
|
|
1117
|
+
* ```
|
|
1118
|
+
*
|
|
1119
|
+
* 2. **Index Usage** - Ensure indexes on:
|
|
1120
|
+
* - Fields in $match
|
|
1121
|
+
* - Fields in $sort (especially with $limit)
|
|
1122
|
+
* - Fields in $lookup (both local and foreign)
|
|
1123
|
+
*
|
|
1124
|
+
* 3. **Projection** - Remove unnecessary fields early:
|
|
1125
|
+
* ```typescript
|
|
1126
|
+
* .project({ password: 0, internalNotes: 0 }) // Remove before joins
|
|
1127
|
+
* .lookup(...)
|
|
1128
|
+
* ```
|
|
1129
|
+
*
|
|
1130
|
+
* 4. **Faceted Pagination** - Get count and data in one query:
|
|
1131
|
+
* ```typescript
|
|
1132
|
+
* .facet({
|
|
1133
|
+
* metadata: [{ $count: 'total' }],
|
|
1134
|
+
* data: [{ $skip: skip }, { $limit: limit }]
|
|
1135
|
+
* })
|
|
1136
|
+
* ```
|
|
1137
|
+
*
|
|
1138
|
+
* 5. **allowDiskUse** - For large datasets:
|
|
1139
|
+
* ```typescript
|
|
1140
|
+
* new AggregationBuilder()
|
|
1141
|
+
* .match({ status: 'active' })
|
|
1142
|
+
* .allowDiskUse()
|
|
1143
|
+
* .exec(Model)
|
|
1144
|
+
* ```
|
|
1145
|
+
*
|
|
1146
|
+
* 6. **Vector Search** - Semantic similarity (Atlas only):
|
|
1147
|
+
* ```typescript
|
|
1148
|
+
* new AggregationBuilder()
|
|
1149
|
+
* .vectorSearch({ index: 'vec_idx', path: 'embedding', queryVector: vec, limit: 10 })
|
|
1150
|
+
* .withVectorScore()
|
|
1151
|
+
* .exec(Model)
|
|
1152
|
+
* ```
|
|
1153
|
+
*/
|
|
1106
1154
|
//#endregion
|
|
1107
1155
|
//#region src/Repository.ts
|
|
1108
1156
|
function ensureLookupProjectionIncludesCursorFields(projection, sort) {
|
|
@@ -1425,6 +1473,8 @@ var Repository = class {
|
|
|
1425
1473
|
const resolvedFilters = context.filters ?? filters;
|
|
1426
1474
|
try {
|
|
1427
1475
|
const query = this.Model.find(resolvedFilters);
|
|
1476
|
+
const sortSpec = context.sort || options.sort;
|
|
1477
|
+
if (sortSpec) query.sort(this._parseSort(sortSpec));
|
|
1428
1478
|
const selectSpec = context.select || options.select;
|
|
1429
1479
|
if (selectSpec) query.select(selectSpec);
|
|
1430
1480
|
if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
|
|
@@ -1487,7 +1537,10 @@ var Repository = class {
|
|
|
1487
1537
|
});
|
|
1488
1538
|
return cachedResult;
|
|
1489
1539
|
}
|
|
1490
|
-
if (params.noPagination) return this.findAll(params.filters ?? {},
|
|
1540
|
+
if (params.noPagination) return this.findAll(context.filters ?? params.filters ?? {}, {
|
|
1541
|
+
...options,
|
|
1542
|
+
sort: context.sort ?? params.sort
|
|
1543
|
+
});
|
|
1491
1544
|
const filters = context.filters ?? params.filters ?? {};
|
|
1492
1545
|
const search = context.search ?? params.search;
|
|
1493
1546
|
const sort = context.sort ?? params.sort ?? "-createdAt";
|
|
@@ -2721,7 +2774,8 @@ function cascadePlugin(options) {
|
|
|
2721
2774
|
repo.on("before:deleteMany", async (context) => {
|
|
2722
2775
|
const query = context.query;
|
|
2723
2776
|
if (!query || Object.keys(query).length === 0) return;
|
|
2724
|
-
|
|
2777
|
+
const idField = repo.idField || "_id";
|
|
2778
|
+
context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
|
|
2725
2779
|
});
|
|
2726
2780
|
repo.on("after:deleteMany", async (payload) => {
|
|
2727
2781
|
const { context } = payload;
|
|
@@ -3088,17 +3142,18 @@ function elasticSearchPlugin(options) {
|
|
|
3088
3142
|
limit,
|
|
3089
3143
|
from
|
|
3090
3144
|
};
|
|
3091
|
-
const
|
|
3145
|
+
const mongoIdField = repo.idField || "_id";
|
|
3146
|
+
const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
|
|
3092
3147
|
if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
|
|
3093
3148
|
if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
|
|
3094
3149
|
if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
|
|
3095
3150
|
return {
|
|
3096
3151
|
docs: (await mongoQuery.exec()).sort((a, b) => {
|
|
3097
|
-
const aId = String(a
|
|
3098
|
-
const bId = String(b
|
|
3152
|
+
const aId = String(a[mongoIdField]);
|
|
3153
|
+
const bId = String(b[mongoIdField]);
|
|
3099
3154
|
return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
|
|
3100
3155
|
}).map((doc) => {
|
|
3101
|
-
const strId = String(doc
|
|
3156
|
+
const strId = String(doc[mongoIdField]);
|
|
3102
3157
|
if (searchOptions.mongoOptions?.lean !== false) return {
|
|
3103
3158
|
...doc,
|
|
3104
3159
|
_score: scores.get(strId)
|
|
@@ -4098,12 +4153,13 @@ function uniqueField(field, errorMessage) {
|
|
|
4098
4153
|
warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
|
|
4099
4154
|
return;
|
|
4100
4155
|
}
|
|
4156
|
+
const idKey = repo.idField || "_id";
|
|
4101
4157
|
const existing = await getByQuery.call(repo, query, {
|
|
4102
|
-
select:
|
|
4158
|
+
select: idKey,
|
|
4103
4159
|
lean: true,
|
|
4104
4160
|
throwOnNotFound: false
|
|
4105
4161
|
});
|
|
4106
|
-
if (existing && String(existing
|
|
4162
|
+
if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
|
|
4107
4163
|
}
|
|
4108
4164
|
};
|
|
4109
4165
|
}
|
package/package.json
CHANGED