@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 +168 -22
- package/README.md +169 -22
- package/dist/index.cjs +1 -610
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -55
- package/dist/index.d.ts +120 -55
- package/dist/index.js +1 -603
- package/dist/index.js.map +1 -1
- package/package.json +18 -9
- package/skills/querykit-mongoose/SKILL.md +189 -0
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
|
|
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
|
-
-
|
|
298
|
-
(`
|
|
299
|
-
|
|
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
|
|
331
|
-
|
|
|
332
|
-
| `findAll(params?)`
|
|
333
|
-
| `findOne(params?)` / `findById(id, params?)`
|
|
334
|
-
| `findList(params?)`
|
|
335
|
-
| `findInfinite(params?)`
|
|
336
|
-
| `findCursor(params?)`
|
|
337
|
-
| `count(filter?)` / `exists(filter?)`
|
|
338
|
-
| `aggregate(spec)`
|
|
339
|
-
| `create(values)` / `createMany(values)`
|
|
340
|
-
| `upsert(values, opts)` / `upsertMany(values, opts)`
|
|
341
|
-
| `updateById(id, patch, idKey?)` / `updateWhere(filter, patch)` | `Row \| undefined` / `
|
|
342
|
-
| `deleteById(id, idKey?)` / `deleteWhere(filter)` | `Row \| undefined` / `
|
|
343
|
-
| `softDelete(id)` / `restore(id)`
|
|
344
|
-
| `scoped(scope)`
|
|
345
|
-
| `registry.transaction(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
|
|
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
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
331
|
-
|
|
|
332
|
-
| `findAll(params?)`
|
|
333
|
-
| `findOne(params?)` / `findById(id, params?)`
|
|
334
|
-
| `findList(params?)`
|
|
335
|
-
| `findInfinite(params?)`
|
|
336
|
-
| `findCursor(params?)`
|
|
337
|
-
| `count(filter?)` / `exists(filter?)`
|
|
338
|
-
| `aggregate(spec)`
|
|
339
|
-
| `create(values)` / `createMany(values)`
|
|
340
|
-
| `upsert(values, opts)` / `upsertMany(values, opts)`
|
|
341
|
-
| `updateById(id, patch, idKey?)` / `updateWhere(filter, patch)` | `Row \| undefined` / `
|
|
342
|
-
| `deleteById(id, idKey?)` / `deleteWhere(filter)` | `Row \| undefined` / `
|
|
343
|
-
| `softDelete(id)` / `restore(id)`
|
|
344
|
-
| `scoped(scope)`
|
|
345
|
-
| `registry.transaction(fn)`
|
|
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
|
|