@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 +377 -104
- package/README.md +375 -102
- package/dist/index.cjs +80 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -70
- package/dist/index.d.ts +3 -70
- package/dist/index.js +81 -17
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +51 -105
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +40 -19
- package/dist/react/index.d.ts +40 -19
- package/dist/react/index.js +51 -106
- package/dist/react/index.js.map +1 -1
- package/dist/registry-DPIEOvLW.d.cts +417 -0
- package/dist/registry-DPIEOvLW.d.ts +417 -0
- package/package.json +9 -4
- package/dist/url-BpslCTRx.d.cts +0 -180
- package/dist/url-BpslCTRx.d.ts +0 -180
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
|
|
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
|
-
|
|
14
|
+
// src/lib/query.ts — configure once
|
|
15
|
+
import { createRegistry } from "@querykitjs/web";
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
-
|
|
34
|
-
- **
|
|
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
|
-
#
|
|
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
|
-
|
|
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>(); //
|
|
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 (
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
158
|
+
An untyped `f` is also exported: `import { f } from "@querykitjs/web"` (no field-type checks, quick use).
|
|
77
159
|
|
|
78
|
-
##
|
|
160
|
+
## Builders — inputs and **what they return**
|
|
79
161
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
122
|
-
import {
|
|
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
|
|
125
|
-
|
|
126
|
-
const { params, setParam, setPage, setSort } = useListParams({
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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",
|
|
135
|
-
// setPage(2), setSort("-createdAt")
|
|
272
|
+
// setParam("status","active") → updates URL, resets page; setPage(2); setSort("-createdAt")
|
|
136
273
|
}
|
|
137
274
|
```
|
|
138
275
|
|
|
139
|
-
|
|
276
|
+
An input/sort/page change mutates the URL → `params` changes → `queryKey` changes → TanStack refetches (and caches each combo).
|
|
140
277
|
|
|
141
|
-
|
|
278
|
+
**Router-agnostic** (Next.js, TanStack Router) — pass `searchParams` to the base hook (no react-router-dom):
|
|
142
279
|
|
|
143
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
body: JSON.stringify(
|
|
151
|
-
});
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
369
|
+
### Cursor (keyset) — `useInfiniteQuery`, bidirectional
|
|
160
370
|
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
173
|
-
|
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
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
|
|