@querykitjs/mongoose 2.0.0 → 3.0.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.
package/README.en.md CHANGED
@@ -69,6 +69,15 @@ bun add @querykitjs/mongoose mongoose
69
69
 
70
70
  `mongoose` (>= 8) is a peer dependency.
71
71
 
72
+ ### Requirements
73
+
74
+ | | Version | Tested (`bun run test:matrix`) |
75
+ | ----------------- | ------------------ | ------------------------------ |
76
+ | Node.js | `>=22.12` | 24 |
77
+ | `mongoose` (peer) | `^8.24 \|\| ^9.11` | 8.24.7, 9.11.4 |
78
+
79
+ Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested.
80
+
72
81
  ## Setup
73
82
 
74
83
  ### 1. Registry (once)
@@ -99,6 +108,12 @@ The `connection` is passed in (so transactions share the right session).
99
108
  `maxPerPage`/`maxLimit` are a **second layer**: even if validation
100
109
  (`@querykitjs/zod` factories) is bypassed, the repository clamps.
101
110
 
111
+ `timestamps` — the createdAt / deletedAt field names (for example in a snake_case project): the default sort and the default `sortable` use that `createdAt`, soft delete (`softDelete`, `restore`, `withDeleted`) uses that `deletedAt`.
112
+
113
+ ```ts
114
+ export const registry = createRegistry(mongoose.connection, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
115
+ ```
116
+
102
117
  ### 2. Per-model repositories
103
118
 
104
119
  ```ts
@@ -199,6 +214,41 @@ A `scope` or `cursorKey` key is **always** fatal, regardless of `strict`.
199
214
  - **Cursor** keys on `_id` by default (override with `cursorKey`) — stable under inserts.
200
215
  - **Transactions need a replica set** — a single-node one is enough for local dev (`mongod --replSet rs0` then `rs.initiate()`, or [`mongodb-memory-server`](https://github.com/typegoose/mongodb-memory-server) in tests).
201
216
 
217
+ - **Indexes and operators.** `startsWith` is an anchored (`^`) but case-insensitive (`i`) regex: MongoDB uses an index poorly for it. On a large collection keep a lowercase shadow field (`nameLower`) and give that `=` / `startsWith`. `contains`, `endsWith`, `!=` and `notIn` cannot use an index (COLLSCAN) — closed in the default `filterable`, open them only for small collections. Use `in` for enum fields.
218
+
219
+ ## Safe by default — `filterable`, `sortable`, `limits`
220
+
221
+ A client filters and sorts only by the paths the server allows, and only with the operators it allows. A refused request stops with a `QueryKitError` before it reaches MongoDB — whatever `strict` and `onSkippedCondition` say. Option names, defaults and `reason`/`site` are the same as drizzle-pg's and prisma-pg's (`id` ≡ `_id`).
222
+
223
+ ```ts
224
+ const usersRepository = registry.repository(User, {
225
+ forcedColumns: { _id: true, name: true, status: true, createdAt: true },
226
+ filterable: {
227
+ status: ["=", "in"],
228
+ createdAt: ["between", ">=", "<="],
229
+ name: ["startsWith"], // open only where listed
230
+ },
231
+ sortable: ["_id", "createdAt", "name"],
232
+ limits: { maxInItems: 500 },
233
+ maxTimeMS: 1000,
234
+ });
235
+ ```
236
+
237
+ | Option | When not given (default) | When refused |
238
+ | ------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
239
+ | `filterable` | `allowedColumns` (else the `forcedColumns` keys, else only the primary key), with `"="` and `"in"` only | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
240
+ | `sortable` | the primary key and `createdAt` (when the table has it); a client-sent `cursorKey` too | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
241
+ | `limits` | core `DEFAULT_QUERY_LIMITS` (depth 5, conditions 30, array 200, string 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
242
+
243
+ - `aggregate`: the `filter` and every `groupBy`/`sum`/`avg`/`min`/`max` column must be in `filterable` (`site: "aggregate"`).
244
+ - `findById`: only the client `filter` is checked; `idKey` is the server's choice.
245
+ - A key the table does **not** have (`nope`) stays `unknown-key` — skipped, or an error under `strict`.
246
+ - Mutation filters (`updateMany`, `deleteMany`, …) count as server code and are not checked.
247
+ - Operator aliases are one: `["="]` also admits `eq`, `["contains"]` also `%_%`. `"*"` admits every operator.
248
+ - An unbranded raw Mongo filter (`{ $where: … }`, `{ secret: { $regex: … } }`, `{ $expr: … }`) can come from a JSON body — `forbidden-key`, nothing is sent (R19). Server code brands it with `raw({...})`; ⚠️ `raw()` is not checked by `filterable`.
249
+ - LIKE patterns: more than `maxLikeWildcards` (2) `%`/`_` → `limit-exceeded`; a run of `%%` counts once.
250
+ - Every read (`find`, `findOne`, `countDocuments`, `aggregate`) carries `maxTimeMS` (default 2 000); past it ⇒ `QueryKitTimeoutError` (`code: "QUERYKIT_TIMEOUT"`) — map it to 503/504, not 400.
251
+
202
252
  ## Advanced filters
203
253
 
204
254
  A filter is a tree of field conditions and logical groups (`and`/`or`/`not`). A flat array is treated as implicit `AND`. `id` resolves to `_id`.
@@ -232,6 +282,8 @@ Falls back to `createdAt DESC`, then `_id DESC`, when no sort is given — a det
232
282
  ```ts
233
283
  // 1. Offset — total_items / total_pages
234
284
  const page = await usersRepository.findList({ page: 2, perPage: 20, filter, sort });
285
+ // withTotal: false — no count query (large tables): perPage + 1 rows, total_items / total_pages = null
286
+ const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
235
287
 
236
288
  // 2. Infinite scroll (limit + offset) — has_more / next_offset
237
289
  const feed = await usersRepository.findInfinite({ limit: 20, offset: 40 });
@@ -242,8 +294,32 @@ const p2 = await usersRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
242
294
  const back = await usersRepository.findCursor({ cursor: p2.meta.prev_cursor, direction: "backward" });
243
295
  ```
244
296
 
297
+ ### Cursor: ties and signing
298
+
299
+ - The seek key is `(cursorKey, _id)`: with a non-unique `cursorKey` (for example a shared `createdAt`) no row is skipped or repeated. When `cursorKey` is the primary key itself the seek uses that one field.
300
+ - A token (v2) holds only scalar values (string, number, boolean, ISO date) and they are cast to the field type; an object, an array or a value that does not fit → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (the driver is not called).
301
+ - The registry option `cursorSecret` signs tokens with HMAC-SHA256; an unsigned or altered token → `invalid-value`. Without it tokens are unsigned — a client can build one itself (still only within the allowed `sortable` keys and the filter).
302
+
303
+ ```ts
304
+ export const registry = createRegistry(mongoose.connection, { cursorSecret: process.env.CURSOR_SECRET });
305
+ ```
306
+
245
307
  ## Typed relations & field selection
246
308
 
309
+ A client `with` opens only the relations listed in `relations` (task 014, R21):
310
+
311
+ ```ts
312
+ const postsRepository = registry.repository(Post, {
313
+ relations: { author: { model: User, columns: ["name"] } }, // model — for typing only
314
+ });
315
+ await postsRepository.findAll({ with: { author: true } }); // author with name only
316
+ ```
317
+
318
+ - `columns` is required — a list or an explicit `"*"`. `with: { rel: true }` also selects only those columns.
319
+ - An unlisted relation, column, filter field or nested relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` `relation` or `relation.field`; the driver is not called.
320
+ - Allowed options: `select`, a nested `populate` (with `nested`), `match` (its fields in `filterable`; no `$where` / `$expr`), `options.limit` / `options.skip`.
321
+ - The shorthand `{ author: User }` is for typing only: `with: { author: … }` → `forbidden-key`.
322
+
247
323
  Read methods infer their return type from `with` (populate) and `columns` — no manual generics:
248
324
 
249
325
  ```ts
@@ -255,10 +331,31 @@ rows[0].id; // ObjectId
255
331
  rows[0].email; // ❌ type error — not selected
256
332
  ```
257
333
 
258
- To type populated relations at runtime, pass the referenced models: `registry.repository(Post, { relations: { author: User } })`.
334
+ To type and open populated relations, give the referenced model inside the rule: `registry.repository(Post, { relations: { author: { model: User, columns: ["name"] } } })` (the shorthand `{ author: User }` only types — it does not allow `with`).
335
+
336
+ ## Bulk update and delete
337
+
338
+ `updateWhere` / `deleteWhere` are one write (no read before it) and return `{ count }`:
339
+
340
+ ```ts
341
+ const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
342
+ await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
343
+
344
+ // returning: { limit } — { count, rows }, at most limit rows (maxLimit, default 200)
345
+ const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
346
+
347
+ // no filter — only when you say so (the scope still applies)
348
+ await usersRepository.deleteWhere({ all: true });
349
+ ```
350
+
351
+ - An empty filter (`undefined`, `{}`, `[]`) or one whose every condition was skipped (for example only an unknown key) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — the driver is not called, the table is never wiped silently.
352
+ - A mutation filter passes `filterable` and `limits` like a read's (`forbidden-key`, `forbidden-operator`, `limit-exceeded`) — the server's own filters too.
353
+ - `rows` are **not** atomic: `updateWhere` reads them right **after** the update with the same filter (if the patch changes a filtered field, other documents may show up); `deleteWhere` reads them just **before** the delete. `count` is `matchedCount` / `deletedCount`.
259
354
 
260
355
  ## Transactions
261
356
 
357
+ > ⚠️ Only **database work** inside the callback. External effects (HTTP calls, messages, email) go through an outbox: the driver may retry the transaction, and an external effect would run again and cannot be rolled back.
358
+
262
359
  ```ts
263
360
  await registry.transaction(async () => {
264
361
  await dispatchesRepository.create({ ... });
@@ -280,6 +377,57 @@ A scope can also be given as `registry.repository(Model, { scope })`. An unknown
280
377
  scope key **throws** when the repository is built — dropping it silently would
281
378
  remove the RBAC filter.
282
379
 
380
+ A scope value may also be a list (task 014): `{ shopId: { in: [1, 2] } }` limits every read and write to those values; an empty list (`{ in: [] }`) matches no row (not an error). `create` fills in the equality scopes only.
381
+
382
+ ```ts
383
+ const mine = repository.scoped({ shopId: { in: user.shopIds } });
384
+ ```
385
+
386
+ ## Values, NULL order and parity
387
+
388
+ All three adapters (drizzle-pg, prisma-pg, mongoose) return the same result (task 021):
389
+
390
+ - **Literal value operators.** `contains`, `startsWith` and `endsWith` match their value literally: `startsWith "%"` finds only rows starting with `%`, `contains "_"` a real `_`. Wildcards belong to `like` / `ilike` / `notLike` only.
391
+ - **Type casting.** Every value is cast to its field type (number, boolean, date, ObjectId, enum). A mismatch (`age = "abc"`) is `invalid-value`: the condition is dropped and `onSkippedCondition` is called, a `QueryKitError` under `strict`; the driver is not called.
392
+ - **`not`** follows SQL three-valued logic: `not(and(…))` / `not(or(…))` over a NULL field agree on every adapter.
393
+ - **NULL order.** NULL first on `asc`, last on `desc` (MongoDB's order).
394
+ - **A malformed sort item** (`null`, `{}`) is dropped and reported as `invalid-value` (`site: "sort"`), an error under `strict`.
395
+
396
+ ## Migrating to 3.0
397
+
398
+ In 3.0 a repository without a policy is **narrow**: `=` and `in` on `allowedColumns` / `forcedColumns` / `_id` only, sorting by `_id` and `createdAt`. To bring back the 2.x behaviour for one repository on purpose:
399
+
400
+ ```ts
401
+ const open = registry.repository(User, {
402
+ filterable: { _id: "*", name: "*", email: "*", status: "*", createdAt: "*" },
403
+ sortable: ["_id", "name", "email", "status", "createdAt"],
404
+ });
405
+ ```
406
+
407
+ A raw Mongo filter now needs `raw()` (R19):
408
+
409
+ ```ts
410
+ import { raw } from "@querykitjs/core";
411
+
412
+ // 2.x — forbidden-key in 3.0:
413
+ // await usersRepository.findAll({ filter: { age: { $gt: 18 } } });
414
+
415
+ // 3.0:
416
+ await usersRepository.findAll({ filter: raw({ age: { $gt: 18 } }) });
417
+ ```
418
+
419
+ - New reasons: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; new site: `page` — return them as 400.
420
+ - Pages deeper than offset 10 000 are refused — use `findCursor`, or raise `limits: { maxOffset }`.
421
+ - Every read runs with `maxTimeMS: 2000`; raise `maxTimeMS` on the repository for long report queries. A timeout throws `QueryKitTimeoutError`.
422
+ - A LIKE pattern with more than 2 wildcards is refused (`maxLikeWildcards`).
423
+ - Cursor tokens are v2 now (task 012): an old (v1) `next_cursor` / `prev_cursor` is refused with `invalid-value` — after the upgrade start paging from the first page again.
424
+ - `updateWhere` / `deleteWhere` return `{ count }` instead of `Row[]` (task 013); for rows pass `{ returning: { limit } }` → `{ count, rows }`.
425
+ - A filterless `updateWhere` / `deleteWhere` is `empty-filter`; write `{ all: true }` for every row. A mutation filter now passes the `filterable` policy.
426
+ - `OffsetMeta.total_items` / `total_pages` are typed `number | null` (`null` with `withTotal: false`).
427
+ - `with` now needs the `relations` allow-list (task 014): on a repository without it `with` is `forbidden-key`, `site: "with"`.
428
+ - `relations: { author: User }` → `relations: { author: { model: User, columns: ["name"] } }` (the shorthand no longer allows `with`).
429
+ - A scope value may be `{ in: [...] }`; the registry option `timestamps` names the createdAt / deletedAt fields.
430
+
283
431
  ## Wire (JSON) values and migrating from DbService
284
432
 
285
433
  A request body is JSON, so a date always arrives as a **string**. The adapter
@@ -294,11 +442,9 @@ casts it according to the schema path:
294
442
  and leaves the rest to the database.
295
443
  - `in`/`notIn` arrays, `between`/`notBetween` tuples and cursor tokens are covered
296
444
  too (`cursorKey: "createdAt"` paginates correctly).
297
- - ⚠️ **Documented divergence:** Mongo cannot `$regex` a `Date` path
298
- (`Can't use $options with Date`), so a text-pattern operator
299
- (`contains`/`ilike`/…) on a date field is **skipped**. drizzle-pg _can_ match it
300
- (Postgres renders the timestamp as text). Neither adapter crashes, and the
301
- difference shows up in `onSkippedCondition`.
445
+ - Mongo cannot `$regex` a `Date` path (`Can't use $options with Date`), so a
446
+ text-pattern operator (`contains`/`ilike`/…) on a date field is **skipped** as
447
+ `invalid-value` and reported in `onSkippedCondition` — the SQL adapters do the same.
302
448
  - An unparseable value (`"not-a-date"`) drops the condition (or 400s under
303
449
  `strict`). **One** bad element in an `in` list drops the whole condition.
304
450
 
@@ -327,22 +473,22 @@ await ordersRepository.aggregate({ sum: "amount", groupBy: "region" });
327
473
 
328
474
  ## API reference
329
475
 
330
- | Method | Returns |
331
- | -------------------------------------------------------------- | ---------------------------- |
332
- | `findAll(params?)` | `Row[]` |
333
- | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
334
- | `findList(params?)` | `{ data, meta }` offset |
335
- | `findInfinite(params?)` | `{ data, meta }` infinite |
336
- | `findCursor(params?)` | `{ data, meta }` cursor |
337
- | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
338
- | `aggregate(spec)` | `AggregateRow[]` |
339
- | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
340
- | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
341
- | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch)` | `Row \| undefined` / `Row[]` |
342
- | `deleteById(id, idKey?)` / `deleteWhere(filter)` | `Row \| undefined` / `Row[]` |
343
- | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
344
- | `scoped(scope)` | scoped `Repository` |
345
- | `registry.transaction(fn)` | result of `fn` |
476
+ | Method | Returns |
477
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------- |
478
+ | `findAll(params?)` | `Row[]` |
479
+ | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
480
+ | `findList(params?)` | `{ data, meta }` offset |
481
+ | `findInfinite(params?)` | `{ data, meta }` infinite |
482
+ | `findCursor(params?)` | `{ data, meta }` cursor |
483
+ | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
484
+ | `aggregate(spec)` | `AggregateRow[]` |
485
+ | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
486
+ | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
487
+ | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
488
+ | `deleteById(id, idKey?)` / `deleteWhere(filter, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
489
+ | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
490
+ | `scoped(scope)` | scoped `Repository` |
491
+ | `registry.transaction(fn)` | result of `fn` |
346
492
 
347
493
  ## Development
348
494
 
package/README.md CHANGED
@@ -69,6 +69,15 @@ bun add @querykitjs/mongoose mongoose
69
69
 
70
70
  `mongoose` (>= 8) — peer dependency.
71
71
 
72
+ ### Talablar
73
+
74
+ | | Versiya | Tekshiriladi (`bun run test:matrix`) |
75
+ | ----------------- | ------------------ | ------------------------------------ |
76
+ | Node.js | `>=22.12` | 24 |
77
+ | `mongoose` (peer) | `^8.24 \|\| ^9.11` | 8.24.7, 9.11.4 |
78
+
79
+ Node 20 endi qo'llab-quvvatlanmaydi (`engines`: `>=22.12`). Peer diapazoni — oxirgi ikki major; ikkala cheti testda.
80
+
72
81
  ## Sozlash
73
82
 
74
83
  ### 1. Registry (bir marta)
@@ -99,6 +108,12 @@ export const registry = createRegistry(mongoose.connection);
99
108
  `maxPerPage`/`maxLimit` — **ikkinchi himoya qatlami**: validatsiya
100
109
  (`@querykitjs/zod` factory'lari) chetlab o'tilsa ham repository o'zi clamp qiladi.
101
110
 
111
+ `timestamps` — createdAt / deletedAt maydon nomlari (masalan snake_case loyihada): default sort va default `sortable` shu `createdAt`ni, soft-delete (`softDelete`, `restore`, `withDeleted`) shu `deletedAt`ni ishlatadi.
112
+
113
+ ```ts
114
+ export const registry = createRegistry(mongoose.connection, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
115
+ ```
116
+
102
117
  ### 2. Har model uchun repository
103
118
 
104
119
  ```ts
@@ -199,6 +214,41 @@ try {
199
214
  - **Cursor** default `_id` bo'yicha (`cursorKey` bilan o'zgartiring) — insert'larда barqaror.
200
215
  - **Tranzaksiya replica set talab qiladi** — lokal dev uchun bir-nodали yetarli (`mongod --replSet rs0` so'ng `rs.initiate()`, yoki testда [`mongodb-memory-server`](https://github.com/typegoose/mongodb-memory-server)).
201
216
 
217
+ - **Indekslar va operatorlar.** `startsWith` — `^` bilan bog'langan, lekin registrga sezgirsiz (`i`) regex: MongoDB uni indeks bilan yomon ishlatadi. Katta kolleksiyada kichik harfli soya maydon (`nameLower`) saqlang va unga `=` / `startsWith` bering. `contains`, `endsWith`, `!=`, `notIn` indeksdan foydalana olmaydi (COLLSCAN) — default `filterable`da yopiq, faqat kichik kolleksiyalar uchun oching. Enum maydonlarida `in` ishlating.
218
+
219
+ ## Safe by default — `filterable`, `sortable`, `limits`
220
+
221
+ Mijoz faqat server ruxsat bergan path bo'yicha, faqat ruxsat bergan operator bilan filtrlaydi va saralaydi. Ruxsatsiz so'rov MongoDB'ga yetmasdan `QueryKitError` bilan to'xtaydi — `strict` va `onSkippedCondition`dan qat'i nazar. Opsiya nomlari, default'lar, `reason`/`site` — drizzle-pg va prisma-pg bilan bir xil (`id` ≡ `_id`).
222
+
223
+ ```ts
224
+ const usersRepository = registry.repository(User, {
225
+ forcedColumns: { _id: true, name: true, status: true, createdAt: true },
226
+ filterable: {
227
+ status: ["=", "in"],
228
+ createdAt: ["between", ">=", "<="],
229
+ name: ["startsWith"], // faqat shu yerda ochilgan
230
+ },
231
+ sortable: ["_id", "createdAt", "name"],
232
+ limits: { maxInItems: 500 },
233
+ maxTimeMS: 1000,
234
+ });
235
+ ```
236
+
237
+ | Opsiya | Berilmasa (default) | Rad etilsa |
238
+ | ------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
239
+ | `filterable` | `allowedColumns` (yo'q bo'lsa `forcedColumns` kalitlari, u ham yo'q bo'lsa faqat primary key), faqat `"="` va `"in"` | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
240
+ | `sortable` | primary key va `createdAt` (jadvalda bo'lsa); mijoz yuborgan `cursorKey` ham shu ro'yxatdan | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
241
+ | `limits` | core `DEFAULT_QUERY_LIMITS` (chuqurlik 5, shartlar 30, massiv 200, satr 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
242
+
243
+ - `aggregate`: `filter` va har `groupBy`/`sum`/`avg`/`min`/`max` ustuni `filterable` ichida bo'lishi shart (`site: "aggregate"`).
244
+ - `findById`: faqat mijoz `filter`i tekshiriladi; `idKey` — server tanlovi.
245
+ - Jadvalda **yo'q** kalit (`nope`) avvalgidek `unknown-key` — skip yoki `strict`da xato.
246
+ - Mutatsiya metodlari (`updateMany`, `deleteMany`, …) filtri server kodi deb hisoblanadi va tekshirilmaydi.
247
+ - Operator aliaslari bitta: `["="]` `eq`ni ham, `["contains"]` `%_%`ni ham ochadi. `"*"` — barcha operatorlar.
248
+ - Brendsiz xom Mongo filtri (`{ $where: … }`, `{ secret: { $regex: … } }`, `{ $expr: … }`) JSON'dan kelishi mumkin — `forbidden-key`, so'rov yuborilmaydi (R19). Server kodi `raw({...})` bilan belgilaydi; ⚠️ `raw()` `filterable` bilan tekshirilmaydi.
249
+ - LIKE naqshi: `maxLikeWildcards` (2) dan ko'p `%`/`_` → `limit-exceeded`; ketma-ket `%%` bitta hisoblanadi.
250
+ - Har o'qish (`find`, `findOne`, `countDocuments`, `aggregate`) `maxTimeMS` (default 2 000) bilan; oshsa `QueryKitTimeoutError` (`code: "QUERYKIT_TIMEOUT"`) — uni 503/504 qiling, 400 emas.
251
+
202
252
  ## Ilg'or filterlar
203
253
 
204
254
  Filter — maydon shartlari va mantiqiy guruhlar (`and`/`or`/`not`) daraxti. Yassi massiv = implicit `AND`. `id` — `_id`ga o'giriladi.
@@ -232,6 +282,8 @@ Sort berilmasa `createdAt DESC` ga, u bo'lmasa `_id DESC` ga tushadi — paginat
232
282
  ```ts
233
283
  // 1. Offset — total_items / total_pages
234
284
  const page = await usersRepository.findList({ page: 2, perPage: 20, filter, sort });
285
+ // withTotal: false — count so'ralmaydi (katta jadval): perPage + 1 qator, total_items / total_pages = null
286
+ const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
235
287
 
236
288
  // 2. Infinite scroll (limit + offset) — has_more / next_offset
237
289
  const feed = await usersRepository.findInfinite({ limit: 20, offset: 40 });
@@ -242,8 +294,32 @@ const p2 = await usersRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
242
294
  const back = await usersRepository.findCursor({ cursor: p2.meta.prev_cursor, direction: "backward" });
243
295
  ```
244
296
 
297
+ ### Cursor: teng qiymatlar va imzo
298
+
299
+ - Seek kaliti `(cursorKey, _id)`: `cursorKey` unikal bo'lmasa ham (masalan bir xil `createdAt`) hech bir qator tushib qolmaydi va takrorlanmaydi. `cursorKey` primary key'ning o'zi bo'lsa — bitta maydonli seek.
300
+ - Token (v2) ichida faqat skalyar qiymat (satr, son, boolean, ISO sana) bo'ladi va maydon tipiga cast qilinadi; obyekt, massiv yoki mos kelmaydigan qiymat → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (drayver chaqirilmaydi).
301
+ - Registry opsiyasi `cursorSecret` token'ni HMAC-SHA256 bilan imzolaydi; imzosiz yoki o'zgartirilgan token → `invalid-value`. Usiz token imzosiz — mijoz uni o'zi yasay oladi (faqat ruxsat etilgan `sortable` kalit va filter doirasida).
302
+
303
+ ```ts
304
+ export const registry = createRegistry(mongoose.connection, { cursorSecret: process.env.CURSOR_SECRET });
305
+ ```
306
+
245
307
  ## Tipli relation'lar va maydon tanlash
246
308
 
309
+ Mijoz `with` bilan faqat `relations`da yozilgan relation'larni ochadi (task 014, R21):
310
+
311
+ ```ts
312
+ const postsRepository = registry.repository(Post, {
313
+ relations: { author: { model: User, columns: ["name"] } }, // model — faqat tip uchun
314
+ });
315
+ await postsRepository.findAll({ with: { author: true } }); // author faqat name bilan
316
+ ```
317
+
318
+ - `columns` majburiy — ro'yxat yoki aniq `"*"`. `with: { rel: true }` ham faqat shu ustunlarni tanlaydi.
319
+ - Ro'yxatda yo'q relation, ustun, filtr maydoni yoki ichki relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` — `relation` yoki `relation.field`; drayver chaqirilmaydi.
320
+ - Ruxsat etilgan opsiyalar: `select`, ichki `populate` (`nested` bilan), `match` (maydonlari `filterable`da; `$where` / `$expr` yo'q), `options.limit` / `options.skip`.
321
+ - Qisqa yozuv `{ author: User }` faqat tip uchun: `with: { author: … }` → `forbidden-key`.
322
+
247
323
  O'qish metodlari natija tipini `with` (populate) va `columns`dan chiqaradi — qo'lda generic yo'q:
248
324
 
249
325
  ```ts
@@ -255,10 +331,31 @@ rows[0].id; // ObjectId
255
331
  rows[0].email; // ❌ tip xatosi — tanlanmagan
256
332
  ```
257
333
 
258
- Populate qilingan relation'larni runtime'да tiplash uchun ref model'larni bering: `registry.repository(Post, { relations: { author: User } })`.
334
+ Populate qilingan relation'larni tiplash va ochish uchun ref model'ni rule ichida bering: `registry.repository(Post, { relations: { author: { model: User, columns: ["name"] } } })` (qisqa `{ author: User }` faqat tip — `with`ga ruxsat bermaydi).
335
+
336
+ ## Ommaviy yangilash va o'chirish
337
+
338
+ `updateWhere` / `deleteWhere` — bitta yozish so'rovi (oldin o'qish yo'q), natija `{ count }`:
339
+
340
+ ```ts
341
+ const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
342
+ await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
343
+
344
+ // returning: { limit } — `{ count, rows }`, ko'pi bilan limit ta qator (maxLimit, default 200)
345
+ const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
346
+
347
+ // filtrsiz — faqat aniq ko'rsatilsa (scope baribir qo'llanadi)
348
+ await usersRepository.deleteWhere({ all: true });
349
+ ```
350
+
351
+ - Bo'sh filtr (`undefined`, `{}`, `[]`) yoki hamma sharti tashlab yuborilgan filtr (masalan faqat noma'lum kalit) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — drayver chaqirilmaydi, jadval jim o'chmaydi.
352
+ - Mutatsiya filtri o'qishdagidek `filterable` va `limits`dan o'tadi (`forbidden-key`, `forbidden-operator`, `limit-exceeded`). Server'ning o'z filtri ham.
353
+ - `rows` atomar **emas**: `updateWhere` — yangilashdan **keyin** shu filtr bilan o'qiladi (patch filtrdagi maydonni o'zgartirsa, boshqa hujjatlar chiqishi mumkin); `deleteWhere` — o'chirishdan **oldin** o'qiladi. `count` — `matchedCount` / `deletedCount`.
259
354
 
260
355
  ## Tranzaksiya
261
356
 
357
+ > ⚠️ Callback ichida **faqat DB amallari**. Tashqi amallar (HTTP, xabar, email) — outbox orqali: drayver tranzaksiyani qayta urinishi mumkin, tashqi amal esa takrorlanadi va qaytarib bo'lmaydi.
358
+
262
359
  ```ts
263
360
  await registry.transaction(async () => {
264
361
  await dispatchesRepository.create({ ... });
@@ -280,6 +377,57 @@ Scope'ni `registry.repository(Model, { scope })` bilan ham berish mumkin.
280
377
  Noma'lum scope kaliti — repository yaratilishida **xato** (jimgina tashlansa
281
378
  RBAC filtri yo'qolib ketardi).
282
379
 
380
+ Scope qiymati ro'yxat ham bo'lishi mumkin (task 014): `{ shopId: { in: [1, 2] } }` — har o'qish va yozish shu qiymatlar bilan cheklanadi; bo'sh ro'yxat (`{ in: [] }`) — hech qanday qator (xato emas). `create` faqat tenglik scope'larini to'ldiradi.
381
+
382
+ ```ts
383
+ const mine = repository.scoped({ shopId: { in: user.shopIds } });
384
+ ```
385
+
386
+ ## Qiymatlar, NULL tartibi va bir xillik
387
+
388
+ Uchala adapter (drizzle-pg, prisma-pg, mongoose) bir xil natija beradi (task 021):
389
+
390
+ - **Literal qiymat operatorlari.** `contains`, `startsWith`, `endsWith` qiymatni harfma-harf qidiradi: `startsWith "%"` faqat `%` bilan boshlanuvchi qatorlar, `contains "_"` — haqiqiy `_`. Wildcard faqat `like` / `ilike` / `notLike` da.
391
+ - **Tip cast'i.** Har qiymat maydon tipiga keltiriladi (son, boolean, sana, ObjectId, enum). Mos kelmasa (`age = "abc"`) — `invalid-value`: shart tashlanadi va `onSkippedCondition` chaqiriladi, `strict` da `QueryKitError`; drayver chaqirilmaydi.
392
+ - **`not`** — SQL uch qiymatli mantiq: NULL maydon bo'yicha `not(and(…))` / `not(or(…))` hamma adapterda bir xil.
393
+ - **NULL tartibi.** `asc` — NULL birinchi, `desc` — NULL oxirida (MongoDB tartibi).
394
+ - **Buzuq sort elementi** (`null`, `{}`) — tashlanadi va `invalid-value` (`site: "sort"`) bilan xabar qilinadi, `strict` da xato.
395
+
396
+ ## Migrating to 3.0
397
+
398
+ 3.0 da siyosatsiz repository **tor**: faqat `allowedColumns` / `forcedColumns` / `_id` bo'yicha `=` va `in`, sort — `_id` va `createdAt`. 2.x xulqini bitta repository uchun ataylab qaytarish:
399
+
400
+ ```ts
401
+ const open = registry.repository(User, {
402
+ filterable: { _id: "*", name: "*", email: "*", status: "*", createdAt: "*" },
403
+ sortable: ["_id", "name", "email", "status", "createdAt"],
404
+ });
405
+ ```
406
+
407
+ Xom Mongo filtri endi `raw()` bilan (R19):
408
+
409
+ ```ts
410
+ import { raw } from "@querykitjs/core";
411
+
412
+ // 2.x — 3.0 da forbidden-key:
413
+ // await usersRepository.findAll({ filter: { age: { $gt: 18 } } });
414
+
415
+ // 3.0:
416
+ await usersRepository.findAll({ filter: raw({ age: { $gt: 18 } }) });
417
+ ```
418
+
419
+ - Yangi `reason`lar: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; yangi `site`: `page` — 400 qiling.
420
+ - Offset 10 000 dan chuqur sahifalar rad etiladi — `findCursor` ishlating yoki `limits: { maxOffset }` bilan oshiring.
421
+ - Har o'qish `maxTimeMS: 2000` bilan; uzoqroq hisobot so'rovlari uchun repository'da `maxTimeMS`ni oshiring. Vaqt tugasa `QueryKitTimeoutError`.
422
+ - LIKE naqshida 2 tadan ko'p wildcard rad etiladi (`maxLikeWildcards`).
423
+ - Cursor token formati v2 (task 012): eski (v1) `next_cursor` / `prev_cursor` endi `invalid-value` bilan rad etiladi — yangilashdan keyin paginatsiyani birinchi sahifadan boshlang.
424
+ - `updateWhere` / `deleteWhere` endi `Row[]` emas, `{ count }` qaytaradi (task 013); qatorlar kerak bo'lsa `{ returning: { limit } }` → `{ count, rows }`.
425
+ - Filtrsiz `updateWhere` / `deleteWhere` → `empty-filter`; hamma qatorlar uchun `{ all: true }` yozing. Mutatsiya filtri endi `filterable` siyosatidan o'tadi.
426
+ - `OffsetMeta.total_items` / `total_pages` tipi `number | null` (`withTotal: false` bilan `null`).
427
+ - `with` endi faqat `relations` allow-list bilan ishlaydi (task 014): ro'yxatsiz repository'da `with` → `forbidden-key`, `site: "with"`.
428
+ - `relations: { author: User }` → `relations: { author: { model: User, columns: ["name"] } }` (qisqa yozuv endi `with`ga ruxsat bermaydi).
429
+ - Scope qiymati `{ in: [...] }` bo'lishi mumkin; `timestamps` registry opsiyasi createdAt / deletedAt nomlarini beradi.
430
+
283
431
  ## Wire (JSON) qiymatlari va DbService'dan migratsiya
284
432
 
285
433
  Backend so'rov body'sini JSON'da oladi, ya'ni sana har doim **string** bo'lib
@@ -294,11 +442,10 @@ keladi. Adapter uni schema path tipiga qarab avtomatik cast qiladi:
294
442
  hukm qiladi, qolganini bazaga qoldiradi.
295
443
  - `in`/`notIn` massivlari, `between`/`notBetween` tuple'lari va cursor token ham
296
444
  qamraladi (`cursorKey: "createdAt"` ishlaydi).
297
- - ⚠️ **Hujjatlashtirilgan farq:** Mongo `Date` path'da `$regex` qila olmaydi
298
- (`Can't use $options with Date`), shuning uchun date maydonidagi text-pattern
299
- operatori (`contains`/`ilike`/…) **tashlab yuboriladi**. drizzle-pg esa buni
300
- bajaradi (Postgres timestamp'ni text sifatida render qiladi). Ikkalasi ham
301
- crash qilmaydi; farq `onSkippedCondition`da ko'rinadi.
445
+ - Mongo `Date` path'da `$regex` qila olmaydi (`Can't use $options with Date`),
446
+ shuning uchun date maydonidagi text-pattern operatori (`contains`/`ilike`/…)
447
+ `invalid-value` sifatida **tashlab yuboriladi** va `onSkippedCondition`da ko'rinadi —
448
+ SQL adapterlar ham shunday qiladi.
302
449
  - Parse bo'lmaydigan qiymat (`"not-a-date"`) shartni bekor qiladi (`strict`da —
303
450
  400). `in` ro'yxatidagi **bitta** yaroqsiz element butun shartni bekor qiladi.
304
451
 
@@ -327,22 +474,22 @@ await ordersRepository.aggregate({ sum: "amount", groupBy: "region" });
327
474
 
328
475
  ## API ma'lumotnoma
329
476
 
330
- | Metod | Qaytaradi |
331
- | -------------------------------------------------------------- | ---------------------------- |
332
- | `findAll(params?)` | `Row[]` |
333
- | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
334
- | `findList(params?)` | `{ data, meta }` offset |
335
- | `findInfinite(params?)` | `{ data, meta }` infinite |
336
- | `findCursor(params?)` | `{ data, meta }` cursor |
337
- | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
338
- | `aggregate(spec)` | `AggregateRow[]` |
339
- | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
340
- | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
341
- | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch)` | `Row \| undefined` / `Row[]` |
342
- | `deleteById(id, idKey?)` / `deleteWhere(filter)` | `Row \| undefined` / `Row[]` |
343
- | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
344
- | `scoped(scope)` | scoped `Repository` |
345
- | `registry.transaction(fn)` | `fn` natijasi |
477
+ | Metod | Qaytaradi |
478
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------- |
479
+ | `findAll(params?)` | `Row[]` |
480
+ | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
481
+ | `findList(params?)` | `{ data, meta }` offset |
482
+ | `findInfinite(params?)` | `{ data, meta }` infinite |
483
+ | `findCursor(params?)` | `{ data, meta }` cursor |
484
+ | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
485
+ | `aggregate(spec)` | `AggregateRow[]` |
486
+ | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
487
+ | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
488
+ | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
489
+ | `deleteById(id, idKey?)` / `deleteWhere(filter, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
490
+ | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
491
+ | `scoped(scope)` | scoped `Repository` |
492
+ | `registry.transaction(fn)` | `fn` natijasi |
346
493
 
347
494
  ## Ishlab chiqish
348
495