@querykitjs/web 3.1.0 → 4.1.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 +208 -38
- package/README.md +209 -39
- package/dist/angular/index.cjs +2 -0
- package/dist/angular/index.cjs.map +1 -0
- package/dist/angular/index.d.cts +70 -0
- package/dist/angular/index.d.ts +70 -0
- package/dist/angular/index.js +2 -0
- package/dist/angular/index.js.map +1 -0
- package/dist/index.cjs +1 -360
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -3
- package/dist/index.d.ts +75 -3
- package/dist/index.js +1 -336
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +1 -147
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +11 -4
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +1 -144
- package/dist/react/index.js.map +1 -1
- package/dist/{registry-B1WohYab.d.cts → registry-CEq4xFui.d.cts} +111 -53
- package/dist/{registry-B1WohYab.d.ts → registry-CEq4xFui.d.ts} +111 -53
- package/dist/vue/index.cjs +2 -0
- package/dist/vue/index.cjs.map +1 -0
- package/dist/vue/index.d.cts +64 -0
- package/dist/vue/index.d.ts +64 -0
- package/dist/vue/index.js +2 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +100 -15
- package/skills/querykit-web/SKILL.md +241 -0
package/README.en.md
CHANGED
|
@@ -46,21 +46,40 @@ function UsersPage() {
|
|
|
46
46
|
- **Registry** — configure `adapter` + defaults once; `resource<T>()` gives entity-typed, adapter-aware builders/filters/schema/keys/parsers.
|
|
47
47
|
- **Two filter styles** — `f.eq()...` builder (operators typed per field) AND object/array (`{key, operation, value}`). Fully compatible with legacy `IFilter[]`.
|
|
48
48
|
- **3 pagination modes** — offset (`list`), infinite (`infinite`), cursor/keyset (`cursor`); each has its own `parse*` to camelCase the meta.
|
|
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.
|
|
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. `mongodb` has no `with` — load relations with a second request.
|
|
50
50
|
- **Declarative schema** — `resource.schema` / `defineListSchema` maps URL params → filter; kills the per-page hand-building.
|
|
51
|
-
- **React hooks** — `useListParams` (react-router-
|
|
51
|
+
- **React hooks, Vue composables and Angular signals** — `useListParams` (react-router / vue-router inside) and `useListParamsBase` (router-agnostic) in `/react` and `/vue`; `injectListParams` and `injectListParamsBase` in `/angular`. `createListState` — the same logic, framework-free.
|
|
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
|
|
54
|
+
- **Zero dependency** — no axios/react-query/dayjs. `react` (+ `react-router` for the hook) `vue` (+ `vue-router`) and `@angular/core` (+ `@angular/router`, `rxjs`) 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
|
|
61
|
-
# The router-agnostic `useListParamsBase` needs no react-router
|
|
60
|
+
# React hook: react (peer). `useListParams` also peers react-router.
|
|
61
|
+
# The router-agnostic `useListParamsBase` needs no react-router.
|
|
62
|
+
# Vue composable: vue (peer). `useListParams` also peers vue-router.
|
|
63
|
+
# Angular: @angular/core and rxjs (peers). `injectListParams` also peers @angular/router.
|
|
62
64
|
```
|
|
63
65
|
|
|
66
|
+
### Requirements
|
|
67
|
+
|
|
68
|
+
| | Version | Tested (`bun run test:matrix`) |
|
|
69
|
+
| ---------------------------------------------------------- | -------------------- | ------------------------------ |
|
|
70
|
+
| Node.js | `>=22.12` | 24 |
|
|
71
|
+
| `react` (optional peer) | `^18.3 \|\| ^19.2.7` | 18.3.1, 19.3.1 |
|
|
72
|
+
| `react-router` (optional peer, `useListParams` only) | `^7.0 \|\| ^8.0` | 7.18.5, 8.5.1 |
|
|
73
|
+
| `vue` (optional peer) | `^3.5` | 3.5.0, 3.5.44 |
|
|
74
|
+
| `vue-router` (optional peer, `useListParams` only) | `^4.6 \|\| ^5` | 4.6.4, 5.4.0 |
|
|
75
|
+
| `@angular/core` (optional peer) | `^21 \|\| ^22` | 21.2.26, 22.2.4 |
|
|
76
|
+
| `@angular/router` (optional peer, `injectListParams` only) | `^21 \|\| ^22` | 21.2.26, 22.2.4 |
|
|
77
|
+
| `rxjs` (optional peer, Angular's own) | `^7.8` | 7.8.2 |
|
|
78
|
+
|
|
79
|
+
Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested. Vue has one supported major: its range starts at 3.5, the oldest minor vue-router 4.6 accepts.
|
|
80
|
+
|
|
81
|
+
**`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`.
|
|
82
|
+
|
|
64
83
|
## Two ways to use it
|
|
65
84
|
|
|
66
85
|
```ts
|
|
@@ -77,25 +96,27 @@ Use (A) when you want defaults in one place, autocomplete, adapter `with`, query
|
|
|
77
96
|
|
|
78
97
|
```ts
|
|
79
98
|
export const qk = createRegistry({
|
|
80
|
-
adapter: "mongoose", // "mongoose" | "drizzle-pg" | "drizzle-sqlite" | "prisma-pg"
|
|
99
|
+
adapter: "mongoose", // "mongoose" | "mongodb" | "drizzle-pg" | "drizzle-sqlite" | "prisma-pg"
|
|
81
100
|
defaults: {
|
|
82
101
|
perPage: 20, // list (offset) page size
|
|
83
102
|
limit: 20, // infinite + cursor page size
|
|
84
103
|
sort: ["-createdAt"], // list + infinite ordering (leading "-" = desc)
|
|
85
104
|
cursor: { order: "asc" }, // cursor-only key traversal order
|
|
86
105
|
},
|
|
106
|
+
timestamps: { createdAt: "created_at" }, // same as the backend registry; the default sort is this field, desc
|
|
87
107
|
pruneEmpty: true, // drop empty filter values (""/null/undefined/[]); keep 0/false
|
|
88
108
|
});
|
|
89
109
|
```
|
|
90
110
|
|
|
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
|
-
| `
|
|
111
|
+
| Config | Meaning |
|
|
112
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
113
|
+
| `adapter` | Target backend — tailors `with` typing (intellisense only). |
|
|
114
|
+
| `defaults.perPage` | Default `perPage` for `list`. |
|
|
115
|
+
| `defaults.limit` | Default `limit` for `infinite` and `cursor`. |
|
|
116
|
+
| `defaults.sort` | Default sort for `list`/`infinite` — `[{key,direction}]` or `["-field"]` shorthand. |
|
|
117
|
+
| `defaults.cursor.order` | Default cursor traversal order (`"asc"`/`"desc"`). |
|
|
118
|
+
| `timestamps.createdAt` | Same as the backend `timestamps.createdAt`; without `defaults.sort` the default sort is `[{ key: createdAt, direction: "desc" }]` (default `"createdAt"`). |
|
|
119
|
+
| `pruneEmpty` | Auto-drop empty filter conditions built from form inputs (default `true`). |
|
|
99
120
|
|
|
100
121
|
Every default is overridable per call.
|
|
101
122
|
|
|
@@ -106,19 +127,19 @@ import type { IUser } from "@my/api-types";
|
|
|
106
127
|
export const users = qk.resource<IUser>("users"); // "users" = cache-key namespace (required, unique)
|
|
107
128
|
```
|
|
108
129
|
|
|
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)`
|
|
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
|
|
130
|
+
| Member | What it is |
|
|
131
|
+
| ------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
132
|
+
| `users.list(input)` | build an offset (page) payload |
|
|
133
|
+
| `users.infinite(input)` | build an infinite (limit/offset) payload |
|
|
134
|
+
| `users.cursor(input)` | build a cursor (keyset) payload |
|
|
135
|
+
| `users.params(input)` | build a paginationless payload (filter/sort/columns/with) |
|
|
136
|
+
| `users.f` | `createFilters<IUser>()` — field-typed builder |
|
|
137
|
+
| `users.search(term, fields, { operation? })` | OR preset across `fields` (`contains` by default, `startsWith`, `endsWith`) |
|
|
138
|
+
| `users.schema(desc)` | `defineListSchema` bound (URL ↔ filter) |
|
|
139
|
+
| `users.fromSearchParams(sp, {schema})` | build a list payload straight from `URLSearchParams` (SSR / no hook) |
|
|
140
|
+
| `users.keys` | stable query keys for TanStack/SWR |
|
|
141
|
+
| `users.parseList / parseInfinite / parseCursor` | map response meta snake→camel |
|
|
142
|
+
| `ListResult<T> / InfiniteResult<T> / CursorResult<T>` (types) | `parse*` return types |
|
|
122
143
|
|
|
123
144
|
All builders inherit the registry's `defaults` + `pruneEmpty`; all types are driven by `IUser` and the `adapter`.
|
|
124
145
|
|
|
@@ -155,8 +176,13 @@ f.contains("age", "x"); // ✗ compile error — `contains` is string-only
|
|
|
155
176
|
```ts
|
|
156
177
|
users.search("ali", ["buyerName", "email"]);
|
|
157
178
|
// → or(contains("buyerName","ali"), contains("email","ali"))
|
|
179
|
+
users.search("ali", ["buyerName"], { operation: "startsWith" });
|
|
180
|
+
// → or(startsWith("buyerName","ali")) — can use an index
|
|
158
181
|
```
|
|
159
182
|
|
|
183
|
+
- `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.
|
|
184
|
+
- Every field must be `filterable` on the server with that operator (`{ buyerName: ["%_%"] }` or `["%_"]`); otherwise the server answers 400 with `forbidden-key` / `forbidden-operator`.
|
|
185
|
+
|
|
160
186
|
An untyped `f` is also exported: `import { f } from "@querykitjs/web"` (no field-type checks, quick use).
|
|
161
187
|
|
|
162
188
|
## Sort — always `[{ key, direction }]` (multi-field)
|
|
@@ -219,6 +245,15 @@ users.parseInfinite(res.data); // { data, meta: { hasMore, nextOffset, … } }
|
|
|
219
245
|
users.parseCursor(res.data); // { data, meta: { nextCursor, hasNext, … } }
|
|
220
246
|
```
|
|
221
247
|
|
|
248
|
+
**`withTotal: false`** — when you do not need the total (an infinite-like list, a "next" button), the server skips the count query:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const body = users.list({ page, perPage: 20, withTotal: false }); // the payload carries withTotal: false (absent when not given)
|
|
252
|
+
const { meta } = users.parseList(res.data); // meta.totalCount === null, meta.totalPages === null; hasNext still works
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`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`.
|
|
256
|
+
|
|
222
257
|
`parse*` exists at two levels (one shared implementation — meta is entity-agnostic):
|
|
223
258
|
`qk.parseList/...` (registry, `data: unknown[]`) when you don't have a `resource`; `users.parseList/...` (entity-typed).
|
|
224
259
|
|
|
@@ -226,11 +261,12 @@ users.parseCursor(res.data); // { data, meta: { nextCursor, hasNext, … } }
|
|
|
226
261
|
|
|
227
262
|
The registry types `with` per adapter — **serializable options only**:
|
|
228
263
|
|
|
229
|
-
| adapter | `with[rel]` intellisense | Hidden (not offered)
|
|
230
|
-
| ------------------------------- | ------------------------------------------------------------------ |
|
|
231
|
-
| `mongoose` | `boolean \| { select?, populate?, match?, options? }` | —
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
264
|
+
| adapter | `with[rel]` intellisense | Hidden (not offered) |
|
|
265
|
+
| ------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
266
|
+
| `mongoose` | `boolean \| { select?, populate?, match?, options? }` | — |
|
|
267
|
+
| `mongodb` | no `with` — load relations with a second request | any `with` (TS error; the server answers `forbidden-key`) |
|
|
268
|
+
| `drizzle-pg` / `drizzle-sqlite` | `boolean \| { columns?, with?, limit?, offset? }` | `where:(f,op)=>…`, `orderBy:(f,op)=>…`, raw `sql` |
|
|
269
|
+
| `prisma-pg` | `boolean \| { select?, include?, where?, orderBy?, take?, skip? }` | function forms |
|
|
234
270
|
|
|
235
271
|
```ts
|
|
236
272
|
// adapter: "mongoose"
|
|
@@ -276,7 +312,7 @@ Empty/**absent** params are pruned; `default` fills only when the param is **abs
|
|
|
276
312
|
## React hook
|
|
277
313
|
|
|
278
314
|
```tsx
|
|
279
|
-
import { useListParams } from "@querykitjs/web/react"; // uses react-router
|
|
315
|
+
import { useListParams } from "@querykitjs/web/react"; // uses react-router internally
|
|
280
316
|
import { qk } from "@/lib/query";
|
|
281
317
|
|
|
282
318
|
const users = qk.resource<IUser>("users");
|
|
@@ -299,15 +335,90 @@ function UsersListPage() {
|
|
|
299
335
|
}
|
|
300
336
|
```
|
|
301
337
|
|
|
338
|
+
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)`.
|
|
339
|
+
|
|
302
340
|
An input/sort/page change mutates the URL → `params` changes → `queryKey` changes → TanStack refetches (and caches each combo).
|
|
303
341
|
|
|
304
|
-
**Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router
|
|
342
|
+
**Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router):
|
|
305
343
|
|
|
306
344
|
```tsx
|
|
307
345
|
import { useListParamsBase } from "@querykitjs/web/react";
|
|
308
346
|
const { params, setPage } = useListParamsBase(users, { schema: usersSchema, searchParams, setSearchParams });
|
|
309
347
|
```
|
|
310
348
|
|
|
349
|
+
## Vue composable
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
import { useListParams } from "@querykitjs/web/vue"; // uses vue-router internally
|
|
353
|
+
import { useQuery } from "@tanstack/vue-query";
|
|
354
|
+
import { computed } from "vue";
|
|
355
|
+
|
|
356
|
+
const users = qk.resource<IUser>("users");
|
|
357
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
358
|
+
|
|
359
|
+
// inside <script setup>
|
|
360
|
+
const { params, clamped, setParam, setPage, setSort, reset } = useListParams(users, { schema: usersSchema });
|
|
361
|
+
|
|
362
|
+
const q = useQuery({
|
|
363
|
+
queryKey: computed(() => users.keys.list(params.value)),
|
|
364
|
+
queryFn: async () => users.parseList((await axios.post("/users/list", params.value)).data),
|
|
365
|
+
});
|
|
366
|
+
// setParam("status", "active") → router.push({ query }) (a new history entry), page reset
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The same reads, clamping and setters as the React hook — `params`, `clamped` and `searchParams` are computed refs.
|
|
370
|
+
A repeated key (`?status=a&status=b`) is kept; `?q` (no `=`) reads as an empty value. Works under SSR (Nuxt): no `window` access.
|
|
371
|
+
|
|
372
|
+
**Router-agnostic** — pass `searchParams` (a ref, a getter or a value) to the base composable:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { useListParamsBase } from "@querykitjs/web/vue";
|
|
376
|
+
const { params, setPage } = useListParamsBase(users, { schema: usersSchema, searchParams: () => currentSearchParams.value, setSearchParams });
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Angular
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
import { Component, computed } from "@angular/core";
|
|
383
|
+
import { injectListParams } from "@querykitjs/web/angular"; // uses the Angular Router
|
|
384
|
+
|
|
385
|
+
const users = qk.resource<IUser>("users");
|
|
386
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
387
|
+
|
|
388
|
+
@Component({
|
|
389
|
+
selector: "app-users",
|
|
390
|
+
template: `<button (click)="list.setPage(2)">{{ list.params().page }}</button>`,
|
|
391
|
+
})
|
|
392
|
+
export class UsersPage {
|
|
393
|
+
readonly list = injectListParams(users, { schema: usersSchema });
|
|
394
|
+
readonly queryKey = computed(() => users.keys.list(this.list.params()));
|
|
395
|
+
// list.setParam("status", "active") → router.navigate (a new history entry), page reset
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The same reads, clamping and setters as the React and Vue versions — `params`, `clamped` and `searchParams` are signals. Call it in an injection context (a field initializer or a constructor); elsewhere Angular throws `NG0203`. A plain ESM entry: no decorator and no Angular package format, it builds with `@angular/build`.
|
|
400
|
+
|
|
401
|
+
**Router-agnostic** — pass your own `searchParams` signal to the base function:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { injectListParamsBase } from "@querykitjs/web/angular";
|
|
405
|
+
const searchParams = signal(new URLSearchParams());
|
|
406
|
+
const list = injectListParamsBase(users, { schema: usersSchema, searchParams, setSearchParams: next => searchParams.set(next) });
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Any framework — `createListState`
|
|
410
|
+
|
|
411
|
+
The logic behind all of them, framework-free (Svelte, Solid, vanilla). Pure: it reads search params and returns the next ones; you navigate.
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { createListState } from "@querykitjs/web";
|
|
415
|
+
|
|
416
|
+
const list = createListState(users, { schema: usersSchema });
|
|
417
|
+
const { params, clamped } = list.read(new URLSearchParams(location.search));
|
|
418
|
+
const next = list.actions(new URLSearchParams(location.search)).setPage(2); // a new URLSearchParams
|
|
419
|
+
history.pushState(null, "", `?${next}`);
|
|
420
|
+
```
|
|
421
|
+
|
|
311
422
|
## SSR — `fromSearchParams` (no hook)
|
|
312
423
|
|
|
313
424
|
For server components / `getServerSideProps` — a payload straight from the URL:
|
|
@@ -357,6 +468,8 @@ const cur = users.parseCursor(curRes.data); // cur.meta.nextCursor, cur.meta.has
|
|
|
357
468
|
|
|
358
469
|
### TanStack `useQuery` (offset)
|
|
359
470
|
|
|
471
|
+
> Ready-made options: [`@querykitjs/tanstack-query`](../tanstack-query/README.en.md) builds these objects for you — `listOptions`, `infiniteOptions`, `cursorOptions` (key, fetch, meta parsing, page params).
|
|
472
|
+
|
|
360
473
|
```tsx
|
|
361
474
|
import { useQuery } from "@tanstack/react-query";
|
|
362
475
|
const users = qk.resource<IUser>("users");
|
|
@@ -465,6 +578,59 @@ createRegistry({ adapter: "drizzle-pg", defaults: { maxPerPage: 50, maxLimit: 25
|
|
|
465
578
|
createQuery({ maxPerPage: Infinity });
|
|
466
579
|
```
|
|
467
580
|
|
|
581
|
+
## Limits
|
|
582
|
+
|
|
583
|
+
`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.
|
|
584
|
+
|
|
585
|
+
| Limit | Default | What counts | Past it |
|
|
586
|
+
| --------------- | -------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
|
|
587
|
+
| `maxOffset` | `10 000` | `list`: `(page - 1) × perPage`; `infinite`: `offset` | `Error: querykit: page 502 × perPage 20 exceeds maxOffset (10000); …` |
|
|
588
|
+
| `maxSortFields` | `3` | `sort` fields after normalization | `Error: querykit: sort with 4 fields exceeds maxSortFields (3)` |
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
buildListParams({ page: 501, perPage: 20 }); // ✅ offset 10 000
|
|
592
|
+
buildListParams({ page: 502, perPage: 20 }); // ❌ Error — use buildCursorParams for deeper pages
|
|
593
|
+
|
|
594
|
+
createQuery({ maxOffset: 50_000, maxSortFields: 4 });
|
|
595
|
+
createRegistry({ adapter: "drizzle-pg", defaults: { maxOffset: 50_000 } });
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
- Filter limits (depth, conditions, `in` items, string length) are **not** checked in web — the server (zod / class-validator) rejects them with a 400.
|
|
599
|
+
- **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.
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
const read = readListParams(schema, new URLSearchParams("page=9999&size=20")); // { page: 501, perPage: 20, clamped: true, … }
|
|
603
|
+
const body = users.fromSearchParams(sp, { schema }); // body.clamped === true
|
|
604
|
+
JSON.stringify(body); // no clamped — it is non-enumerable on the payload and never reaches the server
|
|
605
|
+
const { params, clamped } = useListParams(users, { schema }); // no error during render
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Builders the developer calls directly (`buildListParams`, `resource.list`, …) still throw an `Error`.
|
|
609
|
+
|
|
610
|
+
## Migrating (limits)
|
|
611
|
+
|
|
612
|
+
- `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).
|
|
613
|
+
|
|
614
|
+
- `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.
|
|
615
|
+
- A deep `page` read from the URL is now clamped (`clamped: true`), not an error — no error boundary or manual check needed.
|
|
616
|
+
|
|
617
|
+
## Errors — `isQueryKitError`
|
|
618
|
+
|
|
619
|
+
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:
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
import { isQueryKitError, type QueryKitErrorBody } from "@querykitjs/web";
|
|
623
|
+
|
|
624
|
+
// backend: catch (e) { if (e instanceof QueryKitError) return res.status(400).json({ error: e.info }); }
|
|
625
|
+
const body = error.response?.data?.error;
|
|
626
|
+
if (isQueryKitError(body)) {
|
|
627
|
+
// body: { reason, site, key, source?, operation? }
|
|
628
|
+
if (body.reason === "forbidden-key" && body.site === "filter") showFieldError(body.key);
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
`isQueryKitError` checks `reason` and `site` against the core lists and `key` is a string; any other object (e.g. `{ statusCode: 400, message }`) → `false`.
|
|
633
|
+
|
|
468
634
|
## You send the request yourself
|
|
469
635
|
|
|
470
636
|
The library works only **up to the request**. No transport:
|
|
@@ -486,7 +652,7 @@ const { data, meta } = users.parseList(await res.json());
|
|
|
486
652
|
| `registry.resource<T>(name)` | entity-typed builders/filters/schema/keys/parsers |
|
|
487
653
|
| `resource.list/infinite/cursor/params(input)` | build a payload (offset/infinite/cursor/paginationless) |
|
|
488
654
|
| `resource.f` / `createFilters<T>()` / `f` | typed filter builder / typed / untyped |
|
|
489
|
-
| `resource.search(term, fields)`
|
|
655
|
+
| `resource.search(term, fields, { operation? })` | OR preset (`contains` / `startsWith` / `endsWith`) |
|
|
490
656
|
| `resource.schema(desc)` / `defineListSchema(desc)` | URL param → filter descriptor |
|
|
491
657
|
| `resource.fromSearchParams(sp, {schema})` | searchParams → list payload (SSR) |
|
|
492
658
|
| `resource.keys` | TanStack/SWR query keys |
|
|
@@ -495,12 +661,16 @@ const { data, meta } = users.parseList(await res.json());
|
|
|
495
661
|
| `buildParams / buildListParams / buildInfiniteParams / buildCursorParams` | standalone builders |
|
|
496
662
|
| `createQuery(config)` | builder without a registry (defaults/pruneEmpty) |
|
|
497
663
|
| `schemaToFilter(schema, sp)` | searchParams → `FieldCondition[]` |
|
|
498
|
-
| `searchParamsToPayload(schema, sp)` | searchParams → full payload
|
|
499
|
-
| `readListParams(schema, sp)`
|
|
664
|
+
| `searchParamsToPayload(schema, sp)` | searchParams → full payload (clamped, `clamped`) |
|
|
665
|
+
| `readListParams(schema, sp, url?, limits?)` | searchParams → `ListParams` + `clamped` |
|
|
666
|
+
| `isQueryKitError(body)` / `QueryKitErrorBody` | recognise a querykit error in a 400 response |
|
|
500
667
|
| `setParam/setPage/setSize/setSort/resetParams` | write URL (immutable, page-reset) |
|
|
501
668
|
| `encodeSort/decodeSort` | `[{key,direction}]` ↔ `"-createdAt,id"` (multi-field) |
|
|
502
|
-
| `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router
|
|
669
|
+
| `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router) |
|
|
503
670
|
| `useListParamsBase(resource, {schema, searchParams, setSearchParams})` (`/react`) | router-agnostic hook |
|
|
671
|
+
| `useListParams` / `useListParamsBase` (`/vue`) | the same as Vue composables (computed refs) |
|
|
672
|
+
| `injectListParams` / `injectListParamsBase` (`/angular`) | the same as Angular signals |
|
|
673
|
+
| `createListState(resource, {schema, url?, with?})` | framework-free `read(sp)` / `actions(sp)` |
|
|
504
674
|
|
|
505
675
|
## Development
|
|
506
676
|
|