@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 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-dom inside), `useListParamsBase` (router-agnostic). Framework-agnostic core — Vue can bind the same core.
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-dom` for the hook) are optional peers.
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-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
+ # 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
- | `pruneEmpty` | Auto-drop empty filter conditions built from form inputs (default `true`). |
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)` | 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 |
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
- | `drizzle-pg` / `drizzle-sqlite` | `boolean \| { columns?, with?, limit?, offset? }` | `where:(f,op)=>…`, `orderBy:(f,op)=>…`, raw `sql` |
233
- | `prisma-pg` | `boolean \| { select?, include?, where?, orderBy?, take?, skip? }` | function forms |
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-dom internally
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-dom):
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)` | OR-of-contains preset |
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)` | searchParams → `ListParams` |
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-dom) |
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