@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 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.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): Promise<TDoc[]>;
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-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-C4D1sc18.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-C4D1sc18.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) {
@@ -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 ?? {}, options);
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
- 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]);
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 mongoQuery = this.Model.find({ _id: { $in: ids } });
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._id);
3098
- const bId = String(b._id);
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._id);
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: "_id",
4158
+ select: idKey,
4103
4159
  lean: true,
4104
4160
  throwOnNotFound: false
4105
4161
  });
4106
- 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`);
4107
4163
  }
4108
4164
  };
4109
4165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.5.0",
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,