@classytic/mongokit 3.6.3 → 3.7.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.
Files changed (33) hide show
  1. package/dist/{PaginationEngine-CunXkR9p.mjs → PaginationEngine-CPmZv7Ds.mjs} +8 -5
  2. package/dist/actions/index.d.mts +1 -1
  3. package/dist/actions/index.mjs +1 -1
  4. package/dist/ai/index.d.mts +23 -1
  5. package/dist/ai/index.mjs +194 -12
  6. package/dist/{cursor-CHToazHy.mjs → cursor-DZ9JSoMM.mjs} +11 -7
  7. package/dist/error-Lw9GEAro.mjs +46 -0
  8. package/dist/{index-BSRSsRRx.d.mts → index-C-qZUqv8.d.mts} +1 -1
  9. package/dist/index.d.mts +31 -9
  10. package/dist/index.mjs +53 -12
  11. package/dist/logger-DJ7egMnd.mjs +30 -0
  12. package/dist/{mongooseToJsonSchema-MlaD3C0B.mjs → mongooseToJsonSchema-DUwXPStK.mjs} +25 -21
  13. package/dist/{mongooseToJsonSchema-D2YjHo7W.d.mts → mongooseToJsonSchema-Du-UopRK.d.mts} +18 -2
  14. package/dist/pagination/PaginationEngine.d.mts +3 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +2 -2
  17. package/dist/plugins/index.mjs +2 -2
  18. package/dist/query/primitives/coercion.d.mts +1 -1
  19. package/dist/query/primitives/coercion.mjs +1 -1
  20. package/dist/query/primitives/indexes.d.mts +1 -1
  21. package/dist/{sort-C-BJEWUZ.mjs → sort-ffe-9JBg.mjs} +19 -4
  22. package/dist/{types-DiQfDesD.d.mts → types-BgdeBXI1.d.mts} +68 -0
  23. package/dist/{update-AVfKWNGt.mjs → update-BDsQ3cdy.mjs} +2 -1
  24. package/dist/utils/index.d.mts +2 -2
  25. package/dist/utils/index.mjs +5 -4
  26. package/dist/{validation-chain.plugin-BsFzKVnO.d.mts → validation-chain.plugin-Be9rG04B.d.mts} +25 -2
  27. package/dist/{validation-chain.plugin-7A5qGEQq.mjs → validation-chain.plugin-CEyJssZl.mjs} +129 -17
  28. package/package.json +8 -3
  29. package/dist/error-Bpbi_NKo.mjs +0 -63
  30. /package/dist/{coercion-B19MD38s.d.mts → coercion-oM8G4b6m.d.mts} +0 -0
  31. /package/dist/{field-selection-C5o7jyyA.mjs → field-selection-dI0m4RBs.mjs} +0 -0
  32. /package/dist/{id-resolution-LZ-cCwR5.mjs → id-resolution-e4BT9zAV.mjs} +0 -0
  33. /package/dist/{indexes-qIBCZ3mY.d.mts → indexes-C26GNZRU.d.mts} +0 -0
@@ -1,10 +1,12 @@
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-AVfKWNGt.mjs";
3
- import { t as PaginationEngine } from "./PaginationEngine-CunXkR9p.mjs";
1
+ import { n as debug, r as warn } from "./logger-DJ7egMnd.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-BDsQ3cdy.mjs";
3
+ import { n as parseDuplicateKeyError, t as createError } from "./error-Lw9GEAro.mjs";
4
+ import { t as PaginationEngine } from "./PaginationEngine-CPmZv7Ds.mjs";
4
5
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
5
- import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-LZ-cCwR5.mjs";
6
- import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-C5o7jyyA.mjs";
6
+ import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-e4BT9zAV.mjs";
7
+ import { a as byIdKey, c as listQueryKey, l as modelPattern, o as byQueryKey, r as getFieldsForUser, u as versionKey } from "./field-selection-dI0m4RBs.mjs";
7
8
  import mongoose from "mongoose";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
8
10
  //#region src/plugins/aggregate-helpers.plugin.ts
9
11
  /**
10
12
  * Aggregate helpers plugin
@@ -1228,6 +1230,40 @@ function ensureLookupProjectionIncludesCursorFields(projection, sort) {
1228
1230
  return nextProjection;
1229
1231
  }
1230
1232
  /**
1233
+ * Ordered pairs that produce wrong behavior when registered out of order.
1234
+ * Each tuple is `[mustComeFirst, mustComeAfter, reason]`.
1235
+ *
1236
+ * Plugin names are read from `plugin.name` (Plugin objects). Plain-function
1237
+ * plugins without a name are skipped — no false positives.
1238
+ */
1239
+ const PLUGIN_ORDER_CONSTRAINTS = [[
1240
+ "soft-delete",
1241
+ "batch-operations",
1242
+ "soft-delete must precede batch-operations so bulk deletes/updates see the soft-delete filter"
1243
+ ], [
1244
+ "multi-tenant",
1245
+ "cache",
1246
+ "multi-tenant must precede cache so tenant scoping is baked into cache keys (prevents cross-tenant cache poisoning)"
1247
+ ]];
1248
+ function pluginName(plugin) {
1249
+ if (!plugin || typeof plugin === "function") return void 0;
1250
+ const name = plugin.name;
1251
+ return typeof name === "string" ? name : void 0;
1252
+ }
1253
+ function validatePluginOrder(plugins, modelName, mode) {
1254
+ if (mode === "off") return;
1255
+ const names = plugins.map(pluginName);
1256
+ for (const [first, after, reason] of PLUGIN_ORDER_CONSTRAINTS) {
1257
+ const firstIdx = names.indexOf(first);
1258
+ const afterIdx = names.indexOf(after);
1259
+ if (firstIdx === -1 || afterIdx === -1) continue;
1260
+ if (firstIdx < afterIdx) continue;
1261
+ const message = `[mongokit] Repository "${modelName}": plugin order issue — ${reason}. Got: [..., '${after}' at index ${afterIdx}, '${first}' at index ${firstIdx}]. Swap them, or pass { pluginOrderChecks: 'off' } to silence.`;
1262
+ if (mode === "throw") throw new Error(message);
1263
+ warn(message);
1264
+ }
1265
+ }
1266
+ /**
1231
1267
  * Plugin phase priorities (lower = runs first)
1232
1268
  * Policy hooks (multi-tenant, soft-delete, validation) MUST run before cache
1233
1269
  * to ensure filters are injected before cache keys are computed.
@@ -1262,6 +1298,7 @@ var Repository = class {
1262
1298
  this.searchMode = options.searchMode ?? "text";
1263
1299
  this.searchFields = options.searchFields;
1264
1300
  if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
1301
+ validatePluginOrder(plugins, this.model, options.pluginOrderChecks ?? "warn");
1265
1302
  plugins.forEach((plugin) => {
1266
1303
  this.use(plugin);
1267
1304
  });
@@ -2063,12 +2100,13 @@ var Repository = class {
2063
2100
  };
2064
2101
  if (isKeyset && sort) {
2065
2102
  const parsedSort = this._parseSort(sort);
2066
- const { validateKeysetSort } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
2067
- const { encodeCursor, resolveCursorFilter } = await import("./cursor-CHToazHy.mjs").then((n) => n.t);
2068
- const { getPrimaryField } = await import("./sort-C-BJEWUZ.mjs").then((n) => n.n);
2069
- const normalizedSort = validateKeysetSort(parsedSort);
2103
+ const { validateKeysetSort } = await import("./sort-ffe-9JBg.mjs").then((n) => n.n);
2104
+ const { encodeCursor, resolveCursorFilter } = await import("./cursor-DZ9JSoMM.mjs").then((n) => n.t);
2105
+ const { getPrimaryField } = await import("./sort-ffe-9JBg.mjs").then((n) => n.n);
2106
+ const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
2070
2107
  const cursorVersion = this._pagination.config.cursorVersion ?? 1;
2071
- const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }) : { ...filters || {} };
2108
+ const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
2109
+ const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
2072
2110
  if (projection) {
2073
2111
  if (Object.values(projection).some((v) => v === 1)) {
2074
2112
  for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
@@ -2415,6 +2453,28 @@ var Repository = class {
2415
2453
  * ```
2416
2454
  */
2417
2455
  /**
2456
+ * Resolve the user's jitter config into a deterministic `ttl -> ttl` function.
2457
+ *
2458
+ * - `undefined` / `0`: identity (no jitter).
2459
+ * - number in (0, 1]: symmetric fractional jitter. `0.1` → uniform in [0.9, 1.1].
2460
+ * - function: used as-is; caller owns the math. Clamped to >= 1s below.
2461
+ *
2462
+ * The final TTL is always clamped to a whole second >= 1 so adapters that
2463
+ * treat 0 as "no expiry" (Redis SETEX semantics) don't accidentally persist
2464
+ * entries forever.
2465
+ */
2466
+ function resolveJitter(jitter) {
2467
+ if (!jitter) return (ttl) => ttl;
2468
+ if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
2469
+ const fraction = Math.min(1, Math.max(0, jitter));
2470
+ if (fraction === 0) return (ttl) => ttl;
2471
+ return (ttl) => {
2472
+ const delta = ttl * fraction;
2473
+ const jittered = ttl - delta + Math.random() * 2 * delta;
2474
+ return Math.max(1, Math.round(jittered));
2475
+ };
2476
+ }
2477
+ /**
2418
2478
  * Cache plugin factory
2419
2479
  *
2420
2480
  * @param options - Cache configuration
@@ -2428,7 +2488,8 @@ function cachePlugin(options) {
2428
2488
  queryTtl: options.queryTtl ?? options.ttl ?? 60,
2429
2489
  prefix: options.prefix ?? "mk",
2430
2490
  debug: options.debug ?? false,
2431
- skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
2491
+ skipIfLargeLimit: options.skipIf?.largeLimit ?? 100,
2492
+ applyJitter: resolveJitter(options.jitter)
2432
2493
  };
2433
2494
  const stats = {
2434
2495
  hits: 0,
@@ -2468,7 +2529,7 @@ function cachePlugin(options) {
2468
2529
  async function bumpVersion() {
2469
2530
  const newVersion = Date.now();
2470
2531
  try {
2471
- await config.adapter.set(versionKey(config.prefix, model), newVersion, config.ttl * 10);
2532
+ await config.adapter.set(versionKey(config.prefix, model), newVersion, config.applyJitter(config.ttl * 10));
2472
2533
  stats.invalidations++;
2473
2534
  log(`Bumped version for ${model} to:`, newVersion);
2474
2535
  } catch (e) {
@@ -2647,7 +2708,7 @@ function cachePlugin(options) {
2647
2708
  populate: context.populate,
2648
2709
  lean: context.lean
2649
2710
  });
2650
- const ttl = context.cacheTtl ?? config.byIdTtl;
2711
+ const ttl = config.applyJitter(context.cacheTtl ?? config.byIdTtl);
2651
2712
  try {
2652
2713
  await config.adapter.set(key, result, ttl);
2653
2714
  trackByIdKey(id, key);
@@ -2671,7 +2732,7 @@ function cachePlugin(options) {
2671
2732
  select: context.select,
2672
2733
  populate: context.populate
2673
2734
  });
2674
- const ttl = context.cacheTtl ?? config.queryTtl;
2735
+ const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
2675
2736
  try {
2676
2737
  await config.adapter.set(key, result, ttl);
2677
2738
  stats.sets++;
@@ -2693,7 +2754,7 @@ function cachePlugin(options) {
2693
2754
  populate: context.populate
2694
2755
  });
2695
2756
  try {
2696
- await config.adapter.set(key, result, context.cacheTtl ?? config.queryTtl);
2757
+ await config.adapter.set(key, result, config.applyJitter(context.cacheTtl ?? config.queryTtl));
2697
2758
  stats.sets++;
2698
2759
  } catch (e) {
2699
2760
  log(`Failed to cache getOne:`, e);
@@ -2726,7 +2787,7 @@ function cachePlugin(options) {
2726
2787
  countStrategy: context.countStrategy
2727
2788
  };
2728
2789
  const key = listQueryKey(config.prefix, model, collectionVersion, params);
2729
- const ttl = context.cacheTtl ?? config.queryTtl;
2790
+ const ttl = config.applyJitter(context.cacheTtl ?? config.queryTtl);
2730
2791
  try {
2731
2792
  await config.adapter.set(key, result, ttl);
2732
2793
  stats.sets++;
@@ -4215,6 +4276,57 @@ function subdocumentPlugin() {
4215
4276
  };
4216
4277
  }
4217
4278
  //#endregion
4279
+ //#region src/plugins/tenant-context.ts
4280
+ /**
4281
+ * Request-scoped tenant context via AsyncLocalStorage.
4282
+ *
4283
+ * The multi-tenant plugin already accepts a `resolveContext` callback for
4284
+ * reading tenant ID from an external store. This helper wraps Node's
4285
+ * `AsyncLocalStorage` into a tiny, batteries-included API so apps don't need
4286
+ * to wire the plumbing by hand — a common source of cross-tenant leaks.
4287
+ *
4288
+ * @example
4289
+ * import { createTenantContext, multiTenantPlugin } from '@classytic/mongokit';
4290
+ *
4291
+ * const tenantContext = createTenantContext();
4292
+ *
4293
+ * // In your HTTP middleware (Express / Fastify / NestJS):
4294
+ * app.use((req, _res, next) => {
4295
+ * tenantContext.run({ tenantId: req.auth.organizationId }, next);
4296
+ * });
4297
+ *
4298
+ * // In your repository definition:
4299
+ * const repo = new Repository(Invoice, [
4300
+ * multiTenantPlugin({
4301
+ * tenantField: 'organizationId',
4302
+ * resolveContext: () => tenantContext.getTenantId(),
4303
+ * }),
4304
+ * ]);
4305
+ *
4306
+ * // In your handler — no need to pass organizationId manually:
4307
+ * await repo.getAll({ filters: { status: 'paid' } });
4308
+ */
4309
+ function createTenantContext() {
4310
+ const storage = new AsyncLocalStorage();
4311
+ return {
4312
+ storage,
4313
+ run(store, fn) {
4314
+ return storage.run(store, fn);
4315
+ },
4316
+ getStore() {
4317
+ return storage.getStore();
4318
+ },
4319
+ getTenantId() {
4320
+ return storage.getStore()?.tenantId;
4321
+ },
4322
+ requireTenantId() {
4323
+ const id = storage.getStore()?.tenantId;
4324
+ if (!id) throw new Error("[mongokit] No tenant context active. Wrap the request in tenantContext.run({ tenantId }, fn).");
4325
+ return id;
4326
+ }
4327
+ };
4328
+ }
4329
+ //#endregion
4218
4330
  //#region src/plugins/timestamp.plugin.ts
4219
4331
  /**
4220
4332
  * Timestamp plugin that auto-injects timestamps
@@ -4388,4 +4500,4 @@ function uniqueField(field, errorMessage) {
4388
4500
  };
4389
4501
  }
4390
4502
  //#endregion
4391
- export { auditTrailPlugin as A, HOOK_PRIORITY as C, AggregationBuilder as D, withTransaction as E, aggregateHelpersPlugin as M, batchOperationsPlugin as O, cachePlugin as S, isTransactionUnsupported as T, dateSequentialId as _, uniqueField as a, sequentialId as b, subdocumentPlugin as c, multiTenantPlugin as d, mongoOperationsPlugin as f, customIdPlugin as g, elasticSearchPlugin as h, requireField as i, auditLogPlugin as j, AuditTrailQuery as k, softDeletePlugin as l, fieldFilterPlugin as m, blockIf as n, validationChainPlugin as o, methodRegistryPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, observabilityPlugin as u, getNextSequence as v, Repository as w, cascadePlugin as x, prefixedId as y };
4503
+ export { AuditTrailQuery as A, cachePlugin as C, withTransaction as D, isTransactionUnsupported as E, auditLogPlugin as M, aggregateHelpersPlugin as N, AggregationBuilder as O, cascadePlugin as S, Repository as T, customIdPlugin as _, uniqueField as a, prefixedId as b, createTenantContext as c, observabilityPlugin as d, multiTenantPlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, auditTrailPlugin as j, batchOperationsPlugin as k, subdocumentPlugin as l, methodRegistryPlugin as m, blockIf as n, validationChainPlugin as o, mongoOperationsPlugin as p, immutableField as r, timestampPlugin as s, autoInject as t, softDeletePlugin as u, dateSequentialId as v, HOOK_PRIORITY as w, sequentialId as x, getNextSequence as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.6.3",
3
+ "version": "3.7.0",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -101,8 +101,12 @@
101
101
  "scripts": {
102
102
  "build": "tsdown",
103
103
  "dev": "tsdown --watch",
104
- "test": "vitest run",
105
- "test:watch": "vitest",
104
+ "test": "vitest run --project unit --project integration",
105
+ "test:unit": "vitest run --project unit",
106
+ "test:integration": "vitest run --project integration",
107
+ "test:e2e": "vitest run --project e2e",
108
+ "test:all": "vitest run --project unit --project integration --project e2e",
109
+ "test:watch": "vitest --project unit --project integration",
106
110
  "test:coverage": "vitest run --coverage",
107
111
  "typecheck": "tsc --noEmit",
108
112
  "lint": "biome check src/",
@@ -124,6 +128,7 @@
124
128
  "@vitest/coverage-v8": "^3.2.4",
125
129
  "ajv": "^8.18.0",
126
130
  "ajv-formats": "^3.0.1",
131
+ "dotenv": "^17.4.2",
127
132
  "knip": "^6.3.0",
128
133
  "mongodb-memory-server": "^10.4.3",
129
134
  "mongoose": "^9.4.1",
@@ -1,63 +0,0 @@
1
- //#region src/utils/logger.ts
2
- const noop = () => {};
3
- let current = {
4
- warn: console.warn.bind(console),
5
- debug: noop
6
- };
7
- /**
8
- * Configure the internal logger.
9
- * Pass `false` to silence all output.
10
- */
11
- function configureLogger(config) {
12
- if (config === false) current = {
13
- warn: noop,
14
- debug: noop
15
- };
16
- else current = {
17
- ...current,
18
- ...config
19
- };
20
- }
21
- /** Emit a warning — security blocks, config issues, performance hints */
22
- function warn(message, ...args) {
23
- current.warn(message, ...args);
24
- }
25
- /** Emit debug info — only visible when debug is enabled */
26
- function debug(message, ...args) {
27
- current.debug(message, ...args);
28
- }
29
- //#endregion
30
- //#region src/utils/error.ts
31
- /**
32
- * Creates an error with HTTP status code
33
- *
34
- * @param status - HTTP status code
35
- * @param message - Error message
36
- * @returns Error with status property
37
- *
38
- * @example
39
- * throw createError(404, 'Document not found');
40
- * throw createError(400, 'Invalid input');
41
- * throw createError(403, 'Access denied');
42
- */
43
- function createError(status, message) {
44
- const error = new Error(message);
45
- error.status = status;
46
- return error;
47
- }
48
- /**
49
- * Detect and convert a MongoDB E11000 duplicate-key error into
50
- * a 409 HttpError with an actionable message.
51
- *
52
- * Returns `null` when the error is not a duplicate-key error.
53
- */
54
- function parseDuplicateKeyError(error) {
55
- if (!error || typeof error !== "object") return null;
56
- const mongoErr = error;
57
- if (mongoErr.code !== 11e3) return null;
58
- const fields = mongoErr.keyPattern ? Object.keys(mongoErr.keyPattern) : [];
59
- const values = mongoErr.keyValue ? Object.entries(mongoErr.keyValue).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
60
- return createError(409, fields.length ? `Duplicate value for ${fields.join(", ")}${values ? ` (${values})` : ""}` : "Duplicate key error");
61
- }
62
- //#endregion
63
- export { warn as a, debug as i, parseDuplicateKeyError as n, configureLogger as r, createError as t };