@classytic/mongokit 3.5.1 → 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 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
- - **1170+ passing tests** - Battle-tested and production-ready
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
- - All 1090+ tests pass on Mongoose 9
1452
+ - Full test suite passes on Mongoose 9
1434
1453
 
1435
1454
  ## License
1436
1455
 
@@ -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-DcWUpWBk.mjs";
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.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-DcWUpWBk.mjs";
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-DboBJfGs.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-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";
@@ -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-DboBJfGs.mjs";
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-DcWUpWBk.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-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?._id,
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?._id,
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) {
@@ -2726,7 +2774,8 @@ function cascadePlugin(options) {
2726
2774
  repo.on("before:deleteMany", async (context) => {
2727
2775
  const query = context.query;
2728
2776
  if (!query || Object.keys(query).length === 0) return;
2729
- context._cascadeIds = (await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null)).map((doc) => doc._id);
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]);
2730
2779
  });
2731
2780
  repo.on("after:deleteMany", async (payload) => {
2732
2781
  const { context } = payload;
@@ -3093,17 +3142,18 @@ function elasticSearchPlugin(options) {
3093
3142
  limit,
3094
3143
  from
3095
3144
  };
3096
- const mongoQuery = this.Model.find({ _id: { $in: ids } });
3145
+ const mongoIdField = repo.idField || "_id";
3146
+ const mongoQuery = this.Model.find({ [mongoIdField]: { $in: ids } });
3097
3147
  if (searchOptions.mongoOptions?.select) mongoQuery.select(searchOptions.mongoOptions.select);
3098
3148
  if (searchOptions.mongoOptions?.populate) mongoQuery.populate(searchOptions.mongoOptions.populate);
3099
3149
  if (searchOptions.mongoOptions?.lean !== false) mongoQuery.lean();
3100
3150
  return {
3101
3151
  docs: (await mongoQuery.exec()).sort((a, b) => {
3102
- const aId = String(a._id);
3103
- const bId = String(b._id);
3152
+ const aId = String(a[mongoIdField]);
3153
+ const bId = String(b[mongoIdField]);
3104
3154
  return (docsOrder.get(aId) ?? Number.MAX_SAFE_INTEGER) - (docsOrder.get(bId) ?? Number.MAX_SAFE_INTEGER);
3105
3155
  }).map((doc) => {
3106
- const strId = String(doc._id);
3156
+ const strId = String(doc[mongoIdField]);
3107
3157
  if (searchOptions.mongoOptions?.lean !== false) return {
3108
3158
  ...doc,
3109
3159
  _score: scores.get(strId)
@@ -4103,12 +4153,13 @@ function uniqueField(field, errorMessage) {
4103
4153
  warn(`[mongokit] uniqueField('${field}'): getByQuery not available on repo, skipping uniqueness check`);
4104
4154
  return;
4105
4155
  }
4156
+ const idKey = repo.idField || "_id";
4106
4157
  const existing = await getByQuery.call(repo, query, {
4107
- select: "_id",
4158
+ select: idKey,
4108
4159
  lean: true,
4109
4160
  throwOnNotFound: false
4110
4161
  });
4111
- if (existing && String(existing._id) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
4162
+ if (existing && String(existing[idKey]) !== String(context.id)) throw createError(409, errorMessage || `${field} already exists`);
4112
4163
  }
4113
4164
  };
4114
4165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,