@querykitjs/web 3.1.0 → 4.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
@@ -48,19 +48,31 @@ function UsersPage() {
48
48
  - **3 pagination modes** — offset (`list`), infinite (`infinite`), cursor/keyset (`cursor`); each has its own `parse*` to camelCase the meta.
49
49
  - **Adapter-aware `with`** — only **wire-safe** (JSON) per-relation options are suggested for `mongoose`/`drizzle-pg`/`drizzle-sqlite`/`prisma-pg`; function/SQL forms are never offered.
50
50
  - **Declarative schema** — `resource.schema` / `defineListSchema` maps URL params → filter; kills the per-page hand-building.
51
- - **React hooks** — `useListParams` (react-router-dom inside), `useListParamsBase` (router-agnostic). Framework-agnostic core — Vue can bind the same core.
51
+ - **React hooks** — `useListParams` (react-router inside), `useListParamsBase` (router-agnostic). Framework-agnostic core — Vue can bind the same core.
52
52
  - **SSR** — `resource.fromSearchParams(sp, {schema})` builds a payload straight from `URLSearchParams`, no hook.
53
53
  - **Query keys** — `resource.keys` gives stable keys for TanStack/SWR.
54
- - **Zero dependency** — no axios/react-query/dayjs. `react` (+ `react-router-dom` for the hook) are optional peers.
54
+ - **Zero dependency** — no axios/react-query/dayjs. `react` (+ `react-router` for the hook) are optional peers.
55
55
 
56
56
  ## Install
57
57
 
58
58
  ```bash
59
59
  bun add @querykitjs/web
60
- # React hook: react (peer). `useListParams` also peers react-router-dom.
61
- # The router-agnostic `useListParamsBase` needs no react-router-dom.
60
+ # React hook: react (peer). `useListParams` also peers react-router.
61
+ # The router-agnostic `useListParamsBase` needs no react-router.
62
62
  ```
63
63
 
64
+ ### Requirements
65
+
66
+ | | Version | Tested (`bun run test:matrix`) |
67
+ | ---------------------------------------------------- | -------------------- | ------------------------------ |
68
+ | Node.js | `>=22.12` | 24 |
69
+ | `react` (optional peer) | `^18.3 \|\| ^19.2.7` | 18.3.1, 19.3.1 |
70
+ | `react-router` (optional peer, `useListParams` only) | `^7.0 \|\| ^8.0` | 7.18.5, 8.5.1 |
71
+
72
+ Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested.
73
+
74
+ **`react-router-dom` → `react-router`.** `useListParams` now imports from `react-router` (v7 and v8 both export `useSearchParams`). A project that has `react-router-dom` installs `react-router` too — on v7 it is already a dependency of `react-router-dom`: `npm i react-router`.
75
+
64
76
  ## Two ways to use it
65
77
 
66
78
  ```ts
@@ -84,18 +96,20 @@ export const qk = createRegistry({
84
96
  sort: ["-createdAt"], // list + infinite ordering (leading "-" = desc)
85
97
  cursor: { order: "asc" }, // cursor-only key traversal order
86
98
  },
99
+ timestamps: { createdAt: "created_at" }, // same as the backend registry; the default sort is this field, desc
87
100
  pruneEmpty: true, // drop empty filter values (""/null/undefined/[]); keep 0/false
88
101
  });
89
102
  ```
90
103
 
91
- | Config | Meaning |
92
- | ----------------------- | ----------------------------------------------------------------------------------- |
93
- | `adapter` | Target backend — tailors `with` typing (intellisense only). |
94
- | `defaults.perPage` | Default `perPage` for `list`. |
95
- | `defaults.limit` | Default `limit` for `infinite` and `cursor`. |
96
- | `defaults.sort` | Default sort for `list`/`infinite` — `[{key,direction}]` or `["-field"]` shorthand. |
97
- | `defaults.cursor.order` | Default cursor traversal order (`"asc"`/`"desc"`). |
98
- | `pruneEmpty` | Auto-drop empty filter conditions built from form inputs (default `true`). |
104
+ | Config | Meaning |
105
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `adapter` | Target backend — tailors `with` typing (intellisense only). |
107
+ | `defaults.perPage` | Default `perPage` for `list`. |
108
+ | `defaults.limit` | Default `limit` for `infinite` and `cursor`. |
109
+ | `defaults.sort` | Default sort for `list`/`infinite` — `[{key,direction}]` or `["-field"]` shorthand. |
110
+ | `defaults.cursor.order` | Default cursor traversal order (`"asc"`/`"desc"`). |
111
+ | `timestamps.createdAt` | Same as the backend `timestamps.createdAt`; without `defaults.sort` the default sort is `[{ key: createdAt, direction: "desc" }]` (default `"createdAt"`). |
112
+ | `pruneEmpty` | Auto-drop empty filter conditions built from form inputs (default `true`). |
99
113
 
100
114
  Every default is overridable per call.
101
115
 
@@ -106,19 +120,19 @@ import type { IUser } from "@my/api-types";
106
120
  export const users = qk.resource<IUser>("users"); // "users" = cache-key namespace (required, unique)
107
121
  ```
108
122
 
109
- | Member | What it is |
110
- | ------------------------------------------------------------- | -------------------------------------------------------------------- |
111
- | `users.list(input)` | build an offset (page) payload |
112
- | `users.infinite(input)` | build an infinite (limit/offset) payload |
113
- | `users.cursor(input)` | build a cursor (keyset) payload |
114
- | `users.params(input)` | build a paginationless payload (filter/sort/columns/with) |
115
- | `users.f` | `createFilters<IUser>()` — field-typed builder |
116
- | `users.search(term, fields)` | OR-of-`contains` preset across `fields` |
117
- | `users.schema(desc)` | `defineListSchema` bound (URL ↔ filter) |
118
- | `users.fromSearchParams(sp, {schema})` | build a list payload straight from `URLSearchParams` (SSR / no hook) |
119
- | `users.keys` | stable query keys for TanStack/SWR |
120
- | `users.parseList / parseInfinite / parseCursor` | map response meta snake→camel |
121
- | `ListResult<T> / InfiniteResult<T> / CursorResult<T>` (types) | `parse*` return types |
123
+ | Member | What it is |
124
+ | ------------------------------------------------------------- | --------------------------------------------------------------------------- |
125
+ | `users.list(input)` | build an offset (page) payload |
126
+ | `users.infinite(input)` | build an infinite (limit/offset) payload |
127
+ | `users.cursor(input)` | build a cursor (keyset) payload |
128
+ | `users.params(input)` | build a paginationless payload (filter/sort/columns/with) |
129
+ | `users.f` | `createFilters<IUser>()` — field-typed builder |
130
+ | `users.search(term, fields, { operation? })` | OR preset across `fields` (`contains` by default, `startsWith`, `endsWith`) |
131
+ | `users.schema(desc)` | `defineListSchema` bound (URL ↔ filter) |
132
+ | `users.fromSearchParams(sp, {schema})` | build a list payload straight from `URLSearchParams` (SSR / no hook) |
133
+ | `users.keys` | stable query keys for TanStack/SWR |
134
+ | `users.parseList / parseInfinite / parseCursor` | map response meta snake→camel |
135
+ | `ListResult<T> / InfiniteResult<T> / CursorResult<T>` (types) | `parse*` return types |
122
136
 
123
137
  All builders inherit the registry's `defaults` + `pruneEmpty`; all types are driven by `IUser` and the `adapter`.
124
138
 
@@ -155,8 +169,13 @@ f.contains("age", "x"); // ✗ compile error — `contains` is string-only
155
169
  ```ts
156
170
  users.search("ali", ["buyerName", "email"]);
157
171
  // → or(contains("buyerName","ali"), contains("email","ali"))
172
+ users.search("ali", ["buyerName"], { operation: "startsWith" });
173
+ // → or(startsWith("buyerName","ali")) — can use an index
158
174
  ```
159
175
 
176
+ - `contains` is expensive: `%ali%` cannot use a B-tree index (a full scan on Postgres, a regex on MongoDB). On a large table prefer `startsWith` (`ali%`) or full-text search.
177
+ - Every field must be `filterable` on the server with that operator (`{ buyerName: ["%_%"] }` or `["%_"]`); otherwise the server answers 400 with `forbidden-key` / `forbidden-operator`.
178
+
160
179
  An untyped `f` is also exported: `import { f } from "@querykitjs/web"` (no field-type checks, quick use).
161
180
 
162
181
  ## Sort — always `[{ key, direction }]` (multi-field)
@@ -219,6 +238,15 @@ users.parseInfinite(res.data); // { data, meta: { hasMore, nextOffset, … } }
219
238
  users.parseCursor(res.data); // { data, meta: { nextCursor, hasNext, … } }
220
239
  ```
221
240
 
241
+ **`withTotal: false`** — when you do not need the total (an infinite-like list, a "next" button), the server skips the count query:
242
+
243
+ ```ts
244
+ const body = users.list({ page, perPage: 20, withTotal: false }); // the payload carries withTotal: false (absent when not given)
245
+ const { meta } = users.parseList(res.data); // meta.totalCount === null, meta.totalPages === null; hasNext still works
246
+ ```
247
+
248
+ `Meta.totalCount` / `Meta.totalPages` are typed `number | null`: `null` means no count ran; a field the server did not send at all is `0` as before. zod (`makeOffsetParamsSchema`) and class-validator (`makeOffsetParamsDto`) accept `withTotal`.
249
+
222
250
  `parse*` exists at two levels (one shared implementation — meta is entity-agnostic):
223
251
  `qk.parseList/...` (registry, `data: unknown[]`) when you don't have a `resource`; `users.parseList/...` (entity-typed).
224
252
 
@@ -276,7 +304,7 @@ Empty/**absent** params are pruned; `default` fills only when the param is **abs
276
304
  ## React hook
277
305
 
278
306
  ```tsx
279
- import { useListParams } from "@querykitjs/web/react"; // uses react-router-dom internally
307
+ import { useListParams } from "@querykitjs/web/react"; // uses react-router internally
280
308
  import { qk } from "@/lib/query";
281
309
 
282
310
  const users = qk.resource<IUser>("users");
@@ -299,9 +327,11 @@ function UsersListPage() {
299
327
  }
300
328
  ```
301
329
 
330
+ The hook also returns `clamped: boolean` — the URL's `page` or `sort` was past a limit and was cut (see «Limits» below). To fix the URL: `if (clamped) setPage(params.page)`.
331
+
302
332
  An input/sort/page change mutates the URL → `params` changes → `queryKey` changes → TanStack refetches (and caches each combo).
303
333
 
304
- **Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router-dom):
334
+ **Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router):
305
335
 
306
336
  ```tsx
307
337
  import { useListParamsBase } from "@querykitjs/web/react";
@@ -465,6 +495,59 @@ createRegistry({ adapter: "drizzle-pg", defaults: { maxPerPage: 50, maxLimit: 25
465
495
  createQuery({ maxPerPage: Infinity });
466
496
  ```
467
497
 
498
+ ## Limits
499
+
500
+ `perPage`/`limit` are clamped (above), but **page depth** and the **number of sort fields** are not — the builder throws an `Error`, because silently cutting them would change what the request means (another page, another order). The values come from `@querykitjs/core`, the same as in zod and class-validator.
501
+
502
+ | Limit | Default | What counts | Past it |
503
+ | --------------- | -------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
504
+ | `maxOffset` | `10 000` | `list`: `(page - 1) × perPage`; `infinite`: `offset` | `Error: querykit: page 502 × perPage 20 exceeds maxOffset (10000); …` |
505
+ | `maxSortFields` | `3` | `sort` fields after normalization | `Error: querykit: sort with 4 fields exceeds maxSortFields (3)` |
506
+
507
+ ```ts
508
+ buildListParams({ page: 501, perPage: 20 }); // ✅ offset 10 000
509
+ buildListParams({ page: 502, perPage: 20 }); // ❌ Error — use buildCursorParams for deeper pages
510
+
511
+ createQuery({ maxOffset: 50_000, maxSortFields: 4 });
512
+ createRegistry({ adapter: "drizzle-pg", defaults: { maxOffset: 50_000 } });
513
+ ```
514
+
515
+ - Filter limits (depth, conditions, `in` items, string length) are **not** checked in web — the server (zod / class-validator) rejects them with a 400.
516
+ - **The URL readers clamp.** The URL is in the user's hands, so `readListParams`, `searchParamsToPayload`, `resource.fromSearchParams` and the hooks (`useListParams`, `useListParamsBase`) never throw: `page` becomes the last allowed page (`floor(maxOffset / perPage) + 1`), `sort` keeps its first `maxSortFields` fields, and the result has `clamped: true`. `?page=9999&size=20` → `page: 501`, `clamped: true`; `?sortType=a,b,c,d` → 3 fields, `clamped: true`. Through a registry, the registry's `defaults` limits apply.
517
+
518
+ ```ts
519
+ const read = readListParams(schema, new URLSearchParams("page=9999&size=20")); // { page: 501, perPage: 20, clamped: true, … }
520
+ const body = users.fromSearchParams(sp, { schema }); // body.clamped === true
521
+ JSON.stringify(body); // no clamped — it is non-enumerable on the payload and never reaches the server
522
+ const { params, clamped } = useListParams(users, { schema }); // no error during render
523
+ ```
524
+
525
+ Builders the developer calls directly (`buildListParams`, `resource.list`, …) still throw an `Error`.
526
+
527
+ ## Migrating (limits)
528
+
529
+ - `buildListParams` / `buildInfiniteParams` / `createQuery` / registry `list`/`infinite` now throw for an offset deeper than 10 000 or more than 3 sort fields (they used to build the payload and the server accepted it). Use a cursor for deep pages; raise `maxOffset` / `maxSortFields` where you must (and raise the same limit on the server).
530
+
531
+ - `Meta.totalCount` / `Meta.totalPages` are now `number | null` (`null` in a `withTotal: false` response). Code like `meta.totalCount + 1` needs a `null` check in TS; in an app that never sends `withTotal` the value is a number as before.
532
+ - A deep `page` read from the URL is now clamped (`clamped: true`), not an error — no error boundary or manual check needed.
533
+
534
+ ## Errors — `isQueryKitError`
535
+
536
+ When the server rejects a request (`forbidden-key`, `forbidden-operator`, `limit-exceeded`, `empty-filter`, …), the backend may return `QueryKitError.info` in its 400 response. The envelope is the project's own; web only types the `info` shape:
537
+
538
+ ```ts
539
+ import { isQueryKitError, type QueryKitErrorBody } from "@querykitjs/web";
540
+
541
+ // backend: catch (e) { if (e instanceof QueryKitError) return res.status(400).json({ error: e.info }); }
542
+ const body = error.response?.data?.error;
543
+ if (isQueryKitError(body)) {
544
+ // body: { reason, site, key, source?, operation? }
545
+ if (body.reason === "forbidden-key" && body.site === "filter") showFieldError(body.key);
546
+ }
547
+ ```
548
+
549
+ `isQueryKitError` checks `reason` and `site` against the core lists and `key` is a string; any other object (e.g. `{ statusCode: 400, message }`) → `false`.
550
+
468
551
  ## You send the request yourself
469
552
 
470
553
  The library works only **up to the request**. No transport:
@@ -486,7 +569,7 @@ const { data, meta } = users.parseList(await res.json());
486
569
  | `registry.resource<T>(name)` | entity-typed builders/filters/schema/keys/parsers |
487
570
  | `resource.list/infinite/cursor/params(input)` | build a payload (offset/infinite/cursor/paginationless) |
488
571
  | `resource.f` / `createFilters<T>()` / `f` | typed filter builder / typed / untyped |
489
- | `resource.search(term, fields)` | OR-of-contains preset |
572
+ | `resource.search(term, fields, { operation? })` | OR preset (`contains` / `startsWith` / `endsWith`) |
490
573
  | `resource.schema(desc)` / `defineListSchema(desc)` | URL param → filter descriptor |
491
574
  | `resource.fromSearchParams(sp, {schema})` | searchParams → list payload (SSR) |
492
575
  | `resource.keys` | TanStack/SWR query keys |
@@ -495,11 +578,12 @@ const { data, meta } = users.parseList(await res.json());
495
578
  | `buildParams / buildListParams / buildInfiniteParams / buildCursorParams` | standalone builders |
496
579
  | `createQuery(config)` | builder without a registry (defaults/pruneEmpty) |
497
580
  | `schemaToFilter(schema, sp)` | searchParams → `FieldCondition[]` |
498
- | `searchParamsToPayload(schema, sp)` | searchParams → full payload |
499
- | `readListParams(schema, sp)` | searchParams → `ListParams` |
581
+ | `searchParamsToPayload(schema, sp)` | searchParams → full payload (clamped, `clamped`) |
582
+ | `readListParams(schema, sp, url?, limits?)` | searchParams → `ListParams` + `clamped` |
583
+ | `isQueryKitError(body)` / `QueryKitErrorBody` | recognise a querykit error in a 400 response |
500
584
  | `setParam/setPage/setSize/setSort/resetParams` | write URL (immutable, page-reset) |
501
585
  | `encodeSort/decodeSort` | `[{key,direction}]` ↔ `"-createdAt,id"` (multi-field) |
502
- | `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router-dom) |
586
+ | `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router) |
503
587
  | `useListParamsBase(resource, {schema, searchParams, setSearchParams})` (`/react`) | router-agnostic hook |
504
588
 
505
589
  ## Development
package/README.md CHANGED
@@ -48,19 +48,31 @@ function UsersPage() {
48
48
  - **3 pagination** — offset (`list`), infinite (`infinite`), cursor/keyset (`cursor`); har birining meta'si alohida `parse*` bilan camelCase'ga o'giriladi.
49
49
  - **Adapter-aware `with`** — `mongoose`/`drizzle-pg`/`drizzle-sqlite`/`prisma-pg` uchun faqat **wire-safe** (JSON) relation opsiyalari intellisense'да; funksiya/SQL formalarи ko'rsatilmaydi.
50
50
  - **Declarative schema** — `resource.schema` / `defineListSchema` bilan URL param → filter; har sahifadagi qo'lda qurishni yo'q qiladi.
51
- - **React hook** — `useListParams` (`react-router-dom` ichida), `useListParamsBase` (router-agnostik). Core framework'siz — Vue ham shu core'ga bog'lanadi.
51
+ - **React hook** — `useListParams` (`react-router` ichida), `useListParamsBase` (router-agnostik). Core framework'siz — Vue ham shu core'ga bog'lanadi.
52
52
  - **SSR** — `resource.fromSearchParams(sp, {schema})` hook'siz, `URLSearchParams`'dan to'g'ridan-to'g'ri payload.
53
53
  - **Query keys** — `resource.keys` TanStack/SWR uchun barqaror kalitlar.
54
- - **Zero dependency** — axios/react-query/dayjs YO'Q. `react` (+ hook uchun `react-router-dom`) ixtiyoriy peer.
54
+ - **Zero dependency** — axios/react-query/dayjs YO'Q. `react` (+ hook uchun `react-router`) ixtiyoriy peer.
55
55
 
56
56
  ## O'rnatish
57
57
 
58
58
  ```bash
59
59
  bun add @querykitjs/web
60
- # React hook uchun: react (peer). `useListParams` uchun qo'shimcha: react-router-dom (peer).
61
- # Router-agnostik `useListParamsBase` react-router-dom talab qilmaydi.
60
+ # React hook uchun: react (peer). `useListParams` uchun qo'shimcha: react-router (peer).
61
+ # Router-agnostik `useListParamsBase` react-router talab qilmaydi.
62
62
  ```
63
63
 
64
+ ### Talablar
65
+
66
+ | | Versiya | Tekshiriladi (`bun run test:matrix`) |
67
+ | ------------------------------------------------------ | -------------------- | ------------------------------------ |
68
+ | Node.js | `>=22.12` | 24 |
69
+ | `react` (ixtiyoriy peer) | `^18.3 \|\| ^19.2.7` | 18.3.1, 19.3.1 |
70
+ | `react-router` (ixtiyoriy peer, faqat `useListParams`) | `^7.0 \|\| ^8.0` | 7.18.5, 8.5.1 |
71
+
72
+ Node 20 endi qo'llab-quvvatlanmaydi (`engines`: `>=22.12`). Peer diapazoni — oxirgi ikki major; ikkala cheti testda.
73
+
74
+ **`react-router-dom` → `react-router`.** `useListParams` endi `react-router`dan import qiladi (v7 va v8 ikkalasi ham `useSearchParams` eksport qiladi). `react-router-dom` o'rnatilgan loyiha `react-router` ni ham o'rnatadi — v7 da u allaqachon `react-router-dom`ning bog'liqligi: `npm i react-router`.
75
+
64
76
  ## Ikki xil ishlatish
65
77
 
66
78
  ```ts
@@ -84,18 +96,20 @@ export const qk = createRegistry({
84
96
  sort: ["-createdAt"], // list + infinite tartibi (boshidagi "-" = desc)
85
97
  cursor: { order: "asc" }, // faqat cursor: kalit bo'ylab yurish tartibi
86
98
  },
99
+ timestamps: { createdAt: "created_at" }, // backend registry'dagi bilan bir xil; default sort shu maydon bo'yicha desc
87
100
  pruneEmpty: true, // bo'sh filterlarni tashlash (""/null/undefined/[]); 0/false qoladi
88
101
  });
89
102
  ```
90
103
 
91
- | Config | Vazifasi |
92
- | ----------------------- | --------------------------------------------------------------------------------- |
93
- | `adapter` | Backend turi — `with` tipini moslaydi (faqat intellisense). |
94
- | `defaults.perPage` | `list` uchun default `perPage`. |
95
- | `defaults.limit` | `infinite` va `cursor` uchun default `limit`. |
96
- | `defaults.sort` | `list`/`infinite` default sort — `[{key,direction}]` yoki `["-field"]` shorthand. |
97
- | `defaults.cursor.order` | Default cursor tartibi (`"asc"`/`"desc"`). |
98
- | `pruneEmpty` | Form input'lardan qurilgan bo'sh filterlarni avtomatik tashlash (default `true`). |
104
+ | Config | Vazifasi |
105
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `adapter` | Backend turi — `with` tipini moslaydi (faqat intellisense). |
107
+ | `defaults.perPage` | `list` uchun default `perPage`. |
108
+ | `defaults.limit` | `infinite` va `cursor` uchun default `limit`. |
109
+ | `defaults.sort` | `list`/`infinite` default sort — `[{key,direction}]` yoki `["-field"]` shorthand. |
110
+ | `defaults.cursor.order` | Default cursor tartibi (`"asc"`/`"desc"`). |
111
+ | `timestamps.createdAt` | Backend `timestamps.createdAt` bilan bir xil; `defaults.sort` yo'q bo'lsa default sort `[{ key: createdAt, direction: "desc" }]` (default `"createdAt"`). |
112
+ | `pruneEmpty` | Form input'lardan qurilgan bo'sh filterlarni avtomatik tashlash (default `true`). |
99
113
 
100
114
  Har bir default har chaqiriqда override qilinadi.
101
115
 
@@ -106,19 +120,19 @@ import type { IUser } from "@my/api-types";
106
120
  export const users = qk.resource<IUser>("users"); // "users" = cache-key namespace (majburiy, unikal)
107
121
  ```
108
122
 
109
- | A'zo | Nima |
110
- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
111
- | `users.list(input)` | offset (page) payload quradi |
112
- | `users.infinite(input)` | infinite (limit/offset) payload quradi |
113
- | `users.cursor(input)` | cursor (keyset) payload quradi |
114
- | `users.params(input)` | pagination'siz payload (filter/sort/columns/with) |
115
- | `users.f` | `createFilters<IUser>()` — maydon-tipli builder |
116
- | `users.search(term, fields)` | ko'p maydon bo'ylab OR-of-`contains` preset |
117
- | `users.schema(desc)` | `defineListSchema` bog'langan (URL ↔ filter) |
118
- | `users.fromSearchParams(sp, {schema})` | `URLSearchParams`'dan to'g'ridan-to'g'ri list payload (SSR / hook'siz) |
119
- | `users.keys` | TanStack/SWR uchun barqaror query keys |
120
- | `users.parseList / parseInfinite / parseCursor` | javob meta'sini snake→camel |
121
- | `ListResult<T> / InfiniteResult<T> / CursorResult<T>` (tiplar) | `parse*` qaytaradigan tiplar |
123
+ | A'zo | Nima |
124
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------------- |
125
+ | `users.list(input)` | offset (page) payload quradi |
126
+ | `users.infinite(input)` | infinite (limit/offset) payload quradi |
127
+ | `users.cursor(input)` | cursor (keyset) payload quradi |
128
+ | `users.params(input)` | pagination'siz payload (filter/sort/columns/with) |
129
+ | `users.f` | `createFilters<IUser>()` — maydon-tipli builder |
130
+ | `users.search(term, fields, { operation? })` | ko'p maydon bo'ylab OR preset (`contains` default, `startsWith`, `endsWith`) |
131
+ | `users.schema(desc)` | `defineListSchema` bog'langan (URL ↔ filter) |
132
+ | `users.fromSearchParams(sp, {schema})` | `URLSearchParams`'dan to'g'ridan-to'g'ri list payload (SSR / hook'siz) |
133
+ | `users.keys` | TanStack/SWR uchun barqaror query keys |
134
+ | `users.parseList / parseInfinite / parseCursor` | javob meta'sini snake→camel |
135
+ | `ListResult<T> / InfiniteResult<T> / CursorResult<T>` (tiplar) | `parse*` qaytaradigan tiplar |
122
136
 
123
137
  Barcha builder'lar registry'ning `defaults` + `pruneEmpty`ini meros oladi; tiplar `IUser` va `adapter`dan kelib chiqadi.
124
138
 
@@ -155,8 +169,13 @@ f.contains("age", "x"); // ✗ compile-error — `contains` string-only
155
169
  ```ts
156
170
  users.search("ali", ["buyerName", "email"]);
157
171
  // → or(contains("buyerName","ali"), contains("email","ali"))
172
+ users.search("ali", ["buyerName"], { operation: "startsWith" });
173
+ // → or(startsWith("buyerName","ali")) — indeks ishlatishi mumkin
158
174
  ```
159
175
 
176
+ - `contains` qimmat: `%ali%` B-tree indeksini ishlata olmaydi (Postgres'da to'liq skan, MongoDB'da regex). Katta jadvalda `startsWith` (`ali%`) yoki full-text qidiruvni tanlang.
177
+ - Har maydon serverda `filterable` bo'lishi kerak, o'sha operator bilan (`{ buyerName: ["%_%"] }` yoki `["%_"]`); aks holda server `forbidden-key` / `forbidden-operator` bilan 400 qaytaradi.
178
+
160
179
  Tipsiz `f` ham bor: `import { f } from "@querykitjs/web"` (maydon-tip tekshiruvisiz, tez ishlatish).
161
180
 
162
181
  ## Sort — hamisha `[{ key, direction }]` (ko'p-maydon)
@@ -219,6 +238,15 @@ users.parseInfinite(res.data); // { data, meta: { hasMore, nextOffset, … } }
219
238
  users.parseCursor(res.data); // { data, meta: { nextCursor, hasNext, … } }
220
239
  ```
221
240
 
241
+ **`withTotal: false`** — umumiy son kerak bo'lmasa (infinite-ga o'xshash ro'yxat, "keyingi" tugmasi), server count so'rovini ishlatmaydi:
242
+
243
+ ```ts
244
+ const body = users.list({ page, perPage: 20, withTotal: false }); // payload'da withTotal: false (berilmasa — kalit yo'q)
245
+ const { meta } = users.parseList(res.data); // meta.totalCount === null, meta.totalPages === null; hasNext ishlaydi
246
+ ```
247
+
248
+ `Meta.totalCount` / `Meta.totalPages` tipi `number | null`: `null` — count ishlamagan; server umuman bermagan maydon avvalgidek `0`. zod (`makeOffsetParamsSchema`) va class-validator (`makeOffsetParamsDto`) `withTotal`ni qabul qiladi.
249
+
222
250
  `parse*` ikki darajaда bor (bitta umumiy implementatsiya — meta entity-agnostik):
223
251
  `qk.parseList/...` (registry, `data: unknown[]`) — `resource` bo'lmasa; `users.parseList/...` (entity'ga tipli).
224
252
 
@@ -276,7 +304,7 @@ Bo'sh/**yo'q** paramlar tashlanadi; `default` faqat param **yo'q** bo'lganда t
276
304
  ## React hook
277
305
 
278
306
  ```tsx
279
- import { useListParams } from "@querykitjs/web/react"; // react-router-dom ichida ishlatiladi
307
+ import { useListParams } from "@querykitjs/web/react"; // react-router ichida ishlatiladi
280
308
  import { qk } from "@/lib/query";
281
309
 
282
310
  const users = qk.resource<IUser>("users");
@@ -301,7 +329,9 @@ function UsersListPage() {
301
329
 
302
330
  Input/sort/page o'zgarishi URL'ni o'zgartiradi → `params` o'zgaradi → `queryKey` o'zgaradi → TanStack qayta so'raydi (va har kombinatsiyani cache qiladi).
303
331
 
304
- **Router-agnostik** (Next.js, TanStack Router) — base hook'ga `searchParams` bering (`react-router-dom` talab qilmaydi):
332
+ Hook `clamped: boolean` ham qaytaradi — URL'dagi `page` yoki `sort` limitdan oshgani uchun kesilgan (pastda «Limits»). URL'ni tuzatish uchun: `if (clamped) setPage(params.page)`.
333
+
334
+ **Router-agnostik** (Next.js, TanStack Router) — base hook'ga `searchParams` bering (`react-router` talab qilmaydi):
305
335
 
306
336
  ```tsx
307
337
  import { useListParamsBase } from "@querykitjs/web/react";
@@ -465,6 +495,59 @@ createRegistry({ adapter: "drizzle-pg", defaults: { maxPerPage: 50, maxLimit: 25
465
495
  createQuery({ maxPerPage: Infinity });
466
496
  ```
467
497
 
498
+ ## Limits
499
+
500
+ `perPage`/`limit` clamp qilinadi (yuqorida), lekin **sahifa chuqurligi** va **sort maydonlari soni** clamp qilinmaydi — builder `Error` tashlaydi, chunki jim kesish so'rov ma'nosini o'zgartirardi (boshqa sahifa yoki boshqa tartib). Qiymatlar `@querykitjs/core`dan — zod va class-validator bilan bir xil.
501
+
502
+ | Limit | Default | Nima sanaladi | Oshsa |
503
+ | --------------- | -------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
504
+ | `maxOffset` | `10 000` | `list`: `(page - 1) × perPage`; `infinite`: `offset` | `Error: querykit: page 502 × perPage 20 exceeds maxOffset (10000); …` |
505
+ | `maxSortFields` | `3` | normalizatsiyadan keyingi `sort` maydonlari | `Error: querykit: sort with 4 fields exceeds maxSortFields (3)` |
506
+
507
+ ```ts
508
+ buildListParams({ page: 501, perPage: 20 }); // ✅ offset 10 000
509
+ buildListParams({ page: 502, perPage: 20 }); // ❌ Error — chuqurroq sahifalar uchun buildCursorParams
510
+
511
+ createQuery({ maxOffset: 50_000, maxSortFields: 4 });
512
+ createRegistry({ adapter: "drizzle-pg", defaults: { maxOffset: 50_000 } });
513
+ ```
514
+
515
+ - Filtr limitlari (chuqurlik, shartlar, `in` elementlari, satr uzunligi) web'da **tekshirilmaydi** — ularni server (zod / class-validator) rad etadi va 400 qaytaradi.
516
+ - **URL o'quvchilar clamp qiladi.** URL foydalanuvchi qo'lida, shuning uchun `readListParams`, `searchParamsToPayload`, `resource.fromSearchParams` va hook'lar (`useListParams`, `useListParamsBase`) xato tashlamaydi: `page` ruxsat etilgan oxirgi sahifaga (`floor(maxOffset / perPage) + 1`), `sort` birinchi `maxSortFields` maydoniga kesiladi va natijada `clamped: true` bo'ladi. `?page=9999&size=20` → `page: 501`, `clamped: true`; `?sortType=a,b,c,d` → 3 maydon, `clamped: true`. Registry orqali — registry `defaults` limitlari bilan.
517
+
518
+ ```ts
519
+ const read = readListParams(schema, new URLSearchParams("page=9999&size=20")); // { page: 501, perPage: 20, clamped: true, … }
520
+ const body = users.fromSearchParams(sp, { schema }); // body.clamped === true
521
+ JSON.stringify(body); // clamped yo'q — u payload'da non-enumerable, server'ga bormaydi
522
+ const { params, clamped } = useListParams(users, { schema }); // render paytida xato yo'q
523
+ ```
524
+
525
+ Dasturchi to'g'ridan-to'g'ri chaqirgan builder'lar (`buildListParams`, `resource.list`, …) avvalgidek `Error` tashlaydi.
526
+
527
+ ## Migrating (limitlar)
528
+
529
+ - `buildListParams` / `buildInfiniteParams` / `createQuery` / registry `list`/`infinite` endi offset 10 000 dan chuqur yoki 3 tadan ko'p sort maydonli so'rovda `Error` tashlaydi (ilgari payload yasalardi va server uni qabul qilardi). Chuqur sahifalar — cursor; zarur bo'lsa `maxOffset` / `maxSortFields` bilan oshiring (serverda ham xuddi shu limitni oshiring).
530
+
531
+ - `Meta.totalCount` / `Meta.totalPages` endi `number | null` (`withTotal: false` javobida `null`). `meta.totalCount + 1` kabi kod TS'da `null` tekshiruvini talab qiladi; `withTotal` yubormaydigan ilovada qiymat avvalgidek raqam.
532
+ - URL'dan o'qilgan chuqur `page` endi xato emas, clamp (`clamped: true`) — error boundary yoki qo'lda tekshiruv kerak emas.
533
+
534
+ ## Xatolar — `isQueryKitError`
535
+
536
+ Server so'rovni rad etsa (`forbidden-key`, `forbidden-operator`, `limit-exceeded`, `empty-filter`, …), backend `QueryKitError.info`ni 400 javobida qaytarishi mumkin. Javob konverti loyihaniki; web faqat `info` shaklini tiplaydi:
537
+
538
+ ```ts
539
+ import { isQueryKitError, type QueryKitErrorBody } from "@querykitjs/web";
540
+
541
+ // backend: catch (e) { if (e instanceof QueryKitError) return res.status(400).json({ error: e.info }); }
542
+ const body = error.response?.data?.error;
543
+ if (isQueryKitError(body)) {
544
+ // body: { reason, site, key, source?, operation? }
545
+ if (body.reason === "forbidden-key" && body.site === "filter") showFieldError(body.key);
546
+ }
547
+ ```
548
+
549
+ `isQueryKitError` `reason` va `site` ni core ro'yxatidan tekshiradi, `key` — satr; boshqa har obyekt (masalan `{ statusCode: 400, message }`) → `false`.
550
+
468
551
  ## So'rovni o'zingiz yuborasiz
469
552
 
470
553
  Kutubxona faqat **so'rovgacha** ishlaydi. Transport yo'q:
@@ -486,7 +569,7 @@ const { data, meta } = users.parseList(await res.json());
486
569
  | `registry.resource<T>(name)` | entity-tipli builder/filter/schema/keys/parser |
487
570
  | `resource.list/infinite/cursor/params(input)` | payload qurish (offset/infinite/cursor/pagination'siz) |
488
571
  | `resource.f` / `createFilters<T>()` / `f` | tipli filter builder / tipli / tipsiz |
489
- | `resource.search(term, fields)` | OR-of-contains preset |
572
+ | `resource.search(term, fields, { operation? })` | OR preset (`contains` / `startsWith` / `endsWith`) |
490
573
  | `resource.schema(desc)` / `defineListSchema(desc)` | URL param → filter tavsifi |
491
574
  | `resource.fromSearchParams(sp, {schema})` | searchParams → list payload (SSR) |
492
575
  | `resource.keys` | TanStack/SWR query keys |
@@ -495,11 +578,12 @@ const { data, meta } = users.parseList(await res.json());
495
578
  | `buildParams / buildListParams / buildInfiniteParams / buildCursorParams` | standalone builder'lar |
496
579
  | `createQuery(config)` | registry'siz builder (default'lar/pruneEmpty) |
497
580
  | `schemaToFilter(schema, sp)` | searchParams → `FieldCondition[]` |
498
- | `searchParamsToPayload(schema, sp)` | searchParams → to'liq payload |
499
- | `readListParams(schema, sp)` | searchParams → `ListParams` |
581
+ | `searchParamsToPayload(schema, sp)` | searchParams → to'liq payload (clamp, `clamped`) |
582
+ | `readListParams(schema, sp, url?, limits?)` | searchParams → `ListParams` + `clamped` |
583
+ | `isQueryKitError(body)` / `QueryKitErrorBody` | 400 javobidagi querykit xatosini tanish |
500
584
  | `setParam/setPage/setSize/setSort/resetParams` | URL yozish (immutable, page-reset) |
501
585
  | `encodeSort/decodeSort` | `[{key,direction}]` ↔ `"-createdAt,id"` (multi-field) |
502
- | `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router-dom) |
586
+ | `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router) |
503
587
  | `useListParamsBase(resource, {schema, searchParams, setSearchParams})` (`/react`) | router-agnostik hook |
504
588
 
505
589
  ## Ishlab chiqish