@querykitjs/web 1.0.0 → 2.0.1

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
@@ -6,187 +6,462 @@
6
6
 
7
7
  # @querykitjs/web
8
8
 
9
- > Typed **query-building** for frontends: build filter/sort/pagination payloads, map response meta, sync URL state. It **does not send requests** — you pass the payload to your own `fetch`/`axios`.
9
+ [![npm](https://img.shields.io/npm/v/@querykitjs/web.svg)](https://www.npmjs.com/package/@querykitjs/web) [![license](https://img.shields.io/npm/l/@querykitjs/web.svg)](./LICENSE)
10
10
 
11
- React dashboards hand-write the same boilerplate on every list page: build `IFilter[]` from `searchParams`, drop empty filters, `sortType` ↔ `{name,direction}`, "reset page on any change", snake→camel meta. `@querykitjs/web` removes all of it. The payload matches what the querykit backend (`@querykitjs/drizzle-pg`) accepts.
11
+ > Typed **query-building** for frontends: build filter/sort/pagination payloads, map response meta, sync URL state. It **does not send requests** — you pass the payload to your own `fetch`/`axios`. Zero-dependency.
12
+
13
+ React dashboards hand-write the same boilerplate on every list page: build `IFilter[]` from `searchParams`, drop empty filters, `sortType` ↔ `{name,direction}`, "reset page on any change", snake→camel meta. `@querykitjs/web` removes all of it. The payload matches what the querykit backend (`@querykitjs/drizzle-pg`, a mongoose adapter, …) accepts.
12
14
 
13
15
  ```ts
14
- import { buildListParams, f, mapMeta } from "@querykitjs/web";
16
+ // src/lib/query.ts — configure once
17
+ import { createRegistry } from "@querykitjs/web";
15
18
 
16
- const params = buildListParams({
17
- filter: f.and(f.contains("buyerName", search), f.eq("status", status)),
18
- sort: "-createdAt",
19
- page,
20
- perPage: 20,
19
+ export const qk = createRegistry({
20
+ adapter: "drizzle-pg", // tailors `with` intellisense to the backend
21
+ defaults: { perPage: 20, sort: "-createdAt" },
21
22
  });
22
- const { data, meta } = (await http.post("/buyers/list", params)).data; // your own axios/fetch
23
- setMeta(mapMeta(meta));
23
+ ```
24
+
25
+ ```tsx
26
+ // any list page — the whole boilerplate collapses into one hook
27
+ import { useQuery } from "@tanstack/react-query";
28
+ import { useListParams } from "@querykitjs/web/react";
29
+ import { qk } from "@/lib/query";
30
+
31
+ const users = qk.resource<IUser>("users");
32
+ const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
33
+
34
+ function UsersPage() {
35
+ const { params, setParam, setPage } = useListParams(users, { schema: usersSchema });
36
+ const { data } = useQuery({
37
+ queryKey: users.keys.list(params),
38
+ queryFn: async () => users.parseList((await axios.post("/users/list", params)).data),
39
+ });
40
+ // data = { data: IUser[], meta: { totalPages, currentPage, hasNext, … } }
41
+ }
24
42
  ```
25
43
 
26
44
  ## Features
27
45
 
28
- - **Two filter styles** — `f.eq()...` builder AND object/array (`{key, operation, value}`). Fully compatible with legacy `IFilter[]`.
29
- - **Normalization** — empty-filter pruning, sort decoding. Values pass through unchanged.
30
- - **Declarative schema** — `defineListSchema` maps URL params → filters; kills per-page hand-building.
31
- - **URL-state sync** — `searchParams` ↔ params, `sortType` encode/decode, page-reset. `searchParams` is injected (router-agnostic).
32
- - **React hook** — `useListParams` (peer `react`, `./react` subpath).
33
- - **`mapMeta`** — snake→camel pagination meta.
34
- - **Zero dependency** — no axios/react-query/react-router/dayjs. `react` is an optional peer, only for the hook.
46
+ - **Registry** — configure `adapter` + defaults once; `resource<T>()` gives entity-typed, adapter-aware builders/filters/schema/keys/parsers.
47
+ - **Two filter styles** — `f.eq()...` builder (operators typed per field) AND object/array (`{key, operation, value}`). Fully compatible with legacy `IFilter[]`.
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.
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.
52
+ - **SSR** — `resource.fromSearchParams(sp, {schema})` builds a payload straight from `URLSearchParams`, no hook.
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.
35
55
 
36
56
  ## Install
37
57
 
38
58
  ```bash
39
59
  bun add @querykitjs/web
40
- # For the React hook, having react in your project (peer) is enough
60
+ # React hook: react (peer). `useListParams` also peers react-router-dom.
61
+ # The router-agnostic `useListParamsBase` needs no react-router-dom.
62
+ ```
63
+
64
+ ## Two ways to use it
65
+
66
+ ```ts
67
+ // (A) Registry — configure once, get entity-typed + adapter-aware builders
68
+ import { createRegistry } from "@querykitjs/web";
69
+
70
+ // (B) Standalone — free-form, always available
71
+ import { buildListParams, f } from "@querykitjs/web";
41
72
  ```
42
73
 
43
- ## Filters — two styles
74
+ Use (A) when you want defaults in one place, autocomplete, adapter `with`, query keys, meta parsing. Use (B) for one-off/free-form building.
75
+
76
+ ## `createRegistry(config)` — configure once
77
+
78
+ ```ts
79
+ export const qk = createRegistry({
80
+ adapter: "mongoose", // "mongoose" | "drizzle-pg" | "drizzle-sqlite" | "prisma-pg"
81
+ defaults: {
82
+ perPage: 20, // list (offset) page size
83
+ limit: 20, // infinite + cursor page size
84
+ sort: "-createdAt", // list + infinite ordering (leading "-" = desc)
85
+ cursor: { order: "asc" }, // cursor-only key traversal order
86
+ },
87
+ pruneEmpty: true, // drop empty filter values (""/null/undefined/[]); keep 0/false
88
+ });
89
+ ```
90
+
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` (`"-field"` string or `{name,direction}`). |
97
+ | `defaults.cursor.order` | Default cursor traversal order (`"asc"`/`"desc"`). |
98
+ | `pruneEmpty` | Auto-drop empty filter conditions built from form inputs (default `true`). |
99
+
100
+ Every default is overridable per call.
101
+
102
+ ## `qk.resource<TEntity>(name)` — the entity layer
103
+
104
+ ```ts
105
+ import type { IUser } from "@my/api-types";
106
+ export const users = qk.resource<IUser>("users"); // "users" = cache-key namespace (required, unique)
107
+ ```
108
+
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 |
122
+
123
+ All builders inherit the registry's `defaults` + `pruneEmpty`; all types are driven by `IUser` and the `adapter`.
124
+
125
+ > URL setters (`setParam`/`setPage`/`setSort`/`reset`) are config-free — use the standalone `url` helpers or the `useListParams` hook; there is no `users.url`.
126
+
127
+ ## Filters — `f` (field-typed) and object/array
44
128
 
45
129
  ```ts
46
130
  import { createFilters } from "@querykitjs/web";
47
- const f = createFilters<Buyer>(); // column-name autocomplete
131
+ const f = createFilters<Buyer>(); // or: const f = users.f;
48
132
 
49
- // 1) Builder
133
+ // 1) Builder — field-name autocomplete + operators typed per field
50
134
  filter: f.and(f.contains("buyerName", s), f.eq("status", st));
51
135
 
52
- // 2) Object/array (like idistr) — a flat array is implicit AND
136
+ // 2) Object/array (idistr-style) — flat array = implicit AND
53
137
  filter: [
54
138
  { key: "buyerName", operation: "%_%", value: s },
55
139
  { key: "status", operation: "=", value: st },
56
140
  ];
57
141
  ```
58
142
 
59
- **Operators:** `= != > >= < <=`, `%_%`/`contains`, `%_`/`startsWith`, `_%`/`endsWith`, `like`, `ilike`, `notLike`, `in`, `notIn`, `between`, `isNull`, `isNotNull` (same set as the querykit backend). Builder helpers: `f.eq/ne/gt/gte/lt/lte`, `f.contains/startsWith/endsWith`, `f.like/ilike/notLike`, `f.in/notIn`, `f.between`, `f.range` (date range → two conditions), `f.isNull/isNotNull`, `f.and/or/not`.
60
-
61
- ## Building the payload
143
+ **Operator-per-field-type:** `f.contains`/`like`/`ilike`/`startsWith`/... are offered only for **string** fields, `f.gt`/`gte`/`between`/... only for **comparable** (string/number/Date) fields:
62
144
 
63
145
  ```ts
64
- import { buildParams, buildListParams } from "@querykitjs/web";
146
+ f.contains("buyerName", s); // ✓ buyerName: string
147
+ f.gte("age", 18); // ✓ age: number
148
+ f.contains("age", "x"); // ✗ compile error — `contains` is string-only
149
+ ```
65
150
 
66
- const payload = buildListParams({
67
- filter, // builder or array
68
- sort: "-createdAt", // or { name, direction }
69
- page,
70
- perPage: 20,
71
- with: { supervisor: true }, // relations (default `with`)
72
- });
73
- // -> { filter:[...pruned], sort:{name,direction}, columns, with, page, perPage }
151
+ **Operators:** `= != > >= < <=`, `%_%`/`contains`, `%_`/`startsWith`, `_%`/`endsWith`, `like`, `ilike`, `notLike`, `in`, `notIn`, `between`, `isNull`, `isNotNull`. Builder helpers: `f.eq/ne/gt/gte/lt/lte`, `f.contains/startsWith/endsWith`, `f.like/ilike/notLike`, `f.in/notIn`, `f.between`, `f.range` (date range → two conditions), `f.isNull/isNotNull`, `f.and/or/not`.
152
+
153
+ **`search` preset** — one input, many columns (OR of contains):
154
+
155
+ ```ts
156
+ users.search("ali", ["buyerName", "email"]);
157
+ // → or(contains("buyerName","ali"), contains("email","ali"))
74
158
  ```
75
159
 
76
- Empty filters are dropped (`""`/`null`/`undefined`/`[]`), but `0`/`false` are kept. Values are sent through unchanged.
160
+ An untyped `f` is also exported: `import { f } from "@querykitjs/web"` (no field-type checks, quick use).
77
161
 
78
- ## Pagination modes (offset / infinite / cursor)
162
+ ## Builders — inputs and **what they return**
79
163
 
80
- Three builders matching the backend's three modes. Each mode's response meta is **different**, so there's a separate mapper for each:
164
+ These are **pure, synchronous** functions. They return a **payload object** (not a Promise, not data). You send it yourself.
81
165
 
82
166
  ```ts
83
- import { buildListParams, buildInfiniteParams, buildCursorParams, mapMeta, mapInfiniteMeta, mapCursorMeta } from "@querykitjs/web";
167
+ // list — offset (page)
168
+ users.list({ filter?, sort?, columns?, with?, withDeleted?, page?, perPage? });
169
+ // → { filter, sort:{name,direction}, columns, with, page, perPage, withDeleted? }
84
170
 
85
- // 1) Offset — page / perPage
86
- const p = buildListParams({ filter, page: 2, perPage: 20 });
87
- mapMeta(res.meta); // { totalPages, totalCount, currentPage, perPage, hasNext, hasPrev }
171
+ // infinite — limit/offset
172
+ users.infinite({ filter?, sort?, columns?, with?, withDeleted?, limit?, offset? });
173
+ // → { filter, sort, columns, with, limit, offset }
174
+
175
+ // cursor — NO sort, uses order + direction
176
+ users.cursor({ filter?, columns?, with?, withDeleted?, limit?, cursor?, order?, direction?, cursorKey? });
177
+ // → { filter, columns, with, limit, cursor, order, direction, cursorKey? }
178
+ ```
88
179
 
89
- // 2) Infinite — limit / offset
90
- const p = buildInfiniteParams({ filter, limit: 20, offset: 40 });
91
- mapInfiniteMeta(res.meta); // { limit, offset, count, hasMore, nextOffset }
180
+ Empty filter values are pruned (`""`/`null`/`undefined`/`[]`), but `0`/`false` are kept. Values pass through unchanged.
92
181
 
93
- // 3) Cursor — limit / cursor / order / direction (sort is ignored)
94
- const p = buildCursorParams({ filter, limit: 20, cursor, order: "asc" });
95
- mapCursorMeta(res.meta); // { limit, hasNext, hasPrev, nextCursor, prevCursor }
182
+ ## Pagination + `parse*` (meta)
96
183
 
97
- // withDeleted — include soft-deleted rows
98
- buildListParams({ filter, withDeleted: true });
184
+ Three builders for the backend's three modes. Each response meta is **different** (snake_case on the wire), so each has its own parser:
185
+
186
+ | Builder | Backend returns | `parse*` → camelCase |
187
+ | ---------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
188
+ | `list` | `{ data, meta: { total_items, total_pages, current_page, per_page, has_next, has_prev } }` | `{ totalCount, totalPages, currentPage, perPage, hasNext, hasPrev }` |
189
+ | `infinite` | `{ data, meta: { limit, offset, count, has_more, next_offset } }` | `{ limit, offset, count, hasMore, nextOffset }` |
190
+ | `cursor` | `{ data, meta: { limit, has_next, has_prev, next_cursor, prev_cursor } }` | `{ limit, hasNext, hasPrev, nextCursor, prevCursor }` |
191
+
192
+ ```ts
193
+ const r = users.parseList(res.data); // { data: IUser[], meta: { totalPages, currentPage, hasNext, … } }
194
+ users.parseInfinite(res.data); // { data, meta: { hasMore, nextOffset, … } }
195
+ users.parseCursor(res.data); // { data, meta: { nextCursor, hasNext, … } }
99
196
  ```
100
197
 
101
- ## Declarative list schema
198
+ `parse*` exists at two levels (one shared implementation — meta is entity-agnostic):
199
+ `qk.parseList/...` (registry, `data: unknown[]`) when you don't have a `resource`; `users.parseList/...` (entity-typed).
102
200
 
103
- Declare once instead of hand-building `IFilter[]` on each page:
201
+ ## Adapter-aware, wire-safe `with`
202
+
203
+ The registry types `with` per adapter — **serializable options only**:
204
+
205
+ | adapter | `with[rel]` intellisense | Hidden (not offered) |
206
+ | ------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
207
+ | `mongoose` | `boolean \| { select?, populate?, match?, options? }` | — |
208
+ | `drizzle-pg` / `drizzle-sqlite` | `boolean \| { columns?, with?, limit?, offset? }` | `where:(f,op)=>…`, `orderBy:(f,op)=>…`, raw `sql` |
209
+ | `prisma-pg` | `boolean \| { select?, include?, where?, orderBy?, take?, skip? }` | function forms |
104
210
 
105
211
  ```ts
106
- import { defineListSchema, searchParamsToPayload } from "@querykitjs/web";
212
+ // adapter: "mongoose"
213
+ users.list({ with: { author: { select: "name email" } } }); // ✓ `select`
214
+ // adapter: "drizzle-pg"
215
+ users.list({ with: { author: { columns: { name: true } } } }); // ✓ `columns`; `where`-fn hidden
216
+ ```
217
+
218
+ Runtime is unchanged (pass-through) — pure DX. Function/SQL forms aren't offered because you can't send them over JSON anyway.
107
219
 
108
- const buyersSchema = defineListSchema({
220
+ ## Declarative list schema — URL ↔ filter
221
+
222
+ Instead of hand-building `IFilter[]` on every page, declare it once. `useListParams` (and `schemaToFilter`) applies it: reads each param, applies the operation, prunes empties, fills defaults.
223
+
224
+ ```ts
225
+ const dispatchesSchema = dispatches.schema({
109
226
  id: { operation: "=" },
110
- buyerName: { operation: "%_%", trim: true },
111
- status: { operation: "=" },
112
- createdAt: { range: ["fromDate", "toDate"] }, // → >= and <=
227
+ status: { operation: "=", default: "active" }, // ?status absent → "active"
228
+ productIds: { operation: "in", split: "," }, // ?productIds=1,2,3 → in [1,2,3]
229
+ q: { search: ["name", "imei", "appNumber"] }, // ?q=… → contains-OR across fields
230
+ name: { operation: "%_%", trim: true }, // trims the value
231
+ createdAt: { range: ["fromDate", "toDate"] }, // two params → >= and <= (inclusive)
232
+ // price: { between: ["minPrice", "maxPrice"] }, // OR: one `between` condition (2-tuple)
113
233
  });
114
-
115
- const params = searchParamsToPayload(buyersSchema, searchParams);
116
234
  ```
117
235
 
236
+ ### FieldDescriptor modes
237
+
238
+ | Key | Meaning |
239
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
240
+ | `operation` | filter operation (`"="`, `"%_%"`, `"in"`, …). **Explicit** — no `true` shorthand. |
241
+ | `default` | value used when the URL param is **absent** (replaces manual `\|\| "active"`). |
242
+ | `split` | for array operations (`in`/`notIn`): split a comma-string into an array (default `","`). **Guarded** — absent/empty → pruned (never `[undefined]`). |
243
+ | `range: [from, to]` | two params → **two inclusive** conditions (`>= from`, `<= to`); each bound is independent. |
244
+ | `between: [from, to]` | two params → **one** `between` condition (2-tuple). **Requires both bounds** — one-sided → nothing (use `range` for independent bounds). |
245
+ | `trim` | trim the string value. |
246
+ | `search: [fields]` | one param → OR of `contains` across the listed fields. |
247
+
248
+ **Exactly one mode per descriptor** — Simple (`operation` + `trim`/`default`/`split`), `range`, `between`, `search` are **mutually exclusive** and enforced at compile time (mixing e.g. `range` + `search` is a type error).
249
+
250
+ Empty/**absent** params are pruned; `default` fills only when the param is **absent** (an empty `?x=` means "cleared" → pruned, not defaulted).
251
+
118
252
  ## React hook
119
253
 
120
254
  ```tsx
121
- import { useSearchParams } from "react-router-dom"; // or any source
122
- import { useListParams } from "@querykitjs/web/react";
255
+ import { useListParams } from "@querykitjs/web/react"; // uses react-router-dom internally
256
+ import { qk } from "@/lib/query";
257
+
258
+ const users = qk.resource<IUser>("users");
259
+ const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
123
260
 
124
- function BuyersList() {
125
- const [searchParams, setSearchParams] = useSearchParams();
126
- const { params, setParam, setPage, setSort } = useListParams({
127
- searchParams,
128
- setSearchParams,
129
- schema: buyersSchema,
261
+ function UsersListPage() {
262
+ // reads ?page/?size/?sortType/?name/... from the URL (useSearchParams inside)
263
+ const { params, setParam, setPage, setSort, reset } = useListParams(users, {
264
+ schema: usersSchema,
265
+ with: { manager: true }, // fixed relations (not URL-driven)
130
266
  });
131
267
 
132
- const { data, meta } = useMyList(params); // your own fetch/react-query
268
+ const q = useQuery({
269
+ queryKey: users.keys.list(params),
270
+ queryFn: async () => users.parseList((await axios.post("/users/list", params)).data),
271
+ placeholderData: prev => prev, // keep previous page while fetching
272
+ });
133
273
 
134
- // setParam("status", "active") -> updates URL, resets page
135
- // setPage(2), setSort("-createdAt")
274
+ // setParam("status","active") → updates URL, resets page; setPage(2); setSort("-createdAt")
136
275
  }
137
276
  ```
138
277
 
139
- `searchParams` (`URLSearchParams`) and `setSearchParams` are injected — the library is not tied to `react-router`.
278
+ An input/sort/page change mutates the URL → `params` changes → `queryKey` changes → TanStack refetches (and caches each combo).
279
+
280
+ **Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router-dom):
281
+
282
+ ```tsx
283
+ import { useListParamsBase } from "@querykitjs/web/react";
284
+ const { params, setPage } = useListParamsBase(users, { schema: usersSchema, searchParams, setSearchParams });
285
+ ```
140
286
 
141
- ## You send the request
287
+ ## SSR — `fromSearchParams` (no hook)
142
288
 
143
- The library only handles the work **up to** the request. No transport:
289
+ For server components / `getServerSideProps` — a payload straight from the URL:
144
290
 
145
291
  ```ts
146
- // fetch
147
- const res = await fetch("/api/buyers/list", {
148
- method: "POST",
149
- headers: { "Content-Type": "application/json" },
150
- body: JSON.stringify(params),
151
- });
152
- const { data, meta } = await res.json();
292
+ // Next.js server component
293
+ export default async function Page({ searchParams }) {
294
+ const sp = new URLSearchParams(searchParams);
295
+ const body = users.fromSearchParams(sp, { schema: usersSchema });
296
+ const res = await fetch(`${API}/users/list`, { method: "POST", body: JSON.stringify(body) });
297
+ const { data, meta } = users.parseList(await res.json());
298
+ }
299
+ ```
300
+
301
+ ## Query keys — `users.keys`
153
302
 
154
- // axios
155
- const { data } = await http.post("/buyers/list", params);
156
- setMeta(mapMeta(data.meta));
303
+ Stable, structured keys for TanStack Query / SWR caches:
304
+
305
+ ```ts
306
+ users.keys.all; // ["users"]
307
+ users.keys.list(payload); // ["users", "list", payload]
308
+ users.keys.infinite(payload); // ["users", "infinite", payload]
309
+ users.keys.cursor(payload); // ["users", "cursor", payload]
310
+ users.keys.detail(id); // ["users", "detail", id]
157
311
  ```
158
312
 
159
- ## Other / legacy backends (custom field names)
313
+ ## Full examples
160
314
 
161
- Defaults are querykit-canonical camelCase (`perPage`, `with`). For a legacy snake-case backend (e.g. idistr), configure it:
315
+ ### axios — offset / infinite / cursor
316
+
317
+ ```ts
318
+ import { qk } from "@/lib/query";
319
+ const users = qk.resource<IUser>("users");
320
+
321
+ // offset
322
+ const listRes = await axios.post("/users/list", users.list({ filter: users.f.eq("status", "active"), page }));
323
+ const { data, meta } = users.parseList(listRes.data); // meta.currentPage, meta.totalPages…
324
+
325
+ // infinite
326
+ const infRes = await axios.post("/users/infinite", users.infinite({ limit: 20, offset }));
327
+ const inf = users.parseInfinite(infRes.data); // inf.meta.hasMore, inf.meta.nextOffset
328
+
329
+ // cursor
330
+ const curRes = await axios.post("/users/cursor", users.cursor({ limit: 20, cursor }));
331
+ const cur = users.parseCursor(curRes.data); // cur.meta.nextCursor, cur.meta.hasNext
332
+ ```
333
+
334
+ ### TanStack `useQuery` (offset)
335
+
336
+ ```tsx
337
+ import { useQuery } from "@tanstack/react-query";
338
+ const users = qk.resource<IUser>("users");
339
+
340
+ function useUsers(filters: { status?: string; search?: string }, page: number) {
341
+ const body = users.list({
342
+ filter: [users.f.eq("status", filters.status), users.f.contains("name", filters.search)],
343
+ sort: "-createdAt",
344
+ page,
345
+ });
346
+ return useQuery({
347
+ queryKey: users.keys.list(body), // stable cache key
348
+ queryFn: async () => users.parseList((await axios.post("/users/list", body)).data),
349
+ });
350
+ // data = { data: IUser[], meta: { totalPages, currentPage, hasNext, … } }
351
+ }
352
+ ```
353
+
354
+ ### Infinite scroll — `useInfiniteQuery`
355
+
356
+ ```tsx
357
+ import { useInfiniteQuery } from "@tanstack/react-query";
358
+ const feed = qk.resource<IPost>("feed");
359
+
360
+ function useFeed(filter: IPostFilterInput) {
361
+ return useInfiniteQuery({
362
+ queryKey: feed.keys.infinite({ filter }),
363
+ initialPageParam: 0,
364
+ queryFn: async ({ pageParam }) => feed.parseInfinite((await axios.post("/feed/infinite", feed.infinite({ filter, offset: pageParam, limit: 20 }))).data),
365
+ getNextPageParam: last => (last.meta.hasMore ? last.meta.nextOffset : undefined),
366
+ });
367
+ // const rows = data?.pages.flatMap(p => p.data) ?? [];
368
+ }
369
+ ```
370
+
371
+ ### Cursor (keyset) — `useInfiniteQuery`, bidirectional
372
+
373
+ ```tsx
374
+ const feed = qk.resource<IPost>("feed");
375
+
376
+ function useCursorFeed(filter: IPostFilterInput) {
377
+ return useInfiniteQuery({
378
+ queryKey: feed.keys.cursor({ filter }),
379
+ initialPageParam: null as string | null,
380
+ queryFn: async ({ pageParam, direction }) =>
381
+ feed.parseCursor(
382
+ (
383
+ await axios.post(
384
+ "/feed/cursor",
385
+ feed.cursor({
386
+ filter,
387
+ cursor: pageParam,
388
+ limit: 20,
389
+ order: "asc", // fixed feed order
390
+ direction: direction === "backward" ? "backward" : "forward",
391
+ }),
392
+ )
393
+ ).data,
394
+ ),
395
+ getNextPageParam: last => (last.meta.hasNext ? last.meta.nextCursor : undefined),
396
+ getPreviousPageParam: first => (first.meta.hasPrev ? first.meta.prevCursor : undefined),
397
+ });
398
+ }
399
+ ```
400
+
401
+ `order` is the stable feed order (constant); TanStack's forward/backward maps to the payload's `direction`, and the cursor tokens (`nextCursor`/`prevCursor`) come from the mapped meta.
402
+
403
+ ## Standalone (no registry) — free-form
404
+
405
+ A registry is optional — the builders work directly:
406
+
407
+ ```ts
408
+ import { buildListParams, buildInfiniteParams, buildCursorParams, f } from "@querykitjs/web";
409
+
410
+ const body = buildListParams({
411
+ filter: f.and(f.contains("name", search), f.eq("status", status)),
412
+ sort: "-createdAt",
413
+ page,
414
+ perPage: 20,
415
+ with: { supervisor: true },
416
+ });
417
+ const res = await axios.post("/users/list", body);
418
+ const { data, meta } = qk.parseList(res.data); // registry parse* for meta (entity-agnostic)
419
+ ```
420
+
421
+ `createQuery(config)` — for defaults/`pruneEmpty` without a registry. Wire field names are fixed by the querykit contract (`filter`/`sort`/`columns`/`with`/`page`/`perPage`/`limit`/`offset`/`cursor`/`withDeleted`).
162
422
 
163
423
  ```ts
164
424
  import { createQuery } from "@querykitjs/web";
425
+ const q = createQuery({ defaultPerPage: 20, pruneEmpty: true });
426
+ const params = q.list({ filter, page });
427
+ ```
165
428
 
166
- const q = createQuery({ perPageField: "per_page", withField: "withPopulates" });
167
- const params = q.list({ filter, page, perPage });
429
+ ## You send the request yourself
430
+
431
+ The library works only **up to the request**. No transport:
432
+
433
+ ```ts
434
+ const res = await fetch("/api/users/list", {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify(params),
438
+ });
439
+ const { data, meta } = users.parseList(await res.json());
168
440
  ```
169
441
 
170
442
  ## API reference
171
443
 
172
- | Function | Purpose |
173
- | ---------------------------------------------- | -------------------------------------- |
174
- | `createFilters<T>()` / `f` | typed / untyped filter builder |
175
- | `buildParams(input)` | normalize params (no pagination) |
176
- | `buildListParams(input)` | + `page`/`perPage` (offset) |
177
- | `buildInfiniteParams(input)` | + `limit`/`offset` (infinite) |
178
- | `buildCursorParams(input)` | + `limit`/`cursor`/`order`/`direction` |
179
- | `createQuery(config)` | builder with custom field names |
180
- | `defineListSchema(schema)` | URL param → filter descriptor |
181
- | `schemaToFilter(schema, sp)` | searchParams → `FieldCondition[]` |
182
- | `searchParamsToPayload(schema, sp)` | searchParams → full payload |
183
- | `readListParams(schema, sp)` | searchParams → `ListParams` |
184
- | `setParam/setPage/setSize/setSort/resetParams` | URL writes (immutable, page-reset) |
185
- | `encodeSort/decodeSort` | `{name,direction}` ↔ `"-createdAt"` |
186
- | `mapMeta(raw)` | offset meta → camel |
187
- | `mapInfiniteMeta(raw)` | infinite meta → camel |
188
- | `mapCursorMeta(raw)` | cursor meta → camel |
189
- | `useListParams(opts)` (`/react`) | URL-sync hook |
444
+ | Function / member | What it does |
445
+ | --------------------------------------------------------------------------------- | ------------------------------------------------------- |
446
+ | `createRegistry(config)` | registry with adapter + defaults |
447
+ | `registry.resource<T>(name)` | entity-typed builders/filters/schema/keys/parsers |
448
+ | `resource.list/infinite/cursor/params(input)` | build a payload (offset/infinite/cursor/paginationless) |
449
+ | `resource.f` / `createFilters<T>()` / `f` | typed filter builder / typed / untyped |
450
+ | `resource.search(term, fields)` | OR-of-contains preset |
451
+ | `resource.schema(desc)` / `defineListSchema(desc)` | URL param → filter descriptor |
452
+ | `resource.fromSearchParams(sp, {schema})` | searchParams → list payload (SSR) |
453
+ | `resource.keys` | TanStack/SWR query keys |
454
+ | `resource.parseList/parseInfinite/parseCursor` | response meta → camelCase |
455
+ | `qk.parseList/parseInfinite/parseCursor` | registry-level parse (entity-agnostic) |
456
+ | `buildParams / buildListParams / buildInfiniteParams / buildCursorParams` | standalone builders |
457
+ | `createQuery(config)` | builder without a registry (defaults/pruneEmpty) |
458
+ | `schemaToFilter(schema, sp)` | searchParams → `FieldCondition[]` |
459
+ | `searchParamsToPayload(schema, sp)` | searchParams → full payload |
460
+ | `readListParams(schema, sp)` | searchParams → `ListParams` |
461
+ | `setParam/setPage/setSize/setSort/resetParams` | write URL (immutable, page-reset) |
462
+ | `encodeSort/decodeSort` | `{name,direction}` ↔ `"-createdAt"` |
463
+ | `useListParams(resource, {schema})` (`/react`) | URL-sync hook (react-router-dom) |
464
+ | `useListParamsBase(resource, {schema, searchParams, setSearchParams})` (`/react`) | router-agnostic hook |
190
465
 
191
466
  ## Development
192
467