@classytic/mongokit 3.5.4 → 3.5.6

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 (29) hide show
  1. package/README.md +228 -9
  2. package/dist/{PaginationEngine-DCs-zKwZ.mjs → PaginationEngine-CpsQl471.mjs} +5 -3
  3. package/dist/actions/index.d.mts +1 -1
  4. package/dist/ai/index.d.mts +1 -1
  5. package/dist/coercion-B19MD38s.d.mts +106 -0
  6. package/dist/id-resolution-LZ-cCwR5.mjs +81 -0
  7. package/dist/{index-BNpVzw_-.d.mts → index-DmVbUzDL.d.mts} +1 -1
  8. package/dist/index.d.mts +84 -6
  9. package/dist/index.mjs +101 -36
  10. package/dist/indexes-qIBCZ3mY.d.mts +41 -0
  11. package/dist/{mongooseToJsonSchema-DpjhFfaO.mjs → mongooseToJsonSchema-8Cyu3NFJ.mjs} +50 -45
  12. package/dist/{mongooseToJsonSchema-Dsx3YAAx.d.mts → mongooseToJsonSchema-DQMU0Dlv.d.mts} +4 -3
  13. package/dist/pagination/PaginationEngine.d.mts +1 -1
  14. package/dist/pagination/PaginationEngine.mjs +1 -1
  15. package/dist/plugins/index.d.mts +1 -1
  16. package/dist/plugins/index.mjs +1 -1
  17. package/dist/query/primitives/coercion.d.mts +2 -0
  18. package/dist/query/primitives/coercion.mjs +148 -0
  19. package/dist/query/primitives/geo.d.mts +116 -0
  20. package/dist/query/primitives/geo.mjs +211 -0
  21. package/dist/query/primitives/indexes.d.mts +2 -0
  22. package/dist/query/primitives/indexes.mjs +46 -0
  23. package/dist/{types-CkE3mOaW.d.mts → types-BwO4u3JN.d.mts} +25 -0
  24. package/dist/utils/index.d.mts +60 -3
  25. package/dist/utils/index.mjs +4 -3
  26. package/dist/{validation-chain.plugin-BYMZB8dN.d.mts → validation-chain.plugin-BtmT-FFB.d.mts} +1 -1
  27. package/dist/{validation-chain.plugin-KjPV_6_N.mjs → validation-chain.plugin-DCYjnPau.mjs} +81 -20
  28. package/package.json +15 -3
  29. /package/dist/{field-selection-reyDRzXf.mjs → field-selection-C5o7jyyA.mjs} +0 -0
package/README.md CHANGED
@@ -15,9 +15,13 @@
15
15
  - **17 built-in plugins** - Caching, soft delete, audit trail, validation, multi-tenant, custom IDs, observability, Elasticsearch, and more
16
16
  - **Distributed cache safety** - List cache versions stored in the adapter (Redis) for multi-pod correctness
17
17
  - **Search governance** - Text index guard (throws `400` if no index), allowlisted sort/filter fields, ReDoS protection
18
+ - **Geo queries** - `[near]`, `[nearSphere]`, `[withinRadius]`, `[geoWithin]` URL operators with auto sort/count handling
19
+ - **Schema-aware coercion** - Pass `schema: Model.schema` to QueryParser for deterministic type-safe filter values
20
+ - **String/UUID/Number `_id` support** - Works out of the box with any `_id` type, not just ObjectId
21
+ - **Query primitives** - Composable pure functions (`coercion`, `geo`, `indexes`) importable standalone for custom parsers
18
22
  - **Vector search** - MongoDB Atlas `$vectorSearch` with auto-embedding and multimodal support
19
23
  - **TypeScript first** - Full type safety with discriminated unions, typed events, and field autocomplete
20
- - **Extensively tested** — battle-tested and production-ready
24
+ - **Extensively tested** — 1500+ tests, Mongoose 9.4.1 aligned, battle-tested and production-ready
21
25
 
22
26
  ## Installation
23
27
 
@@ -25,7 +29,7 @@
25
29
  npm install @classytic/mongokit mongoose
26
30
  ```
27
31
 
28
- > Requires Mongoose `^9.0.0` | Node.js `>=22`
32
+ > Requires Mongoose `>=9.4.1` | Node.js `>=22`
29
33
 
30
34
  ## Quick Start
31
35
 
@@ -192,6 +196,128 @@ const repo = new Repository(UserModel, plugins, {
192
196
  });
193
197
  ```
194
198
 
199
+ ### Search configuration
200
+
201
+ `Repository.getAll({ search })` has three layers, ordered from simplest to most
202
+ flexible. Pick the lowest one that solves your problem.
203
+
204
+ | Layer | When to use | Where it lives |
205
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
206
+ | **`searchMode: 'text'`** | You have a MongoDB text index and want `$text`-ranked results. | Repository option (default) |
207
+ | **`searchMode: 'regex'`** | Small/medium collection, no text index, simple substring matching across known fields. Works behind any HTTP framework. | Repository option |
208
+ | **Search-resolver plugin** | External engine (Elasticsearch, Meilisearch, Typesense, pgvector, hybrid BM25+vector). Composes with cache, multi-tenant. | `before:getAll` plugin — see Plugins below |
209
+
210
+ ```javascript
211
+ // Built-in regex mode — no MongoDB text index required
212
+ const repo = new Repository(
213
+ MemoModel,
214
+ [],
215
+ {},
216
+ {
217
+ // 'text' → default, requires text index
218
+ // 'regex' → case-insensitive $or of $regex across searchFields, no index needed
219
+ // 'auto' → text if a text index exists, otherwise regex
220
+ searchMode: "regex",
221
+ searchFields: ["title", "scope", "body"],
222
+ },
223
+ );
224
+
225
+ await repo.getAll({ search: "alpha", filters: { scope: "public" } });
226
+ ```
227
+
228
+ User input is regex-escaped (literal substring match), and any pre-existing
229
+ `$or` filter is preserved by promoting both clauses to `$and`.
230
+
231
+ > **QueryParser ↔ Repository:** `QueryParser` also exposes `searchMode`/`searchFields`
232
+ > for the HTTP boundary (`?search=foo` URL → parsed query). The two layers are
233
+ > independent: configure the parser if you want URL-time consumption (Arc does
234
+ > this), configure the Repository if you want data-layer consumption
235
+ > (Express/Nest/raw handlers), or both for defense in depth — they will not
236
+ > conflict because the parser clears `parsed.search` before it reaches the repo.
237
+
238
+ ### Geo queries
239
+
240
+ QueryParser supports 4 geo operators that compile to canonical GeoJSON-shaped
241
+ MongoDB queries. All validate coordinate ranges and reject malformed input by
242
+ dropping the filter (not silently widening).
243
+
244
+ ```javascript
245
+ const parser = new QueryParser({ schema: PlaceModel.schema });
246
+
247
+ // Distance-sorted (implicit distance ordering, no explicit sort allowed)
248
+ const near = parser.parse({ "location[near]": "-73.98,40.76,5000" });
249
+ // → { location: { $near: { $geometry: { type: 'Point', coordinates: [-73.98, 40.76] }, $maxDistance: 5000 } } }
250
+
251
+ // Count-compatible radius — use this for paginated getAll (supports sort + count)
252
+ const radius = parser.parse({ "location[withinRadius]": "-73.98,40.76,5000" });
253
+ // → { location: { $geoWithin: { $centerSphere: [[-73.98, 40.76], 0.000783...] } } }
254
+
255
+ // Bounding box (works without a 2dsphere index)
256
+ const box = parser.parse({ "location[geoWithin]": "-74,40.7,-73.9,40.8" });
257
+ // → { location: { $geoWithin: { $box: [[-74, 40.7], [-73.9, 40.8]] } } }
258
+ ```
259
+
260
+ **Repository integration:** `Repository.getAll` auto-detects `$near` /
261
+ `$nearSphere` in filters and handles the MongoDB constraints transparently:
262
+
263
+ - Skips the default `-createdAt` sort (MongoDB forbids explicit sort with `$near`)
264
+ - Warns and drops any caller-supplied sort that conflicts
265
+ - Rewrites the count query via `$geoWithin: $centerSphere` for accurate `total`
266
+ - Falls back to `countStrategy: 'none'` for unbounded `$near` (no `$maxDistance`)
267
+ - All plugins (multi-tenant, soft-delete, cache, populate) compose unchanged
268
+
269
+ Use `[near]` when you need distance-sorted results. Use `[withinRadius]` when
270
+ you need paginated lists with `total` and custom sort.
271
+
272
+ ### String / UUID / Number `_id`
273
+
274
+ Schemas that declare `_id` as `String`, `Number`, or `UUID` instead of the
275
+ default `ObjectId` work out of the box — no configuration needed:
276
+
277
+ ```typescript
278
+ const SessionSchema = new Schema({
279
+ _id: { type: String, default: () => randomUUID() },
280
+ userId: { type: String, required: true },
281
+ });
282
+
283
+ const repo = new Repository(SessionModel);
284
+ await repo.create({ userId: "user-1" }); // _id auto-generated as UUID
285
+ await repo.getById("550e8400-e29b-..."); // works — no ObjectId validation
286
+ await repo.update("550e8400-e29b-...", { ... }); // works
287
+ ```
288
+
289
+ The `id-resolution` utility detects the `_id` type from the schema and
290
+ validates accordingly. Schema generation also includes String `_id` as an
291
+ optional field in `createBody` so Fastify/AJV validation accepts user-supplied
292
+ IDs. Available standalone:
293
+
294
+ ```typescript
295
+ import { getSchemaIdType, isValidIdForType } from "@classytic/mongokit/utils";
296
+
297
+ const idType = getSchemaIdType(SessionModel.schema); // 'string'
298
+ isValidIdForType("550e8400-...", idType); // true
299
+ isValidIdForType("not-valid", "objectid"); // false
300
+ ```
301
+
302
+ ### Query primitives
303
+
304
+ Pure, composable functions that power QueryParser internals — importable
305
+ standalone for custom parsers, Arc controllers, migration scripts, and AI
306
+ agents that build queries programmatically:
307
+
308
+ ```typescript
309
+ // Geo
310
+ import { parseGeoFilter, hasNearOperator } from "@classytic/mongokit/query/primitives/geo";
311
+ // Coercion
312
+ import { coerceFieldValue, buildFieldTypeMap } from "@classytic/mongokit/query/primitives/coercion";
313
+ // Schema indexes
314
+ import { extractSchemaIndexes } from "@classytic/mongokit/query/primitives/indexes";
315
+ ```
316
+
317
+ Each primitive is independently unit-tested, tree-shake friendly (no barrel),
318
+ and decoupled from Mongoose — they accept structural types, not concrete
319
+ Mongoose instances.
320
+
195
321
  ## Plugins
196
322
 
197
323
  ### Using Plugins
@@ -754,6 +880,62 @@ const results = await repo.searchSimilar({ query: [0.1, 0.2, ...], limit: 5 });
754
880
  const vector = await repo.embed('some text');
755
881
  ```
756
882
 
883
+ ### Custom Search Backends (search-resolver plugin contract)
884
+
885
+ The built-in `searchMode: 'text' | 'regex'` is sugar over a more general
886
+ **plugin contract** for any search backend — Elasticsearch, Meilisearch,
887
+ Typesense, Algolia, pgvector, Pinecone, hybrid BM25+vector. The contract
888
+ keeps mongokit core free of backend-specific dependencies, and the same
889
+ plugin works behind any HTTP framework (Arc, Express, Nest, raw handlers)
890
+ without further wiring.
891
+
892
+ **The contract** (4 lines):
893
+
894
+ 1. Hook `before:getAll`.
895
+ 2. If `ctx.search` is set, resolve it against your backend (returns IDs).
896
+ 3. Mutate `ctx.filters` to constrain the Mongo query: `_id: { $in: ids }`.
897
+ 4. Set `ctx.search = undefined` so Repository's text-index check is bypassed.
898
+
899
+ ```javascript
900
+ function meilisearchPlugin({ client, index }) {
901
+ return (repo) => {
902
+ repo.on("before:getAll", async (ctx) => {
903
+ if (!ctx.search) return;
904
+ const hits = await client.index(index).search(ctx.search, {
905
+ limit: ctx.limit ?? 20,
906
+ });
907
+ const ids = hits.hits.map((h) => h.id);
908
+ ctx.filters = { ...(ctx.filters ?? {}), _id: { $in: ids } };
909
+ ctx.search = undefined; // bypass text-index check
910
+ ctx._meiliRanking = ids; // stash for after:getAll re-sort
911
+ });
912
+ repo.on("after:getAll", (ctx) => {
913
+ if (!ctx._meiliRanking) return;
914
+ const order = new Map(ctx._meiliRanking.map((id, i) => [String(id), i]));
915
+ ctx.result.docs.sort(
916
+ (a, b) => order.get(String(a._id)) - order.get(String(b._id)),
917
+ );
918
+ });
919
+ };
920
+ }
921
+
922
+ // Wire at Repository construction — works in Arc, Express, Nest, anywhere
923
+ const repo = new Repository(ProductModel, [
924
+ meilisearchPlugin({ client: meili, index: "products" }),
925
+ cachePlugin({ adapter: createMemoryCache(), ttl: 60 }), // composes cleanly
926
+ ]);
927
+ ```
928
+
929
+ **Why this contract works:**
930
+
931
+ - `before:getAll` runs inside `_buildContext`, _before_ Repository reads `search` or hits the text-index check. A plugin that clears `ctx.search` short-circuits the built-in search path entirely.
932
+ - `Repository.getAll` reads `search` only from the post-hook context (no fallback to original params) — this is the framework-level guarantee that makes `ctx.search = undefined` a real clear, not a silently overridden no-op.
933
+ - The caller's existing filters are preserved by spreading: `ctx.filters = { ...(ctx.filters ?? {}), _id: ... }`. Multi-tenant scoping (which also runs in `before:getAll`), soft-delete filters, and the search plugin compose without ordering concerns — they all mutate `ctx.filters` independently and all survive.
934
+ - Cache plugin composes too: it sees the post-hook context (filters with the resolved ID list), so cache keys are accurate. ES/Meili results get cached just like Mongo results.
935
+ - The same plugin shape works for every backend; only the resolver call changes.
936
+
937
+ The bundled `elasticSearchPlugin` (next section) is the canonical reference implementation, and `tests/repository-search-mode.test.ts` exercises this contract end-to-end with multi-tenant + soft-delete + cache composition.
938
+
757
939
  ### Elasticsearch / OpenSearch Plugin
758
940
 
759
941
  Delegates heavy text and semantic search to an external search engine while fetching full documents from MongoDB. Keeps your OLTP (transactional) MongoDB operations fast by separating search I/O.
@@ -1044,12 +1226,13 @@ Converts HTTP query strings to MongoDB queries with built-in security:
1044
1226
  import { QueryParser } from "@classytic/mongokit";
1045
1227
 
1046
1228
  const parser = new QueryParser({
1229
+ schema: ProductModel.schema, // Schema-aware coercion (recommended)
1047
1230
  maxLimit: 100, // Prevent excessive queries
1048
1231
  maxFilterDepth: 5, // Prevent nested injection
1049
1232
  maxRegexLength: 100, // ReDoS protection
1050
- allowedFilterFields: ['status', 'name', 'email'], // Whitelist filter fields
1051
- allowedSortFields: ['createdAt', 'name'], // Whitelist sort fields
1052
- allowedOperators: ['eq', 'ne', 'in', 'gt', 'lt'], // Whitelist operators
1233
+ allowedFilterFields: ["status", "name", "email"], // Whitelist filter fields
1234
+ allowedSortFields: ["createdAt", "name"], // Whitelist sort fields
1235
+ allowedOperators: ["eq", "ne", "in", "gt", "lt"], // Whitelist operators
1053
1236
  });
1054
1237
 
1055
1238
  // Parse request query
@@ -1061,6 +1244,35 @@ parser.allowedSortFields; // ['createdAt', 'name'] or undefined
1061
1244
  parser.allowedOperators; // ['eq', 'ne', 'in', 'gt', 'lt'] or undefined
1062
1245
  ```
1063
1246
 
1247
+ **Schema-aware value coercion (recommended):** Pass `schema: Model.schema` and
1248
+ the parser coerces filter values exactly to each field's declared type. With
1249
+ schema configured, `?stock=50` against a `Number` field becomes `50` (number),
1250
+ `?name=12345` against a `String` field stays `'12345'`, `?releasedAt=2026-04-07`
1251
+ against a `Date` field becomes a `Date` instance, `?address.zip=01234` against
1252
+ a nested `String` field preserves the leading zero. Direct equality and
1253
+ operator syntax (`stock[gte]=50`) coerce identically — no more "why does
1254
+ operator work but direct equality doesn't" surprises. Unknown fields fall
1255
+ through to a safe heuristic so ad-hoc filters still work.
1256
+
1257
+ For DB-agnostic setups (raw MongoDB, Prisma, computed fields, models you
1258
+ don't own), use `fieldTypes` instead of `schema`. The two can be combined —
1259
+ `fieldTypes` overrides `schema` per path:
1260
+
1261
+ ```typescript
1262
+ const parser = new QueryParser({
1263
+ schema: ProductModel.schema,
1264
+ fieldTypes: {
1265
+ "address.zip": "string", // override a Mongoose path
1266
+ legacyCount: "number", // declare a non-schema field
1267
+ },
1268
+ });
1269
+ ```
1270
+
1271
+ Without either option, the parser falls back to a string-shape heuristic
1272
+ (numeric strings without leading zeros and ≤15 chars become numbers; ObjectId
1273
+ hex stays a string; `'true'`/`'false'` become booleans). The heuristic is
1274
+ safe but inherently lossy — schema-aware mode is strictly better.
1275
+
1064
1276
  **Supported query patterns:**
1065
1277
 
1066
1278
  ```bash
@@ -1078,9 +1290,15 @@ GET /users?after=eyJfaWQiOi...&limit=20 # Cursor-based
1078
1290
  # Sorting
1079
1291
  GET /users?sort=-createdAt,name
1080
1292
 
1081
- # Search (requires text index)
1293
+ # Search (default: text index; or configure searchMode: 'regex' / 'auto')
1082
1294
  GET /users?search=john
1083
1295
 
1296
+ # Geo queries (requires 2dsphere index for near/nearSphere)
1297
+ GET /places?location[near]=-73.98,40.76,5000 # Within 5km, distance-sorted
1298
+ GET /places?location[nearSphere]=-73.98,40.76,5000 # Spherical variant
1299
+ GET /places?location[withinRadius]=-73.98,40.76,5000 # Count-compatible radius (use for paginated lists)
1300
+ GET /places?location[geoWithin]=-74,40.7,-73.9,40.8 # Bounding box (no index required)
1301
+
1084
1302
  # Simple populate
1085
1303
  GET /posts?populate=author,category
1086
1304
 
@@ -1476,14 +1694,15 @@ await repo.getById('my-chat-uuid', { idField: 'chatId' }); // by chatId
1476
1694
 
1477
1695
  All plugins respect `idField`: soft-delete, cascade, audit-log, audit-trail, validation-chain, elastic, cache, observability.
1478
1696
 
1479
- ## No Breaking Changes
1697
+ ## Mongoose Compatibility
1480
1698
 
1481
- Extending Repository works exactly the same with Mongoose 8 and 9. The package:
1699
+ Requires Mongoose `>=9.4.1`. Tested and aligned with Mongoose 9.4.1. The package:
1482
1700
 
1483
1701
  - Uses its own event system (not Mongoose middleware)
1484
1702
  - Defines its own `FilterQuery` type (unaffected by Mongoose 9 rename)
1485
1703
  - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
1486
- - Full test suite passes on Mongoose 9
1704
+ - Reads `embeddedSchemaType.instance` for array element types (Mongoose 9.x)
1705
+ - 1500+ tests pass on Mongoose 9.4.1
1487
1706
 
1488
1707
  ## License
1489
1708
 
@@ -129,7 +129,7 @@ var PaginationEngine = class {
129
129
  * console.log(result.docs, result.total, result.hasNext);
130
130
  */
131
131
  async paginate(options = {}) {
132
- const { filters = {}, sort = { _id: -1 }, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = "exact", readPreference, collation } = options;
132
+ const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = "exact", readPreference, collation } = options;
133
133
  const sanitizedPage = validatePage(page, this.config);
134
134
  const sanitizedLimit = validateLimit(limit, this.config);
135
135
  const skip = calculateSkip(sanitizedPage, sanitizedLimit);
@@ -137,7 +137,8 @@ var PaginationEngine = class {
137
137
  let query = this.Model.find(filters);
138
138
  if (select) query = query.select(select);
139
139
  if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
140
- query = query.sort(sort).skip(skip).limit(fetchLimit).lean(lean);
140
+ if (sort) query = query.sort(sort);
141
+ query = query.skip(skip).limit(fetchLimit).lean(lean);
141
142
  if (collation) query = query.collation(collation);
142
143
  if (session) query = query.session(session);
143
144
  if (hint) query = query.hint(hint);
@@ -149,7 +150,8 @@ var PaginationEngine = class {
149
150
  if ((countStrategy === "estimated" || useEstimated) && !hasFilters) countPromise = this.Model.estimatedDocumentCount();
150
151
  else if (countStrategy === "none") countPromise = Promise.resolve(0);
151
152
  else {
152
- const countQuery = this.Model.countDocuments(filters).session(session ?? null);
153
+ const countTarget = countFilters ?? filters;
154
+ const countQuery = this.Model.countDocuments(countTarget).session(session ?? null);
153
155
  if (hint) countQuery.hint(hint);
154
156
  if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
155
157
  if (readPreference) countQuery.read(readPreference);
@@ -1,2 +1,2 @@
1
- import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-BNpVzw_-.mjs";
1
+ import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-DmVbUzDL.mjs";
2
2
  export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
@@ -1,4 +1,4 @@
1
- import { J as Plugin } from "../types-CkE3mOaW.mjs";
1
+ import { J as Plugin } from "../types-BwO4u3JN.mjs";
2
2
  import { ClientSession, PipelineStage } from "mongoose";
3
3
 
4
4
  //#region src/ai/types.d.ts
@@ -0,0 +1,106 @@
1
+ //#region src/query/primitives/coercion.d.ts
2
+ /**
3
+ * Value coercion primitives
4
+ *
5
+ * Pure functions that convert raw URL-decoded values into typed values
6
+ * suitable for MongoDB queries. Two layers:
7
+ *
8
+ * 1. `coerceHeuristic(value)` — string-shape based, used when no schema
9
+ * is configured. Safe defaults: rejects leading zeros, scientific
10
+ * notation, and strings >15 chars to preserve zip codes, phone codes,
11
+ * and long numeric IDs that exceed JS safe-integer precision.
12
+ *
13
+ * 2. `coerceToType(value, type)` — type-directed, used when a Mongoose
14
+ * schema or `fieldTypes` map declares the field's type. Strictly
15
+ * better than the heuristic when type information is available.
16
+ *
17
+ * QueryParser composes these via `coerceFieldValue(field, value, types)`
18
+ * which prefers the type-directed path and falls back to the heuristic.
19
+ *
20
+ * Field-type extraction from a Mongoose schema is also here so the
21
+ * normalization logic lives next to the coercion that consumes it.
22
+ */
23
+ /** Normalized field type used for schema-aware coercion */
24
+ type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'objectid' | 'mixed';
25
+ /**
26
+ * Minimal structural type for a Mongoose schema (read-only `.paths`).
27
+ * Mongoose stores array element types in two different shadow properties
28
+ * depending on version and declaration style:
29
+ * - `caster.instance` — older / some array forms
30
+ * - `embeddedSchemaType.instance` — Mongoose 8+, both `[Type]` and `[{ type: Type }]`
31
+ * We read both and prefer whichever is populated.
32
+ */
33
+ interface SchemaPathsLike {
34
+ paths: Record<string, {
35
+ instance?: string;
36
+ caster?: {
37
+ instance?: string;
38
+ };
39
+ embeddedSchemaType?: {
40
+ instance?: string;
41
+ };
42
+ }>;
43
+ }
44
+ /**
45
+ * Map a Mongoose `SchemaType.instance` string to our normalized FieldType.
46
+ * For arrays (`type: [Number]` or `type: [{ type: Number }]`), Mongoose
47
+ * stores the element type on `embeddedSchemaType.instance` (Mongoose 8+) or
48
+ * `caster.instance` (older). We probe both — element-level coercion is what
49
+ * `[in]`/`[nin]` operators need against typed arrays.
50
+ *
51
+ * Returns null for paths we cannot classify (embedded documents handled by
52
+ * their own nested paths).
53
+ */
54
+ declare function normalizeMongooseType(schemaType: {
55
+ instance?: string;
56
+ caster?: {
57
+ instance?: string;
58
+ };
59
+ embeddedSchemaType?: {
60
+ instance?: string;
61
+ };
62
+ }): FieldType | null;
63
+ /**
64
+ * Build a normalized field-type map from a Mongoose schema and an optional
65
+ * override map. The override wins on a per-path basis. Paths with unmappable
66
+ * types are silently skipped — `coerceFieldValue` will fall back to the
67
+ * heuristic for them.
68
+ */
69
+ declare function buildFieldTypeMap(schema?: SchemaPathsLike | null, overrides?: Record<string, FieldType>): Map<string, FieldType>;
70
+ /**
71
+ * String-shape heuristic — used when no field type is known.
72
+ *
73
+ * Rules (deliberately conservative — silent miscoercion is worse than
74
+ * leaving a string):
75
+ * - `null`/`undefined` pass through
76
+ * - Already-typed primitives (number, boolean) pass through
77
+ * - Arrays recurse element-wise
78
+ * - Objects pass through (filter operator objects, etc.)
79
+ * - `'true'`/`'false'` → boolean
80
+ * - 24-char hex → kept as string (ObjectId)
81
+ * - Strings 1–15 chars matching `^-?(0|[1-9]\d*)(\.\d+)?$` → number
82
+ * (rejects leading zeros, scientific notation, hex/octal, NaN, Infinity)
83
+ * - Everything else stays a string
84
+ */
85
+ declare function coerceHeuristic(value: unknown): unknown;
86
+ /**
87
+ * Type-directed coercion — used when a field's declared type is known.
88
+ *
89
+ * Returns the original value unchanged when coercion is impossible
90
+ * (invalid date, non-numeric string for a Number field) — better to surface
91
+ * a no-match than to silently produce `Invalid Date` or `NaN` which MongoDB
92
+ * treats as legitimate query values.
93
+ */
94
+ declare function coerceToType(value: unknown, type: FieldType): unknown;
95
+ /**
96
+ * Field-aware coercion. Looks up `field` in `types` and uses
97
+ * `coerceToType` when found, otherwise falls back to `coerceHeuristic`.
98
+ *
99
+ * This is the single choke point QueryParser uses for typed coercion of
100
+ * direct equality, `[eq]`, `[in]`, `[nin]`, `[gt]`/`[gte]`/`[lt]`/`[lte]`
101
+ * (the last only when a type is declared — see QueryParser for the
102
+ * preserves-pre-3.5.5-behavior fallback).
103
+ */
104
+ declare function coerceFieldValue(field: string, value: unknown, types: Map<string, FieldType>): unknown;
105
+ //#endregion
106
+ export { coerceHeuristic as a, coerceFieldValue as i, SchemaPathsLike as n, coerceToType as o, buildFieldTypeMap as r, normalizeMongooseType as s, FieldType as t };
@@ -0,0 +1,81 @@
1
+ import mongoose from "mongoose";
2
+ //#region src/utils/id-resolution.ts
3
+ /**
4
+ * ID Resolution Primitive
5
+ *
6
+ * Pure functions for detecting a Mongoose schema's _id type and validating
7
+ * id values against it. Used by Repository.getById to decide whether to
8
+ * run ObjectId validation (for ObjectId schemas) or skip it (for String /
9
+ * Number / UUID schemas).
10
+ *
11
+ * Also usable standalone — Arc controllers, custom repositories, migration
12
+ * scripts, or test harnesses can import these directly to make the same
13
+ * decision without depending on Repository.
14
+ *
15
+ * Design: no `this`, no Model, no DB calls. Only reads `schema.paths._id.instance`.
16
+ */
17
+ /**
18
+ * Check if a Mongoose SchemaType `instance` string is an ObjectId variant.
19
+ * Mongoose 9.x uses `'ObjectId'`, older versions use `'ObjectID'`. This
20
+ * is the single source of truth — use it instead of inline
21
+ * `instance === 'ObjectId' || instance === 'ObjectID'` checks.
22
+ */
23
+ function isObjectIdInstance(instance) {
24
+ return instance === "ObjectId" || instance === "ObjectID";
25
+ }
26
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+ /**
28
+ * Detect the _id path type from a Mongoose schema.
29
+ *
30
+ * Returns 'objectid' as the safe default when:
31
+ * - No schema is provided (mock models, test harnesses)
32
+ * - The schema has no `paths._id` (auto-generated ObjectId)
33
+ * - The instance string is unrecognized
34
+ *
35
+ * This safe-default means existing callers that don't set _id explicitly
36
+ * get the same ObjectId validation they always had — the behavioral change
37
+ * is strictly opt-in via schema declaration.
38
+ */
39
+ function getSchemaIdType(schema) {
40
+ const instance = schema?.paths?._id?.instance;
41
+ if (!instance) return "objectid";
42
+ if (isObjectIdInstance(instance)) return "objectid";
43
+ switch (instance) {
44
+ case "String": return "string";
45
+ case "Number": return "number";
46
+ case "UUID": return "uuid";
47
+ default: return "unknown";
48
+ }
49
+ }
50
+ /**
51
+ * Validate an id value against a known _id type — WITHOUT hitting the DB.
52
+ *
53
+ * Returns `true` when the value is structurally valid for the given type.
54
+ * Returns `false` when it's definitely invalid (Repository should return
55
+ * 404 / null immediately rather than wasting a round-trip).
56
+ *
57
+ * For `'string'` and `'unknown'` types, validation is intentionally
58
+ * permissive: any truthy, non-empty value is accepted. The DB is the
59
+ * authority for whether the document exists — we only reject values that
60
+ * are structurally impossible (null, undefined, empty string).
61
+ */
62
+ function isValidIdForType(id, idType) {
63
+ if (id === null || id === void 0) return false;
64
+ switch (idType) {
65
+ case "objectid": {
66
+ if (typeof id === "object") return mongoose.Types.ObjectId.isValid(id);
67
+ const s = String(id);
68
+ return s.length > 0 && mongoose.Types.ObjectId.isValid(s);
69
+ }
70
+ case "string": return String(id).length > 0;
71
+ case "number": {
72
+ if (typeof id === "number") return Number.isFinite(id);
73
+ const n = Number(id);
74
+ return Number.isFinite(n);
75
+ }
76
+ case "uuid": return typeof id === "string" && UUID_RE.test(id);
77
+ case "unknown": return String(id).length > 0;
78
+ }
79
+ }
80
+ //#endregion
81
+ export { isObjectIdInstance as n, isValidIdForType as r, getSchemaIdType as t };
@@ -1,4 +1,4 @@
1
- import { $ as ReadPreferenceType, At as LookupOptions, D as GroupResult, V as ObjectId, W as OperationOptions, Z as PopulateSpec, _t as UpdateOptions, a as AnyDocument, b as DeleteResult, g as CreateOptions, gt as UpdateManyResult, pt as SortSpec, st as SelectSpec, vt as UpdateWithValidationResult, z as MinMaxResult } from "./types-CkE3mOaW.mjs";
1
+ import { $ as ReadPreferenceType, At as LookupOptions, D as GroupResult, V as ObjectId, W as OperationOptions, Z as PopulateSpec, _t as UpdateOptions, a as AnyDocument, b as DeleteResult, g as CreateOptions, gt as UpdateManyResult, pt as SortSpec, st as SelectSpec, vt as UpdateWithValidationResult, z as MinMaxResult } from "./types-BwO4u3JN.mjs";
2
2
  import { ClientSession, Model, PipelineStage } from "mongoose";
3
3
 
4
4
  //#region src/actions/aggregate.d.ts