@querykitjs/web 3.0.0 → 4.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 +130 -31
- package/README.md +130 -31
- package/dist/index.cjs +1 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -3
- package/dist/index.d.ts +39 -3
- package/dist/index.js +1 -331
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +1 -147
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +11 -4
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +1 -144
- package/dist/react/index.js.map +1 -1
- package/dist/{registry-DbZQk28q.d.cts → registry-DOVi_zCU.d.cts} +78 -11
- package/dist/{registry-DbZQk28q.d.ts → registry-DOVi_zCU.d.ts} +78 -11
- package/package.json +39 -15
- package/skills/querykit-web/SKILL.md +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@querykitjs/web",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Frontend query-building for querykit — typed filters, pagination params & URL sync. Zero-dependency; you send the request.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": {
|
|
@@ -23,18 +23,36 @@
|
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
"import": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./dist/index.d.cts",
|
|
32
|
+
"default": "./dist/index.cjs"
|
|
33
|
+
}
|
|
29
34
|
},
|
|
30
35
|
"./react": {
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
"import": {
|
|
37
|
+
"types": "./dist/react/index.d.ts",
|
|
38
|
+
"default": "./dist/react/index.js"
|
|
39
|
+
},
|
|
40
|
+
"require": {
|
|
41
|
+
"types": "./dist/react/index.d.cts",
|
|
42
|
+
"default": "./dist/react/index.cjs"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"typesVersions": {
|
|
47
|
+
"*": {
|
|
48
|
+
"react": [
|
|
49
|
+
"./dist/react/index.d.ts"
|
|
50
|
+
]
|
|
34
51
|
}
|
|
35
52
|
},
|
|
36
53
|
"files": [
|
|
37
54
|
"dist",
|
|
55
|
+
"skills/querykit-web",
|
|
38
56
|
"README.md",
|
|
39
57
|
"README.en.md",
|
|
40
58
|
"LICENSE"
|
|
@@ -43,31 +61,37 @@
|
|
|
43
61
|
"publishConfig": {
|
|
44
62
|
"access": "public"
|
|
45
63
|
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=22.12"
|
|
66
|
+
},
|
|
46
67
|
"scripts": {
|
|
47
68
|
"build": "tsup",
|
|
48
69
|
"dev": "tsup --watch",
|
|
49
70
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"test:smoke": "bun run test/smoke.ts"
|
|
71
|
+
"test:smoke": "bun run test/smoke.ts",
|
|
72
|
+
"test:hook": "bun run test/hook.ts"
|
|
51
73
|
},
|
|
52
74
|
"dependencies": {
|
|
53
|
-
"@querykitjs/core": "^
|
|
75
|
+
"@querykitjs/core": "^2.0.0"
|
|
54
76
|
},
|
|
55
77
|
"peerDependencies": {
|
|
56
|
-
"react": "
|
|
57
|
-
"react-router
|
|
78
|
+
"react": "^18.3 || ^19.2.7",
|
|
79
|
+
"react-router": "^7.0 || ^8.0"
|
|
58
80
|
},
|
|
59
81
|
"peerDependenciesMeta": {
|
|
60
82
|
"react": {
|
|
61
83
|
"optional": true
|
|
62
84
|
},
|
|
63
|
-
"react-router
|
|
85
|
+
"react-router": {
|
|
64
86
|
"optional": true
|
|
65
87
|
}
|
|
66
88
|
},
|
|
67
89
|
"devDependencies": {
|
|
68
|
-
"@types/react": "
|
|
69
|
-
"react": "
|
|
70
|
-
"react
|
|
90
|
+
"@types/react": "19.3.0",
|
|
91
|
+
"@types/react-dom": "19.3.0",
|
|
92
|
+
"react": "19.3.1",
|
|
93
|
+
"react-dom": "19.3.1",
|
|
94
|
+
"react-router": "8.5.1"
|
|
71
95
|
},
|
|
72
96
|
"keywords": [
|
|
73
97
|
"querykit",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: querykit-web
|
|
3
|
+
description: Use when frontend code builds or reads a querykit list request (@querykitjs/web, @querykitjs/web/react) - createRegistry({ adapter }), qk.resource<T>(name), f filters, sort, list / infinite / cursor payloads, withTotal, parseList / parseInfinite / parseCursor, search, adapter-aware with, defineListSchema and URL helpers (readListParams, setPage, setSort), useListParams / useListParamsBase (clamped), fromSearchParams for SSR, query keys, or reading a 400 with isQueryKitError.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Safety rules
|
|
7
|
+
|
|
8
|
+
- DO treat the server as the authority: `filterable`, `sortable` and the filter limits live in the backend repository and its validator. The web package does not repeat them, so a refused filter comes back as a 400.
|
|
9
|
+
- DON'T write `page` / `perPage` / `limit` math by hand. Build every payload with `resource.list` / `infinite` / `cursor` (or `buildListParams` …): `perPage` and `limit` are clamped to `200`, and an offset past `maxOffset` (`10000`) or more than `maxSortFields` (`3`) sort fields throws an `Error`. Use a cursor for deep pages.
|
|
10
|
+
- DO read list state from the URL with `readListParams`, `fromSearchParams` or `useListParams`: they never throw, they clamp a too-deep `page` or too-long `sort` and report it as `clamped: true` (`clamped` is not sent to the server).
|
|
11
|
+
- DON'T send `columns`, `with` or `withDeleted` unless the backend opened them on purpose (validator `allow`, repository `relations`). In `with`, name only the relations and columns the backend lists in `relations`; anything else is `forbidden-key`.
|
|
12
|
+
- DO send dates as ISO strings: the wire is JSON.
|
|
13
|
+
- DO read a 400 body with `isQueryKitError(body)` and use its `reason`, `site` and `key` (for example, mark the field named by `key`).
|
|
14
|
+
- DO handle `withTotal: false`: `meta.totalCount` and `meta.totalPages` are then `null`, so show "next" / "previous" from `hasNext` / `hasPrev` instead of a page count.
|
|
15
|
+
|
|
16
|
+
## When to use
|
|
17
|
+
|
|
18
|
+
- A browser or SSR frontend (React, Next.js, any fetch client) that talks to a querykit backend.
|
|
19
|
+
- Building list, infinite-scroll or cursor request bodies, keeping filters in the URL, and caching with TanStack Query or SWR.
|
|
20
|
+
|
|
21
|
+
The package builds the payload only. You send the request yourself.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createRegistry } from "@querykitjs/web";
|
|
27
|
+
|
|
28
|
+
interface User {
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
status: "active" | "blocked";
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const qk = createRegistry({
|
|
36
|
+
adapter: "drizzle-pg", // "mongoose" | "drizzle-pg" | "drizzle-sqlite" | "prisma-pg" — types `with` only
|
|
37
|
+
defaults: { perPage: 20, limit: 20 },
|
|
38
|
+
timestamps: { createdAt: "createdAt" }, // same name as the backend; the default sort is this field, desc
|
|
39
|
+
});
|
|
40
|
+
export const users = qk.resource<User>("users"); // the name is the cache-key namespace
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Common tasks
|
|
44
|
+
|
|
45
|
+
### Filter, sort and an offset page
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createRegistry } from "@querykitjs/web";
|
|
49
|
+
|
|
50
|
+
const users = createRegistry({ adapter: "prisma-pg" }).resource<{ id: number; name: string; age: number; createdAt: string }>("users");
|
|
51
|
+
const { f } = users;
|
|
52
|
+
|
|
53
|
+
const body = users.list({
|
|
54
|
+
filter: f.and(f.gte("age", 18), f.or(f.contains("name", "ali"), f.isNull("age"))),
|
|
55
|
+
sort: [
|
|
56
|
+
{ key: "name", direction: "asc" },
|
|
57
|
+
{ key: "createdAt", direction: "desc" },
|
|
58
|
+
],
|
|
59
|
+
page: 2,
|
|
60
|
+
perPage: 20,
|
|
61
|
+
withTotal: false, // the server skips the count
|
|
62
|
+
});
|
|
63
|
+
const res = await fetch("/api/users/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
64
|
+
const { data, meta } = users.parseList(await res.json());
|
|
65
|
+
console.log(data.length, meta.totalCount ?? "unknown", meta.hasNext);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Infinite scroll and cursor payloads
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createRegistry } from "@querykitjs/web";
|
|
72
|
+
|
|
73
|
+
const posts = createRegistry({ adapter: "mongoose" }).resource<{ _id: string; title: string; createdAt: string }>("posts");
|
|
74
|
+
|
|
75
|
+
const feedBody = posts.infinite({ limit: 20, offset: 40 });
|
|
76
|
+
const firstPage = posts.cursor({ limit: 20, order: "desc", cursorKey: "createdAt" }); // no sort on a cursor
|
|
77
|
+
declare const raw: unknown;
|
|
78
|
+
const feed = posts.parseInfinite(raw as Parameters<typeof posts.parseInfinite>[0]);
|
|
79
|
+
const page = posts.parseCursor(raw as Parameters<typeof posts.parseCursor>[0]);
|
|
80
|
+
const nextBody = posts.cursor({ limit: 20, order: "desc", cursorKey: "createdAt", cursor: page.meta.nextCursor });
|
|
81
|
+
console.log(feedBody, firstPage, feed.meta.hasMore, feed.meta.nextOffset, nextBody);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Search box across several fields
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { createRegistry } from "@querykitjs/web";
|
|
88
|
+
|
|
89
|
+
const users = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; name: string; email: string }>("users");
|
|
90
|
+
|
|
91
|
+
const filter = users.search("ali", ["name", "email"], { operation: "startsWith" }); // OR of startsWith
|
|
92
|
+
const body = users.list({ filter, page: 1 });
|
|
93
|
+
console.log(body);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Relations with an adapter-aware `with`
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createRegistry } from "@querykitjs/web";
|
|
100
|
+
|
|
101
|
+
const posts = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; title: string }>("posts");
|
|
102
|
+
|
|
103
|
+
// only relations and columns the backend lists in `relations`
|
|
104
|
+
const body = posts.list({ with: { author: { columns: { name: true } } }, page: 1 });
|
|
105
|
+
console.log(body.with);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Filters in the URL
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { defineListSchema, readListParams, setPage, setSort } from "@querykitjs/web";
|
|
112
|
+
|
|
113
|
+
const schema = defineListSchema({
|
|
114
|
+
status: { operation: "=", default: "active" },
|
|
115
|
+
q: { search: ["name", "email"] },
|
|
116
|
+
createdAt: { range: ["fromDate", "toDate"] },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const read = readListParams(schema, new URLSearchParams("page=9999&size=20&q=ali"));
|
|
120
|
+
console.log(read.page, read.clamped); // 501 true — clamped, never thrown
|
|
121
|
+
|
|
122
|
+
let sp = new URLSearchParams(location.search);
|
|
123
|
+
sp = setPage(sp, 2);
|
|
124
|
+
sp = setSort(sp, [{ key: "name", direction: "asc" }]); // resets the page
|
|
125
|
+
history.replaceState(null, "", `?${sp.toString()}`);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### React list page with `useListParams`
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { createRegistry } from "@querykitjs/web";
|
|
132
|
+
import { useListParams } from "@querykitjs/web/react";
|
|
133
|
+
|
|
134
|
+
const users = createRegistry({ adapter: "mongoose" }).resource<{ _id: string; name: string; status: string }>("users");
|
|
135
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
136
|
+
|
|
137
|
+
export function UsersListPage() {
|
|
138
|
+
const { params, clamped, setParam, setPage } = useListParams(users, { schema: usersSchema });
|
|
139
|
+
if (clamped) setPage(params.page ?? 1); // fix a hand-edited URL
|
|
140
|
+
return (
|
|
141
|
+
<div>
|
|
142
|
+
<input placeholder="Name" onChange={event => setParam("name", event.target.value)} />
|
|
143
|
+
<pre>{JSON.stringify(users.keys.list(params))}</pre>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### SSR payload and a refused request
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { createRegistry, isQueryKitError } from "@querykitjs/web";
|
|
153
|
+
|
|
154
|
+
const users = createRegistry({ adapter: "prisma-pg" }).resource<{ id: number; name: string }>("users");
|
|
155
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true } });
|
|
156
|
+
|
|
157
|
+
export async function loadUsers(searchParams: URLSearchParams) {
|
|
158
|
+
const body = users.fromSearchParams(searchParams, { schema: usersSchema });
|
|
159
|
+
const res = await fetch("https://api.example.com/users/list", { method: "POST", body: JSON.stringify(body) });
|
|
160
|
+
const json: unknown = await res.json();
|
|
161
|
+
if (res.status === 400) {
|
|
162
|
+
const error = (json as { error?: unknown }).error;
|
|
163
|
+
if (isQueryKitError(error)) return { fieldError: { key: error.key, reason: error.reason, site: error.site } };
|
|
164
|
+
}
|
|
165
|
+
return users.parseList(json as Parameters<typeof users.parseList>[0]);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Request contract
|
|
170
|
+
|
|
171
|
+
The same JSON shape as every querykit package (`@querykitjs/core`): conditions `{ key, operation?, value? }`, groups `{ and }` / `{ or }` / `{ not }` (a flat array = AND), `sort` always `[{ key, direction? }]`, offset `page` / `perPage` / `withTotal`, infinite `limit` / `offset`, cursor `limit` / `cursor` / `cursorKey` / `order` / `direction` (no `sort`). Meta is snake_case: `total_items`, `total_pages`, `has_next`, `has_more`, `next_offset`, `next_cursor`, `prev_cursor`. The body types are `WireOffsetParams`, `WireInfiniteParams` and `WireCursorParams`; validate with `@querykitjs/zod` or `@querykitjs/class-validator`, then pass the body as is. Refusals are `QueryKitError` with `{ source, site, key, operation?, reason }`.
|
|
172
|
+
|
|
173
|
+
`parseList` / `parseInfinite` / `parseCursor` map that meta to camelCase: `totalCount`, `totalPages`, `currentPage`, `perPage`, `hasNext`, `hasPrev`; `limit`, `offset`, `count`, `hasMore`, `nextOffset`; `nextCursor`, `prevCursor`.
|
|
174
|
+
|
|
175
|
+
## Mistakes to avoid
|
|
176
|
+
|
|
177
|
+
- Expecting `list` / `infinite` / `cursor` to fetch: they return a plain payload object, synchronously.
|
|
178
|
+
- Passing `sort` to `cursor`: cursor pages order by `cursorKey` with `order` and `direction`.
|
|
179
|
+
- Computing `meta.totalCount + 1` without a `null` check: it is `null` after `withTotal: false`.
|
|
180
|
+
- Validating filter size in the browser: depth, condition count and string length are checked by the server only.
|
|
181
|
+
- Using `useListParams` outside react-router: use `useListParamsBase` with your own `searchParams` and `setSearchParams`.
|