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