@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@querykitjs/web",
3
- "version": "3.0.0",
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
- "types": "./dist/index.d.ts",
27
- "import": "./dist/index.js",
28
- "require": "./dist/index.cjs"
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
- "types": "./dist/react/index.d.ts",
32
- "import": "./dist/react/index.js",
33
- "require": "./dist/react/index.cjs"
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": "^1.1.0"
75
+ "@querykitjs/core": "^2.0.0"
54
76
  },
55
77
  "peerDependencies": {
56
- "react": ">=18",
57
- "react-router-dom": ">=6"
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-dom": {
85
+ "react-router": {
64
86
  "optional": true
65
87
  }
66
88
  },
67
89
  "devDependencies": {
68
- "@types/react": "^19.0.0",
69
- "react": "^19.0.0",
70
- "react-router-dom": "^7.0.0"
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`.