@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 +379 -104
- package/README.md +377 -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,462 @@
|
|
|
6
6
|
|
|
7
7
|
# @querykitjs/web
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/@querykitjs/web) [](./LICENSE)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
16
|
+
// src/lib/query.ts — configure once
|
|
17
|
+
import { createRegistry } from "@querykitjs/web";
|
|
15
18
|
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
-
|
|
34
|
-
- **
|
|
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
|
-
#
|
|
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
|
-
|
|
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>(); //
|
|
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 (
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
160
|
+
An untyped `f` is also exported: `import { f } from "@querykitjs/web"` (no field-type checks, quick use).
|
|
77
161
|
|
|
78
|
-
##
|
|
162
|
+
## Builders — inputs and **what they return**
|
|
79
163
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
122
|
-
import {
|
|
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
|
|
125
|
-
|
|
126
|
-
const { params, setParam, setPage, setSort } = useListParams({
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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",
|
|
135
|
-
// setPage(2), setSort("-createdAt")
|
|
274
|
+
// setParam("status","active") → updates URL, resets page; setPage(2); setSort("-createdAt")
|
|
136
275
|
}
|
|
137
276
|
```
|
|
138
277
|
|
|
139
|
-
|
|
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
|
-
##
|
|
287
|
+
## SSR — `fromSearchParams` (no hook)
|
|
142
288
|
|
|
143
|
-
|
|
289
|
+
For server components / `getServerSideProps` — a payload straight from the URL:
|
|
144
290
|
|
|
145
291
|
```ts
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
body: JSON.stringify(
|
|
151
|
-
});
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
##
|
|
313
|
+
## Full examples
|
|
160
314
|
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
173
|
-
|
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
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
|
|