@hypequery/react 0.1.1 → 0.2.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.md +204 -275
- package/dist/analyticsHooks.d.ts +34 -0
- package/dist/analyticsHooks.d.ts.map +1 -0
- package/dist/analyticsHooks.js +27 -0
- package/dist/analyticsHooks.test.d.ts +2 -0
- package/dist/analyticsHooks.test.d.ts.map +1 -0
- package/dist/analyticsHooks.test.js +98 -0
- package/dist/createHooks.d.ts +31 -2
- package/dist/createHooks.d.ts.map +1 -1
- package/dist/createHooks.js +89 -20
- package/dist/createHooks.test.js +189 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,377 +1,306 @@
|
|
|
1
1
|
# @hypequery/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React hooks for typed Hypequery HTTP endpoints, built on TanStack Query.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Use `@hypequery/react` with APIs created by `@hypequery/serve`. The hooks call generated routes and infer input/output types from the API type you pass in.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npm install @hypequery/react @tanstack/react-query
|
|
11
|
+
# or
|
|
12
|
+
pnpm add @hypequery/react @tanstack/react-query
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
Peer dependencies: `react@^18`, `@tanstack/react-query@^5`.
|
|
12
|
-
|
|
13
15
|
## Quick Start
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
// lib/analytics.ts
|
|
17
|
-
import { createHooks } from '@hypequery/react';
|
|
18
|
-
import type { Api } from '@/analytics/queries';
|
|
17
|
+
Given a serve API:
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
```ts
|
|
20
|
+
// server/api.ts
|
|
21
|
+
import { initServe } from '@hypequery/serve';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { db } from './db.js';
|
|
24
|
+
|
|
25
|
+
const { query, serve } = initServe({
|
|
26
|
+
context: () => ({ db }),
|
|
27
|
+
basePath: '/api/analytics',
|
|
22
28
|
});
|
|
23
|
-
```
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
const weeklyRevenue = query({
|
|
31
|
+
description: 'Weekly revenue',
|
|
32
|
+
input: z.object({ startDate: z.string() }),
|
|
33
|
+
query: ({ ctx, input }) =>
|
|
34
|
+
ctx.db
|
|
35
|
+
.table('orders')
|
|
36
|
+
.where('created_at', 'gte', input.startDate)
|
|
37
|
+
.sum('total', 'revenue')
|
|
38
|
+
.execute(),
|
|
39
|
+
});
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
export const api = serve({
|
|
42
|
+
queries: { weeklyRevenue },
|
|
43
|
+
});
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
api.route('/weeklyRevenue', api.queries.weeklyRevenue);
|
|
31
46
|
|
|
32
|
-
export
|
|
33
|
-
return (
|
|
34
|
-
<QueryClientProvider client={queryClient}>
|
|
35
|
-
{children}
|
|
36
|
-
</QueryClientProvider>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
47
|
+
export type AnalyticsApi = typeof api;
|
|
39
48
|
```
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
Create hooks on the client:
|
|
42
51
|
|
|
43
52
|
```tsx
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Mutation
|
|
48
|
-
const rebuild = useMutation('rebuildMetrics');
|
|
49
|
-
rebuild.mutate({ force: true });
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## API Reference
|
|
55
|
-
|
|
56
|
-
### `createHooks<Api>(config)`
|
|
57
|
-
|
|
58
|
-
Factory function that creates type-safe `useQuery` and `useMutation` hooks for your API.
|
|
59
|
-
|
|
60
|
-
**Parameters:**
|
|
61
|
-
|
|
62
|
-
```ts
|
|
63
|
-
interface CreateHooksConfig<TApi> {
|
|
64
|
-
baseUrl: string; // Required: API base URL (e.g., '/api' or 'https://api.example.com')
|
|
65
|
-
fetchFn?: typeof fetch; // Optional: Custom fetch implementation (defaults to global fetch)
|
|
66
|
-
headers?: Record<string, string | undefined> | (() => Record<string, string | undefined>); // Optional: Default headers or a resolver function
|
|
67
|
-
config?: Record<string, QueryMethodConfig>; // Optional: Per-route HTTP method overrides
|
|
68
|
-
api?: TApi; // Optional: API object to auto-extract HTTP methods
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface QueryMethodConfig {
|
|
72
|
-
method?: string; // HTTP method: 'GET', 'POST', 'PUT', 'DELETE', etc.
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Returns:**
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
{
|
|
80
|
-
useQuery: <Name>(name, input?, options?) => UseQueryResult<Output, HttpError>,
|
|
81
|
-
useMutation: <Name>(name, options?) => UseMutationResult<Output, HttpError, Input>
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**Example:**
|
|
53
|
+
// client/analytics-hooks.ts
|
|
54
|
+
import { createHooks } from '@hypequery/react';
|
|
55
|
+
import type { AnalyticsApi } from '../server/api.js';
|
|
86
56
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
headers: {
|
|
91
|
-
'X-API-Key': process.env.API_KEY,
|
|
92
|
-
},
|
|
93
|
-
config: {
|
|
94
|
-
// Override HTTP method for specific routes
|
|
95
|
-
uploadFile: { method: 'POST' },
|
|
96
|
-
},
|
|
57
|
+
export const { useQuery, useMutation } = createHooks<AnalyticsApi>({
|
|
58
|
+
baseUrl: '/api/analytics',
|
|
59
|
+
api: {} as AnalyticsApi,
|
|
97
60
|
});
|
|
98
61
|
```
|
|
99
62
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
### `useQuery(name, input?, options?)`
|
|
63
|
+
Use them inside a component:
|
|
103
64
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
65
|
+
```tsx
|
|
66
|
+
function RevenuePanel() {
|
|
67
|
+
const revenue = useQuery('weeklyRevenue', {
|
|
68
|
+
startDate: '2026-01-01',
|
|
69
|
+
});
|
|
107
70
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
useQuery(name: Name)
|
|
111
|
-
useQuery(name: Name, options: QueryOptions)
|
|
71
|
+
if (revenue.isLoading) return <p>Loading...</p>;
|
|
72
|
+
if (revenue.error) return <p>{revenue.error.message}</p>;
|
|
112
73
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
useQuery(name: Name, input: Input, options: QueryOptions)
|
|
74
|
+
return <pre>{JSON.stringify(revenue.data, null, 2)}</pre>;
|
|
75
|
+
}
|
|
116
76
|
```
|
|
117
77
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
- `name` - Query name from your API definition
|
|
121
|
-
- `input` - Query input (typed from your API). Optional if query has no input schema
|
|
122
|
-
- `options` - TanStack Query options (`enabled`, `staleTime`, `refetchInterval`, etc.)
|
|
123
|
-
|
|
124
|
-
**Returns:** TanStack Query's `UseQueryResult<Output, HttpError>`
|
|
78
|
+
## Dataset And Metric Hooks
|
|
125
79
|
|
|
126
|
-
|
|
80
|
+
`createAnalyticsHooks` adds convenience wrappers for semantic endpoint names. Metrics use their endpoint name directly. Dataset endpoints are addressed as `dataset:<name>` in the API type and exposed through `useDataset(name, ...)`.
|
|
127
81
|
|
|
128
82
|
```tsx
|
|
129
|
-
|
|
130
|
-
|
|
83
|
+
import { createAnalyticsHooks } from '@hypequery/react';
|
|
84
|
+
import type { AnalyticsApi } from '../server/api.js';
|
|
131
85
|
|
|
132
|
-
//
|
|
133
|
-
const { data, error, isLoading } = useQuery('getUser', { id: '123' });
|
|
86
|
+
import { manifest } from '../server/api.js'; // serve api.manifest(), serialized
|
|
134
87
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
refetchOnWindowFocus: false,
|
|
88
|
+
export const { useMetric, useDataset } = createAnalyticsHooks<AnalyticsApi>({
|
|
89
|
+
baseUrl: '/api/analytics',
|
|
90
|
+
manifest,
|
|
91
|
+
metrics: ['revenue', 'averageOrderValue'] as const,
|
|
140
92
|
});
|
|
141
93
|
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
94
|
+
function Dashboard() {
|
|
95
|
+
const revenue = useMetric('revenue', {
|
|
96
|
+
dimensions: ['country'],
|
|
97
|
+
filters: [{ field: 'status', operator: 'eq', value: 'completed' }],
|
|
98
|
+
orderBy: [{ field: 'revenue', direction: 'desc' }],
|
|
99
|
+
limit: 10,
|
|
100
|
+
});
|
|
149
101
|
|
|
150
|
-
|
|
102
|
+
const orders = useDataset('orders', {
|
|
103
|
+
dimensions: ['status'],
|
|
104
|
+
measures: ['revenue', 'orderCount'],
|
|
105
|
+
});
|
|
151
106
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
107
|
+
return (
|
|
108
|
+
<pre>
|
|
109
|
+
{JSON.stringify({ revenue: revenue.data, orders: orders.data }, null, 2)}
|
|
110
|
+
</pre>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
156
114
|
|
|
157
|
-
|
|
115
|
+
## Pagination
|
|
158
116
|
|
|
159
|
-
|
|
117
|
+
Semantic queries that set a `limit` return `meta.pagination = { limit, offset, hasMore }`
|
|
118
|
+
(`hasMore` is exact — the server over-fetches one row rather than running a count query).
|
|
119
|
+
`useInfiniteQuery`, and the `useInfiniteMetric` / `useInfiniteDataset` wrappers, build on
|
|
120
|
+
this to page through results. They advance the offset automatically and request meta for you.
|
|
160
121
|
|
|
161
122
|
```tsx
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
onSuccess: (data) => {
|
|
169
|
-
console.log('Profile updated:', data);
|
|
170
|
-
queryClient.invalidateQueries(['hypequery', 'getProfile']);
|
|
171
|
-
},
|
|
172
|
-
onError: (error) => {
|
|
173
|
-
console.error('Failed to update profile:', error.message);
|
|
174
|
-
},
|
|
175
|
-
});
|
|
123
|
+
function OrdersTable() {
|
|
124
|
+
const orders = useInfiniteDataset('orders', {
|
|
125
|
+
dimensions: ['status'],
|
|
126
|
+
measures: ['revenue'],
|
|
127
|
+
limit: 50,
|
|
128
|
+
});
|
|
176
129
|
|
|
177
|
-
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{orders.data?.pages.flatMap((page) => page.data).map((row, i) => (
|
|
133
|
+
<Row key={i} row={row} />
|
|
134
|
+
))}
|
|
135
|
+
<button
|
|
136
|
+
onClick={() => orders.fetchNextPage()}
|
|
137
|
+
disabled={!orders.hasNextPage || orders.isFetchingNextPage}
|
|
138
|
+
>
|
|
139
|
+
Load more
|
|
140
|
+
</button>
|
|
141
|
+
</>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
178
144
|
```
|
|
179
145
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
### `queryOptions(options)`
|
|
183
|
-
|
|
184
|
-
Helper function to explicitly mark an object as query options (not input). Useful when your query input and TanStack Query options have overlapping property names.
|
|
146
|
+
`input.limit` is the page size; `input.offset`, if provided, is the starting offset.
|
|
185
147
|
|
|
186
|
-
|
|
148
|
+
## Route Configuration
|
|
187
149
|
|
|
188
|
-
|
|
150
|
+
Hooks need to know each endpoint's HTTP method and path. There are three ways to
|
|
151
|
+
supply that, in increasing precedence: a route manifest, a runtime `api` object,
|
|
152
|
+
or explicit `config`.
|
|
189
153
|
|
|
190
|
-
|
|
154
|
+
### Route manifest (recommended)
|
|
191
155
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
```tsx
|
|
197
|
-
// ❌ Ambiguous - could be input or options?
|
|
198
|
-
useQuery('getConfig', { enabled: true, staleTime: 5000 });
|
|
156
|
+
`@hypequery/serve`'s `api.manifest()` returns a serializable map of every
|
|
157
|
+
query/metric/dataset key to its `{ method, path }`. Export it from a server-only
|
|
158
|
+
module and pass it to the hooks — this avoids importing server code into the
|
|
159
|
+
browser bundle while keeping client routes in sync with the server.
|
|
199
160
|
|
|
200
|
-
|
|
201
|
-
|
|
161
|
+
```ts
|
|
162
|
+
// server side (server-only module)
|
|
163
|
+
export const manifest = api.manifest();
|
|
202
164
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
165
|
+
// client side
|
|
166
|
+
const { useQuery } = createHooks<AnalyticsApi>({
|
|
167
|
+
baseUrl: '/api/analytics',
|
|
168
|
+
manifest,
|
|
169
|
+
});
|
|
206
170
|
```
|
|
207
171
|
|
|
208
|
-
|
|
172
|
+
> Metric and dataset endpoints are POST routes whose paths differ from their map
|
|
173
|
+
> keys (e.g. `dataset:orders` → `POST /api/analytics/datasets/orders/query`). They
|
|
174
|
+
> require a `manifest` (or explicit `config`); calling them without one throws a
|
|
175
|
+
> clear error rather than hitting the wrong URL.
|
|
209
176
|
|
|
210
|
-
|
|
177
|
+
#### Generating the manifest at build time
|
|
211
178
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
179
|
+
The cleanest way to keep server code out of the browser bundle is to generate a
|
|
180
|
+
JSON file at build time and import that on the client. `api.manifest()` is pure —
|
|
181
|
+
it reads the configured routes and never touches your database — so this step is
|
|
182
|
+
cheap and safe to run in CI.
|
|
215
183
|
|
|
216
184
|
```ts
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
```
|
|
185
|
+
// scripts/gen-manifest.ts — run before your client build
|
|
186
|
+
import { writeFileSync } from 'node:fs';
|
|
187
|
+
import { api } from '../server/api.js';
|
|
222
188
|
|
|
223
|
-
|
|
189
|
+
writeFileSync('src/generated/manifest.json', JSON.stringify(api.manifest(), null, 2));
|
|
190
|
+
```
|
|
224
191
|
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
console.log(error.body); // { message: "User not found" }
|
|
232
|
-
|
|
233
|
-
// Type-safe instanceof check
|
|
234
|
-
if (error instanceof HttpError) {
|
|
235
|
-
if (error.status === 404) {
|
|
236
|
-
return <NotFound />;
|
|
237
|
-
}
|
|
192
|
+
```jsonc
|
|
193
|
+
// package.json
|
|
194
|
+
{
|
|
195
|
+
"scripts": {
|
|
196
|
+
"gen:manifest": "tsx scripts/gen-manifest.ts",
|
|
197
|
+
"build": "npm run gen:manifest && <your client build>"
|
|
238
198
|
}
|
|
239
199
|
}
|
|
240
200
|
```
|
|
241
201
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
import { HttpError } from '@hypequery/react';
|
|
246
|
-
|
|
247
|
-
function UserProfile({ userId }: { userId: string }) {
|
|
248
|
-
const { data, error, isLoading } = useQuery('getUser', { id: userId });
|
|
249
|
-
|
|
250
|
-
if (isLoading) return <Spinner />;
|
|
251
|
-
|
|
252
|
-
if (error instanceof HttpError) {
|
|
253
|
-
switch (error.status) {
|
|
254
|
-
case 404:
|
|
255
|
-
return <NotFound message="User not found" />;
|
|
256
|
-
case 403:
|
|
257
|
-
return <Unauthorized />;
|
|
258
|
-
case 500:
|
|
259
|
-
return <ServerError details={error.body} />;
|
|
260
|
-
default:
|
|
261
|
-
return <Error message={error.message} />;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
202
|
+
```ts
|
|
203
|
+
// client side — imports plain JSON, no server code in the bundle
|
|
204
|
+
import manifest from './generated/manifest.json';
|
|
264
205
|
|
|
265
|
-
|
|
266
|
-
|
|
206
|
+
const { useQuery } = createHooks<AnalyticsApi>({
|
|
207
|
+
baseUrl: '/api/analytics',
|
|
208
|
+
manifest,
|
|
209
|
+
});
|
|
267
210
|
```
|
|
268
211
|
|
|
269
|
-
|
|
212
|
+
The manifest is derived entirely from your serve config (`basePath`, route keys,
|
|
213
|
+
and `semanticPaths`), so the generated file is deterministic and only changes when
|
|
214
|
+
your API shape does — commit it or regenerate it on every build.
|
|
270
215
|
|
|
271
|
-
|
|
216
|
+
> `baseUrl` supplies the origin/host; the per-endpoint path comes from the
|
|
217
|
+
> manifest (it already includes the server's `basePath`), so there's no
|
|
218
|
+
> double-prefixing. Keep `baseUrl` aligned with where the API is mounted.
|
|
272
219
|
|
|
273
|
-
###
|
|
220
|
+
### Explicit config
|
|
221
|
+
|
|
222
|
+
If a manifest is not available at runtime, pass route config explicitly. This
|
|
223
|
+
overrides any manifest entry.
|
|
274
224
|
|
|
275
225
|
```ts
|
|
276
|
-
const { useQuery } = createHooks<
|
|
277
|
-
baseUrl: '/api',
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const token = await getAuthToken();
|
|
281
|
-
return fetch(url, {
|
|
282
|
-
...init,
|
|
283
|
-
headers: {
|
|
284
|
-
...init?.headers,
|
|
285
|
-
Authorization: `Bearer ${token}`,
|
|
286
|
-
},
|
|
287
|
-
});
|
|
226
|
+
const { useQuery } = createHooks<AnalyticsApi>({
|
|
227
|
+
baseUrl: '/api/analytics',
|
|
228
|
+
config: {
|
|
229
|
+
weeklyRevenue: { method: 'POST', path: '/weeklyRevenue' },
|
|
288
230
|
},
|
|
289
231
|
});
|
|
290
232
|
```
|
|
291
233
|
|
|
292
|
-
|
|
234
|
+
`path` may be relative to `baseUrl`, absolute within the same origin, or an absolute HTTP URL.
|
|
293
235
|
|
|
294
|
-
|
|
295
|
-
const { useQuery } = createHooks<Api>({
|
|
296
|
-
baseUrl: '/api',
|
|
297
|
-
headers: {
|
|
298
|
-
'X-API-Key': process.env.API_KEY,
|
|
299
|
-
'X-Client-Version': '1.0.0',
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
```
|
|
236
|
+
## Headers & auth
|
|
303
237
|
|
|
304
|
-
|
|
238
|
+
Pass static headers, or a function (sync or async) invoked per request — handy for
|
|
239
|
+
attaching a fresh/short-lived token.
|
|
305
240
|
|
|
306
241
|
```ts
|
|
307
|
-
const
|
|
308
|
-
baseUrl: '/api',
|
|
309
|
-
headers: () => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
},
|
|
242
|
+
const hooks = createHooks<AnalyticsApi>({
|
|
243
|
+
baseUrl: '/api/analytics',
|
|
244
|
+
headers: async () => ({
|
|
245
|
+
authorization: `Bearer ${await getAccessToken()}`,
|
|
246
|
+
}),
|
|
313
247
|
});
|
|
314
248
|
```
|
|
315
249
|
|
|
316
|
-
|
|
250
|
+
Provide `onUnauthorized` to refresh credentials on a `401` and retry the request once
|
|
251
|
+
with freshly resolved headers:
|
|
317
252
|
|
|
318
253
|
```ts
|
|
319
|
-
const
|
|
320
|
-
baseUrl: '/api',
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
deleteUser: { method: 'DELETE' },
|
|
254
|
+
const hooks = createHooks<AnalyticsApi>({
|
|
255
|
+
baseUrl: '/api/analytics',
|
|
256
|
+
headers: () => ({ authorization: `Bearer ${tokenStore.access}` }),
|
|
257
|
+
onUnauthorized: async () => {
|
|
258
|
+
await tokenStore.refresh(); // throw to abort; resolve to retry once
|
|
325
259
|
},
|
|
326
260
|
});
|
|
327
261
|
```
|
|
328
262
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
If your API definition includes HTTP method information, pass it to auto-configure:
|
|
263
|
+
## Query Options
|
|
332
264
|
|
|
333
|
-
|
|
334
|
-
import { api } from '@/analytics/queries';
|
|
265
|
+
TanStack Query options can be passed as the final argument.
|
|
335
266
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
267
|
+
```tsx
|
|
268
|
+
const result = useQuery(
|
|
269
|
+
'weeklyRevenue',
|
|
270
|
+
{ startDate: '2026-01-01' },
|
|
271
|
+
{
|
|
272
|
+
staleTime: 60_000,
|
|
273
|
+
refetchOnWindowFocus: false,
|
|
274
|
+
},
|
|
275
|
+
);
|
|
340
276
|
```
|
|
341
277
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
## TypeScript
|
|
278
|
+
For no-input queries, use `queryOptions()` when an options object might look like input.
|
|
345
279
|
|
|
346
|
-
|
|
280
|
+
```tsx
|
|
281
|
+
import { queryOptions } from '@hypequery/react';
|
|
347
282
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
output: { name: string; email: string };
|
|
353
|
-
};
|
|
354
|
-
weeklyRevenue: {
|
|
355
|
-
input: { startDate: string };
|
|
356
|
-
output: { total: number };
|
|
357
|
-
};
|
|
358
|
-
}
|
|
283
|
+
const result = useQuery('health', queryOptions({
|
|
284
|
+
staleTime: 30_000,
|
|
285
|
+
}));
|
|
286
|
+
```
|
|
359
287
|
|
|
360
|
-
|
|
288
|
+
## Mutations
|
|
361
289
|
|
|
362
|
-
|
|
363
|
-
const { data } = useQuery('getUser', { id: '123' });
|
|
364
|
-
// ^? { name: string; email: string } | undefined
|
|
290
|
+
`useMutation(name)` uses the same endpoint typing and sends requests as `POST` by default unless route config overrides it.
|
|
365
291
|
|
|
366
|
-
|
|
367
|
-
const
|
|
292
|
+
```tsx
|
|
293
|
+
const refresh = useMutation('refreshReport');
|
|
368
294
|
|
|
369
|
-
|
|
370
|
-
const { data } = useQuery('getUser', { userId: '123' });
|
|
295
|
+
refresh.mutate({ reportId: 'revenue' });
|
|
371
296
|
```
|
|
372
297
|
|
|
373
|
-
|
|
298
|
+
## Notes
|
|
299
|
+
|
|
300
|
+
- This package does not define datasets. Use `@hypequery/datasets` for semantic definitions.
|
|
301
|
+
- This package does not generate SQL. It calls HTTP routes exposed by `@hypequery/serve`.
|
|
302
|
+
- Dataset relationship JOIN execution is not implied by the React hooks; hooks consume whatever endpoint behavior the server exposes.
|
|
374
303
|
|
|
375
304
|
## License
|
|
376
305
|
|
|
377
|
-
|
|
306
|
+
Apache-2.0.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { UseQueryOptions as TanstackUseQueryOptions, UseQueryResult, UseInfiniteQueryResult, InfiniteData } from '@tanstack/react-query';
|
|
2
|
+
import { type CreateHooksConfig } from './createHooks.js';
|
|
3
|
+
import { HttpError } from './errors.js';
|
|
4
|
+
import type { ExtractNames, QueryInput, QueryOutput } from './types.js';
|
|
5
|
+
type DatasetKey<Name extends string> = `dataset:${Name}`;
|
|
6
|
+
type DatasetNamesFromApi<Api> = ExtractNames<Api> extends infer Key ? Key extends `dataset:${infer Name}` ? Name : never : never;
|
|
7
|
+
type QueryKey<Name extends string, Input> = Input extends never ? ['hypequery', Name] : ['hypequery', Name, Input];
|
|
8
|
+
type QueryOptions<Api, Key extends ExtractNames<Api>> = Omit<TanstackUseQueryOptions<QueryOutput<Api, Key>, HttpError, QueryOutput<Api, Key>, QueryKey<Key, QueryInput<Api, Key>>>, 'queryKey' | 'queryFn'>;
|
|
9
|
+
type MetricQueryParams<Api, Key extends ExtractNames<Api>> = QueryInput<Api, Key> extends never ? [name: Key, options?: QueryOptions<Api, Key>] : [name: Key, input: QueryInput<Api, Key>, options?: QueryOptions<Api, Key>];
|
|
10
|
+
type DatasetQueryParams<Api, Name extends DatasetNamesFromApi<Api>> = QueryInput<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>> extends never ? [name: Name, options?: QueryOptions<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>] : [
|
|
11
|
+
name: Name,
|
|
12
|
+
input: QueryInput<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>,
|
|
13
|
+
options?: QueryOptions<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>
|
|
14
|
+
];
|
|
15
|
+
export interface CreateAnalyticsHooksConfig<Api extends Record<string, {
|
|
16
|
+
input: any;
|
|
17
|
+
output: any;
|
|
18
|
+
}>, TMetrics extends readonly Exclude<ExtractNames<Api>, `dataset:${string}`>[] = readonly Exclude<ExtractNames<Api>, `dataset:${string}`>[]> extends CreateHooksConfig<Api> {
|
|
19
|
+
metrics?: TMetrics;
|
|
20
|
+
}
|
|
21
|
+
export declare function createAnalyticsHooks<Api extends Record<string, {
|
|
22
|
+
input: any;
|
|
23
|
+
output: any;
|
|
24
|
+
}>, const TMetrics extends readonly Exclude<ExtractNames<Api>, `dataset:${string}`>[] = readonly Exclude<ExtractNames<Api>, `dataset:${string}`>[]>(config: CreateAnalyticsHooksConfig<Api, TMetrics>): {
|
|
25
|
+
readonly useMetric: <Name extends TMetrics extends readonly (infer U)[] ? U extends string ? U : never : Exclude<ExtractNames<Api>, `dataset:${string}`>>(...args: MetricQueryParams<Api, Name>) => UseQueryResult<QueryOutput<Api, Name>, HttpError>;
|
|
26
|
+
readonly useDataset: <Name extends DatasetNamesFromApi<Api>>(...args: DatasetQueryParams<Api, Name>) => UseQueryResult<QueryOutput<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>, HttpError>;
|
|
27
|
+
readonly useInfiniteMetric: <Name extends TMetrics extends readonly (infer U)[] ? U extends string ? U : never : Exclude<ExtractNames<Api>, `dataset:${string}`>>(name: Name, input: QueryInput<Api, Name>, options?: Parameters<(<Name_1 extends Extract<keyof Api, string>>(name: Name_1, input: QueryInput<Api, Name_1>, options?: Omit<import("@tanstack/react-query").UseInfiniteQueryOptions<QueryOutput<Api, Name_1>, HttpError, InfiniteData<QueryOutput<Api, Name_1>, number>, QueryInput<Api, Name_1> extends never ? ["hypequery", Name_1] : ["hypequery", Name_1, QueryInput<Api, Name_1>], number>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam"> | undefined) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name_1>, number>, HttpError>)>[2]) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name>, number>, HttpError>;
|
|
28
|
+
readonly useInfiniteDataset: <Name extends DatasetNamesFromApi<Api>>(name: Name, input: QueryInput<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>, options?: Parameters<(<Name_1 extends Extract<keyof Api, string>>(name: Name_1, input: QueryInput<Api, Name_1>, options?: Omit<import("@tanstack/react-query").UseInfiniteQueryOptions<QueryOutput<Api, Name_1>, HttpError, InfiniteData<QueryOutput<Api, Name_1>, number>, QueryInput<Api, Name_1> extends never ? ["hypequery", Name_1] : ["hypequery", Name_1, QueryInput<Api, Name_1>], number>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam"> | undefined) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name_1>, number>, HttpError>)>[2]) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Extract<ExtractNames<Api>, DatasetKey<Name>>>, number>, HttpError>;
|
|
29
|
+
readonly useQuery: <Name extends Extract<keyof Api, string>>(...args: QueryInput<Api, Name> extends never ? [name: Name, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined] : [name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined]) => UseQueryResult<QueryOutput<Api, Name>, HttpError>;
|
|
30
|
+
readonly useMutation: <Name extends Extract<keyof Api, string>>(name: Name, options?: Omit<import("@tanstack/react-query").UseMutationOptions<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>, unknown>, "mutationFn"> | undefined) => import("@tanstack/react-query").UseMutationResult<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>>;
|
|
31
|
+
readonly useInfiniteQuery: <Name extends Extract<keyof Api, string>>(name: Name, input: QueryInput<Api, Name>, options?: Omit<import("@tanstack/react-query").UseInfiniteQueryOptions<QueryOutput<Api, Name>, HttpError, InfiniteData<QueryOutput<Api, Name>, number>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>], number>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam"> | undefined) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name>, number>, HttpError>;
|
|
32
|
+
};
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=analyticsHooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyticsHooks.d.ts","sourceRoot":"","sources":["../src/analyticsHooks.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHooks } from './createHooks.js';
|
|
2
|
+
export function createAnalyticsHooks(config) {
|
|
3
|
+
const hooks = createHooks(config);
|
|
4
|
+
function useMetric(...args) {
|
|
5
|
+
const [name, ...rest] = args;
|
|
6
|
+
return hooks.useQuery(name, ...rest);
|
|
7
|
+
}
|
|
8
|
+
function useDataset(...args) {
|
|
9
|
+
const [name, ...rest] = args;
|
|
10
|
+
const key = `dataset:${String(name)}`;
|
|
11
|
+
return hooks.useQuery(key, ...rest);
|
|
12
|
+
}
|
|
13
|
+
function useInfiniteMetric(name, input, options) {
|
|
14
|
+
return hooks.useInfiniteQuery(name, input, options);
|
|
15
|
+
}
|
|
16
|
+
function useInfiniteDataset(name, input, options) {
|
|
17
|
+
const key = `dataset:${String(name)}`;
|
|
18
|
+
return hooks.useInfiniteQuery(key, input, options);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...hooks,
|
|
22
|
+
useMetric,
|
|
23
|
+
useDataset,
|
|
24
|
+
useInfiniteMetric,
|
|
25
|
+
useInfiniteDataset,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyticsHooks.test.d.ts","sourceRoot":"","sources":["../src/analyticsHooks.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { createAnalyticsHooks } from './analyticsHooks.js';
|
|
6
|
+
function createWrapper() {
|
|
7
|
+
const queryClient = new QueryClient({
|
|
8
|
+
defaultOptions: {
|
|
9
|
+
queries: { retry: false },
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
return ({ children }) => (_jsx(QueryClientProvider, { client: queryClient, children: children }));
|
|
13
|
+
}
|
|
14
|
+
function mockSuccessResponse(data) {
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
status: 200,
|
|
18
|
+
json: () => Promise.resolve(data),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe('createAnalyticsHooks', () => {
|
|
22
|
+
const fetchMock = vi.fn();
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
fetchMock.mockReset();
|
|
25
|
+
});
|
|
26
|
+
it('queries metrics through useMetric with metric names only', async () => {
|
|
27
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ totalRevenue: 42 }] }));
|
|
28
|
+
const { useMetric } = createAnalyticsHooks({
|
|
29
|
+
baseUrl: '/api/analytics',
|
|
30
|
+
fetchFn: fetchMock,
|
|
31
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
32
|
+
config: {
|
|
33
|
+
totalRevenue: { method: 'POST', path: '/api/analytics/metrics/totalRevenue' },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const { result } = renderHook(() => useMetric('totalRevenue', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(result.current.data).toEqual({ data: [{ totalRevenue: 42 }] });
|
|
39
|
+
});
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/analytics/metrics/totalRevenue', expect.objectContaining({
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body: JSON.stringify({ dimensions: ['country'] }),
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
it('queries datasets through useDataset without exposing dataset: keys', async () => {
|
|
46
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ country: 'US', revenue: 120 }] }));
|
|
47
|
+
const { useDataset } = createAnalyticsHooks({
|
|
48
|
+
baseUrl: '/api/analytics',
|
|
49
|
+
fetchFn: fetchMock,
|
|
50
|
+
metrics: [],
|
|
51
|
+
config: {
|
|
52
|
+
'dataset:orders': { method: 'POST', path: '/api/analytics/datasets/orders/query' },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const { result } = renderHook(() => useDataset('orders', { dimensions: ['country'], measures: ['revenue'] }), { wrapper: createWrapper() });
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(result.current.data).toEqual({ data: [{ country: 'US', revenue: 120 }] });
|
|
58
|
+
});
|
|
59
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/analytics/datasets/orders/query', expect.objectContaining({
|
|
60
|
+
method: 'POST',
|
|
61
|
+
body: JSON.stringify({ dimensions: ['country'], measures: ['revenue'] }),
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
it('supports metric names from the declared semantic metric list', async () => {
|
|
65
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ period: '2025-01-01', monthlyRevenue: 90 }] }));
|
|
66
|
+
const { useMetric } = createAnalyticsHooks({
|
|
67
|
+
baseUrl: '/api/analytics',
|
|
68
|
+
fetchFn: fetchMock,
|
|
69
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
70
|
+
config: {
|
|
71
|
+
monthlyRevenue: { method: 'POST', path: '/api/analytics/metrics/monthlyRevenue' },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const { result } = renderHook(() => useMetric('monthlyRevenue', { by: 'month' }), { wrapper: createWrapper() });
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(result.current.data).toEqual({ data: [{ period: '2025-01-01', monthlyRevenue: 90 }] });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('resolves semantic config paths against an absolute baseUrl host', async () => {
|
|
80
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ totalRevenue: 42 }] }));
|
|
81
|
+
const { useMetric } = createAnalyticsHooks({
|
|
82
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
83
|
+
fetchFn: fetchMock,
|
|
84
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
85
|
+
config: {
|
|
86
|
+
totalRevenue: { method: 'POST', path: '/api/analytics/metrics/totalRevenue' },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const { result } = renderHook(() => useMetric('totalRevenue', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(result.current.data).toEqual({ data: [{ totalRevenue: 42 }] });
|
|
92
|
+
});
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/metrics/totalRevenue', expect.objectContaining({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: JSON.stringify({ dimensions: ['country'] }),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
});
|
package/dist/createHooks.d.ts
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
|
-
import { type UseQueryOptions as TanstackUseQueryOptions, type UseMutationOptions as TanstackUseMutationOptions, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query';
|
|
1
|
+
import { type UseQueryOptions as TanstackUseQueryOptions, type UseMutationOptions as TanstackUseMutationOptions, type UseInfiniteQueryOptions as TanstackUseInfiniteQueryOptions, type UseMutationResult, type UseQueryResult, type UseInfiniteQueryResult, type InfiniteData } from '@tanstack/react-query';
|
|
2
2
|
import type { ExtractNames, QueryInput, QueryOutput } from './types.js';
|
|
3
3
|
import { HttpError } from './errors.js';
|
|
4
4
|
export interface QueryMethodConfig {
|
|
5
5
|
method?: string;
|
|
6
|
+
path?: string;
|
|
6
7
|
}
|
|
8
|
+
/** A single route as produced by `@hypequery/serve`'s `api.manifest()`. */
|
|
9
|
+
export interface RouteManifestEntry {
|
|
10
|
+
method?: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Map of query/metric/dataset keys to their HTTP method and full path. Pair
|
|
15
|
+
* with `InferAPIType` and `api.manifest()` from `@hypequery/serve` to resolve
|
|
16
|
+
* routes on the client without importing server code into the bundle.
|
|
17
|
+
*/
|
|
18
|
+
export type RouteManifest = Record<string, RouteManifestEntry>;
|
|
7
19
|
type HeaderMap = Record<string, string | undefined>;
|
|
8
|
-
type HeadersInput = HeaderMap | (() => HeaderMap);
|
|
20
|
+
type HeadersInput = HeaderMap | (() => HeaderMap | Promise<HeaderMap>);
|
|
9
21
|
export interface CreateHooksConfig<TApi = Record<string, {
|
|
10
22
|
input: unknown;
|
|
11
23
|
output: unknown;
|
|
12
24
|
}>> {
|
|
13
25
|
baseUrl: string;
|
|
14
26
|
fetchFn?: typeof fetch;
|
|
27
|
+
/**
|
|
28
|
+
* Static headers, or a (optionally async) function returning headers. The
|
|
29
|
+
* function is invoked per request, so it can supply a fresh/short-lived token.
|
|
30
|
+
*/
|
|
15
31
|
headers?: HeadersInput;
|
|
16
32
|
config?: Record<string, QueryMethodConfig>;
|
|
33
|
+
/**
|
|
34
|
+
* Route manifest from `@hypequery/serve`'s `api.manifest()`. Resolves the
|
|
35
|
+
* method and full path for each key — required for metric/dataset endpoints,
|
|
36
|
+
* which are POST routes whose paths differ from their map keys.
|
|
37
|
+
*/
|
|
38
|
+
manifest?: RouteManifest;
|
|
39
|
+
/**
|
|
40
|
+
* Called when a request returns 401. Use it to refresh credentials (e.g. a
|
|
41
|
+
* token). If it resolves without throwing, the request is retried once with
|
|
42
|
+
* freshly resolved headers.
|
|
43
|
+
*/
|
|
44
|
+
onUnauthorized?: () => void | Promise<void>;
|
|
17
45
|
api?: TApi;
|
|
18
46
|
}
|
|
19
47
|
declare const OPTIONS_SYMBOL: unique symbol;
|
|
@@ -26,6 +54,7 @@ export declare function createHooks<Api extends Record<string, {
|
|
|
26
54
|
}>>(config: CreateHooksConfig<Api>): {
|
|
27
55
|
readonly useQuery: <Name extends ExtractNames<Api>>(...args: QueryInput<Api, Name> extends never ? [name: Name, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined] : [name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined]) => UseQueryResult<QueryOutput<Api, Name>, HttpError>;
|
|
28
56
|
readonly useMutation: <Name extends ExtractNames<Api>>(name: Name, options?: Omit<TanstackUseMutationOptions<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>, unknown>, "mutationFn">) => UseMutationResult<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>>;
|
|
57
|
+
readonly useInfiniteQuery: <Name extends ExtractNames<Api>>(name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseInfiniteQueryOptions<QueryOutput<Api, Name>, HttpError, InfiniteData<QueryOutput<Api, Name>, number>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>], number>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam">) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name>, number>, HttpError>;
|
|
29
58
|
};
|
|
30
59
|
export {};
|
|
31
60
|
//# sourceMappingURL=createHooks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.tsx"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,eAAe,IAAI,uBAAuB,EAC/C,KAAK,kBAAkB,IAAI,0BAA0B,EACrD,KAAK,uBAAuB,IAAI,+BAA+B,EAC/D,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAUxC,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,2EAA2E;AAC3E,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;AAE/D,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AACpD,KAAK,YAAY,GACb,SAAS,GACT,CAAC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;AAE3C,MAAM,WAAW,iBAAiB,CAAC,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;IAC3F,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,QAAA,MAAM,cAAc,eAAkC,CAAC;AAEvD,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG;IAAE,CAAC,cAAc,CAAC,EAAE,IAAI,CAAA;CAAE,CAEtF;AAqGD,wBAAgB,WAAW,CAAC,GAAG,SAAS,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EACjF,MAAM,EAAE,iBAAiB,CAAC,GAAG,CAAC;wBAgGZ,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,+kBAE7C,cAAc,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC;2BAwC/B,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,QAC3C,IAAI,kIAET,iBAAiB,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;gCAwBpD,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,QAChD,IAAI,SACH,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,yTAE3B,sBAAsB,CAAC,YAAY,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC;EA4BnF"}
|
package/dist/createHooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useQuery as useTanstackQuery, useMutation as useTanstackMutation, } from '@tanstack/react-query';
|
|
1
|
+
import { useQuery as useTanstackQuery, useMutation as useTanstackMutation, useInfiniteQuery as useTanstackInfiniteQuery, } from '@tanstack/react-query';
|
|
2
2
|
import { HttpError } from './errors.js';
|
|
3
3
|
const OPTIONS_SYMBOL = Symbol.for('hypequery-options');
|
|
4
4
|
export function queryOptions(opts) {
|
|
@@ -7,12 +7,19 @@ export function queryOptions(opts) {
|
|
|
7
7
|
const normalizeMethodConfig = (source) => {
|
|
8
8
|
if (!source)
|
|
9
9
|
return {};
|
|
10
|
-
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, { method: value.method ?? 'GET' }]));
|
|
10
|
+
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, { method: value.method ?? 'GET', path: value.path }]));
|
|
11
11
|
};
|
|
12
12
|
const deriveMethodConfig = (api) => {
|
|
13
13
|
if (typeof api !== 'object' || api === null) {
|
|
14
14
|
return {};
|
|
15
15
|
}
|
|
16
|
+
// Preferred: the serve API exposes a manifest() with full method + path.
|
|
17
|
+
if (typeof api.manifest === 'function') {
|
|
18
|
+
const manifest = api.manifest();
|
|
19
|
+
if (manifest && typeof manifest === 'object') {
|
|
20
|
+
return normalizeMethodConfig(manifest);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
16
23
|
if ('_routeConfig' in api &&
|
|
17
24
|
typeof api._routeConfig === 'object' &&
|
|
18
25
|
api._routeConfig !== null) {
|
|
@@ -25,11 +32,28 @@ const deriveMethodConfig = (api) => {
|
|
|
25
32
|
}
|
|
26
33
|
return {};
|
|
27
34
|
};
|
|
28
|
-
const
|
|
35
|
+
const isAbsoluteHttpUrl = (value) => /^https?:\/\//.test(value);
|
|
36
|
+
const ensureTrailingSlash = (value) => value.endsWith('/') ? value : `${value}/`;
|
|
37
|
+
const buildUrl = (baseUrl, name, path) => {
|
|
38
|
+
if (path) {
|
|
39
|
+
if (isAbsoluteHttpUrl(path)) {
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
if (isAbsoluteHttpUrl(baseUrl)) {
|
|
43
|
+
return new URL(path, ensureTrailingSlash(baseUrl)).toString();
|
|
44
|
+
}
|
|
45
|
+
if (path.startsWith('/')) {
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
if (!baseUrl) {
|
|
49
|
+
throw new Error('baseUrl is required');
|
|
50
|
+
}
|
|
51
|
+
return `${ensureTrailingSlash(baseUrl)}${path}`;
|
|
52
|
+
}
|
|
29
53
|
if (!baseUrl) {
|
|
30
54
|
throw new Error('baseUrl is required');
|
|
31
55
|
}
|
|
32
|
-
return
|
|
56
|
+
return `${ensureTrailingSlash(baseUrl)}${name}`;
|
|
33
57
|
};
|
|
34
58
|
const parseResponse = async (res) => {
|
|
35
59
|
const text = await res.text();
|
|
@@ -52,18 +76,31 @@ const looksLikeQueryOptions = (value) => {
|
|
|
52
76
|
const matches = optionKeys.filter((key) => key in value).length;
|
|
53
77
|
return matches >= 2;
|
|
54
78
|
};
|
|
55
|
-
const resolveHeaders = (headers) => {
|
|
79
|
+
const resolveHeaders = async (headers) => {
|
|
56
80
|
if (!headers)
|
|
57
81
|
return {};
|
|
58
|
-
const raw = typeof headers === 'function' ? headers() : headers;
|
|
82
|
+
const raw = typeof headers === 'function' ? await headers() : headers;
|
|
59
83
|
return Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== undefined));
|
|
60
84
|
};
|
|
61
85
|
export function createHooks(config) {
|
|
62
|
-
const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, api } = config;
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, manifest, onUnauthorized, api } = config;
|
|
87
|
+
// Precedence: explicit config > manifest > derived from a runtime api object.
|
|
88
|
+
const finalConfig = {
|
|
89
|
+
...deriveMethodConfig(api),
|
|
90
|
+
...(manifest ? normalizeMethodConfig(manifest) : {}),
|
|
91
|
+
...explicitConfig,
|
|
92
|
+
};
|
|
93
|
+
const fetchQuery = async (name, input, defaultMethod = 'GET', extraHeaders) => {
|
|
94
|
+
const methodConfig = finalConfig[name];
|
|
95
|
+
// Semantic endpoints (dataset:<name>) live at paths that differ from their
|
|
96
|
+
// map key, so without a resolved path we'd call the wrong URL. Fail loudly
|
|
97
|
+
// instead of silently requesting `${baseUrl}/dataset:<name>`.
|
|
98
|
+
if (name.includes(':') && !methodConfig?.path) {
|
|
99
|
+
throw new Error(`No route configured for "${name}". Pass \`manifest\` (from the serve ` +
|
|
100
|
+
`api.manifest()) or an explicit \`config\` entry to createHooks().`);
|
|
101
|
+
}
|
|
102
|
+
const url = buildUrl(baseUrl, name, methodConfig?.path);
|
|
103
|
+
const method = methodConfig?.method ?? defaultMethod;
|
|
67
104
|
let finalUrl = url;
|
|
68
105
|
let body;
|
|
69
106
|
if (method === 'GET' && input && typeof input === 'object') {
|
|
@@ -84,15 +121,24 @@ export function createHooks(config) {
|
|
|
84
121
|
else if (input !== undefined) {
|
|
85
122
|
body = JSON.stringify(input);
|
|
86
123
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
const attempt = async () => {
|
|
125
|
+
const resolvedHeaders = await resolveHeaders(headers);
|
|
126
|
+
return fetchFn(finalUrl, {
|
|
127
|
+
method,
|
|
128
|
+
headers: {
|
|
129
|
+
...resolvedHeaders,
|
|
130
|
+
...(extraHeaders ?? {}),
|
|
131
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
132
|
+
},
|
|
133
|
+
body,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
let res = await attempt();
|
|
137
|
+
// On 401, give the caller a chance to refresh credentials and retry once.
|
|
138
|
+
if (res.status === 401 && onUnauthorized) {
|
|
139
|
+
await onUnauthorized();
|
|
140
|
+
res = await attempt();
|
|
141
|
+
}
|
|
96
142
|
if (!res.ok) {
|
|
97
143
|
const errorBody = await parseResponse(res);
|
|
98
144
|
throw new HttpError(`${method} request to ${finalUrl} failed with status ${res.status}`, res.status, errorBody);
|
|
@@ -133,8 +179,31 @@ export function createHooks(config) {
|
|
|
133
179
|
...options,
|
|
134
180
|
});
|
|
135
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Offset-paginated query for semantic endpoints. Pages are advanced using the
|
|
184
|
+
* `meta.pagination` returned by the server (the request opts into meta via the
|
|
185
|
+
* `x-include-meta` header). `input.limit` is the page size; `input.offset`, if
|
|
186
|
+
* provided, is the starting offset.
|
|
187
|
+
*/
|
|
188
|
+
function useInfiniteQuery(name, input, options) {
|
|
189
|
+
const initialOffset = input?.offset ?? 0;
|
|
190
|
+
const queryKey = ['hypequery', name, input];
|
|
191
|
+
return useTanstackInfiniteQuery({
|
|
192
|
+
queryKey,
|
|
193
|
+
initialPageParam: initialOffset,
|
|
194
|
+
queryFn: ({ pageParam }) => fetchQuery(name, { ...input, offset: pageParam }, 'POST', { 'x-include-meta': 'true' }),
|
|
195
|
+
getNextPageParam: (lastPage) => {
|
|
196
|
+
const pagination = lastPage.meta?.pagination;
|
|
197
|
+
if (!pagination || !pagination.hasMore)
|
|
198
|
+
return undefined;
|
|
199
|
+
return pagination.offset + pagination.limit;
|
|
200
|
+
},
|
|
201
|
+
...(options ?? {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
136
204
|
return {
|
|
137
205
|
useQuery,
|
|
138
206
|
useMutation,
|
|
207
|
+
useInfiniteQuery,
|
|
139
208
|
};
|
|
140
209
|
}
|
package/dist/createHooks.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
-
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
4
4
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
5
|
import { createHooks, queryOptions } from './createHooks.js';
|
|
6
6
|
import { HttpError } from './errors.js';
|
|
@@ -93,6 +93,57 @@ describe('createHooks', () => {
|
|
|
93
93
|
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ 'x-token': 'first' });
|
|
94
94
|
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ 'x-token': 'second' });
|
|
95
95
|
});
|
|
96
|
+
it('supports an async header factory', async () => {
|
|
97
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ name: 'User' }));
|
|
98
|
+
const { useQuery } = createHooks({
|
|
99
|
+
baseUrl: 'https://example.com/api',
|
|
100
|
+
fetchFn: fetchMock,
|
|
101
|
+
headers: async () => ({ authorization: 'Bearer fresh-token' }),
|
|
102
|
+
});
|
|
103
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
104
|
+
wrapper: createWrapper(),
|
|
105
|
+
});
|
|
106
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
107
|
+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ authorization: 'Bearer fresh-token' });
|
|
108
|
+
});
|
|
109
|
+
it('calls onUnauthorized and retries once on 401', async () => {
|
|
110
|
+
fetchMock
|
|
111
|
+
.mockResolvedValueOnce(mockErrorResponse(401, { error: 'expired' }))
|
|
112
|
+
.mockResolvedValueOnce(mockSuccessResponse({ name: 'User' }));
|
|
113
|
+
let token = 'stale';
|
|
114
|
+
const onUnauthorized = vi.fn(async () => { token = 'refreshed'; });
|
|
115
|
+
const { useQuery } = createHooks({
|
|
116
|
+
baseUrl: 'https://example.com/api',
|
|
117
|
+
fetchFn: fetchMock,
|
|
118
|
+
headers: () => ({ authorization: `Bearer ${token}` }),
|
|
119
|
+
onUnauthorized,
|
|
120
|
+
});
|
|
121
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
122
|
+
wrapper: createWrapper(),
|
|
123
|
+
});
|
|
124
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
125
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
127
|
+
// The retry uses freshly resolved headers.
|
|
128
|
+
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ authorization: 'Bearer refreshed' });
|
|
129
|
+
expect(result.current.data).toEqual({ name: 'User' });
|
|
130
|
+
});
|
|
131
|
+
it('throws after a single 401 retry still fails', async () => {
|
|
132
|
+
fetchMock.mockResolvedValue(mockErrorResponse(401, { error: 'nope' }));
|
|
133
|
+
const onUnauthorized = vi.fn(async () => { });
|
|
134
|
+
const { useQuery } = createHooks({
|
|
135
|
+
baseUrl: 'https://example.com/api',
|
|
136
|
+
fetchFn: fetchMock,
|
|
137
|
+
onUnauthorized,
|
|
138
|
+
});
|
|
139
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
140
|
+
wrapper: createWrapper(),
|
|
141
|
+
});
|
|
142
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
143
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
145
|
+
expect(result.current.error?.status).toBe(401);
|
|
146
|
+
});
|
|
96
147
|
});
|
|
97
148
|
describe('HTTP Method Handling', () => {
|
|
98
149
|
const fetchMock = vi.fn();
|
|
@@ -186,6 +237,108 @@ describe('createHooks', () => {
|
|
|
186
237
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
187
238
|
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({ method: 'POST' }));
|
|
188
239
|
});
|
|
240
|
+
it('resolves root-relative config paths against an absolute baseUrl host', async () => {
|
|
241
|
+
const { useQuery } = createHooks({
|
|
242
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
243
|
+
fetchFn: fetchMock,
|
|
244
|
+
config: {
|
|
245
|
+
getUser: { method: 'POST', path: '/api/analytics/queries/getUser' },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
249
|
+
wrapper: createWrapper(),
|
|
250
|
+
});
|
|
251
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
252
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({
|
|
253
|
+
method: 'POST',
|
|
254
|
+
body: JSON.stringify({ id: '123' }),
|
|
255
|
+
}));
|
|
256
|
+
});
|
|
257
|
+
it('resolves relative config paths against baseUrl', async () => {
|
|
258
|
+
const { useQuery } = createHooks({
|
|
259
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
260
|
+
fetchFn: fetchMock,
|
|
261
|
+
config: {
|
|
262
|
+
getUser: { method: 'POST', path: 'queries/getUser' },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
266
|
+
wrapper: createWrapper(),
|
|
267
|
+
});
|
|
268
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
269
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/hypequery/queries/getUser', expect.objectContaining({
|
|
270
|
+
method: 'POST',
|
|
271
|
+
body: JSON.stringify({ id: '123' }),
|
|
272
|
+
}));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('Route manifest', () => {
|
|
276
|
+
const fetchMock = vi.fn();
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
fetchMock.mockReset();
|
|
279
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
280
|
+
});
|
|
281
|
+
it('resolves method and path from a manifest', async () => {
|
|
282
|
+
const { useQuery } = createHooks({
|
|
283
|
+
baseUrl: 'https://api.example.com',
|
|
284
|
+
fetchFn: fetchMock,
|
|
285
|
+
manifest: {
|
|
286
|
+
getUser: { method: 'POST', path: '/api/analytics/metrics/getUser' },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
290
|
+
wrapper: createWrapper(),
|
|
291
|
+
});
|
|
292
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
293
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/metrics/getUser', expect.objectContaining({
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: JSON.stringify({ id: '123' }),
|
|
296
|
+
}));
|
|
297
|
+
});
|
|
298
|
+
it('lets explicit config override the manifest', async () => {
|
|
299
|
+
const { useQuery } = createHooks({
|
|
300
|
+
baseUrl: 'https://api.example.com',
|
|
301
|
+
fetchFn: fetchMock,
|
|
302
|
+
manifest: {
|
|
303
|
+
getUser: { method: 'POST', path: '/manifest/getUser' },
|
|
304
|
+
},
|
|
305
|
+
config: {
|
|
306
|
+
getUser: { method: 'POST', path: '/override/getUser' },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
310
|
+
wrapper: createWrapper(),
|
|
311
|
+
});
|
|
312
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
313
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/override/getUser', expect.objectContaining({ method: 'POST' }));
|
|
314
|
+
});
|
|
315
|
+
it('derives method config from an api object exposing manifest()', async () => {
|
|
316
|
+
const mockApi = {
|
|
317
|
+
manifest: () => ({
|
|
318
|
+
getUser: { method: 'PATCH', path: '/api/analytics/queries/getUser' },
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
321
|
+
const { useQuery } = createHooks({
|
|
322
|
+
baseUrl: 'https://api.example.com',
|
|
323
|
+
fetchFn: fetchMock,
|
|
324
|
+
api: mockApi,
|
|
325
|
+
});
|
|
326
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
327
|
+
wrapper: createWrapper(),
|
|
328
|
+
});
|
|
329
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
330
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({ method: 'PATCH' }));
|
|
331
|
+
});
|
|
332
|
+
it('throws for an unresolved semantic (dataset:) key', async () => {
|
|
333
|
+
const { useQuery } = createHooks({
|
|
334
|
+
baseUrl: 'https://api.example.com',
|
|
335
|
+
fetchFn: fetchMock,
|
|
336
|
+
});
|
|
337
|
+
const { result } = renderHook(() => useQuery('dataset:orders', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
338
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
339
|
+
expect(result.current.error?.message).toContain('No route configured for "dataset:orders"');
|
|
340
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
189
342
|
});
|
|
190
343
|
describe('Query Parameter Serialization', () => {
|
|
191
344
|
const fetchMock = vi.fn();
|
|
@@ -581,4 +734,39 @@ describe('createHooks', () => {
|
|
|
581
734
|
await expect(result.current.mutateAsync({ id: '123', name: 'John' })).rejects.toThrow();
|
|
582
735
|
});
|
|
583
736
|
});
|
|
737
|
+
describe('useInfiniteQuery', () => {
|
|
738
|
+
const fetchMock = vi.fn();
|
|
739
|
+
beforeEach(() => {
|
|
740
|
+
fetchMock.mockReset();
|
|
741
|
+
});
|
|
742
|
+
it('paginates using meta.pagination and advances the offset', async () => {
|
|
743
|
+
fetchMock
|
|
744
|
+
.mockResolvedValueOnce(mockSuccessResponse({
|
|
745
|
+
data: [{ id: 'a' }],
|
|
746
|
+
meta: { pagination: { limit: 1, offset: 0, hasMore: true } },
|
|
747
|
+
}))
|
|
748
|
+
.mockResolvedValueOnce(mockSuccessResponse({
|
|
749
|
+
data: [{ id: 'b' }],
|
|
750
|
+
meta: { pagination: { limit: 1, offset: 1, hasMore: false } },
|
|
751
|
+
}));
|
|
752
|
+
const { useInfiniteQuery } = createHooks({
|
|
753
|
+
baseUrl: 'https://example.com/api',
|
|
754
|
+
fetchFn: fetchMock,
|
|
755
|
+
config: { listItems: { method: 'POST', path: '/datasets/items/query' } },
|
|
756
|
+
});
|
|
757
|
+
const { result } = renderHook(() => useInfiniteQuery('listItems', { tags: [], limit: 1 }), { wrapper: createWrapper() });
|
|
758
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
759
|
+
// First page requests offset 0 and opts into meta.
|
|
760
|
+
expect(fetchMock.mock.calls[0][1]?.headers['x-include-meta']).toBe('true');
|
|
761
|
+
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ tags: [], limit: 1, offset: 0 }));
|
|
762
|
+
expect(result.current.hasNextPage).toBe(true);
|
|
763
|
+
await act(async () => {
|
|
764
|
+
await result.current.fetchNextPage();
|
|
765
|
+
});
|
|
766
|
+
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
|
767
|
+
// Second page advances offset by the page size.
|
|
768
|
+
expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ tags: [], limit: 1, offset: 1 }));
|
|
769
|
+
expect(result.current.data?.pages).toHaveLength(2);
|
|
770
|
+
});
|
|
771
|
+
});
|
|
584
772
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createHooks, queryOptions } from './createHooks.js';
|
|
2
|
+
export { createAnalyticsHooks } from './analyticsHooks.js';
|
|
2
3
|
export { HttpError } from './errors.js';
|
|
3
4
|
export type { QueryInput, QueryOutput, HttpMethod } from './types.js';
|
|
4
5
|
export type { CreateHooksConfig, QueryMethodConfig } from './createHooks.js';
|
|
6
|
+
export type { CreateAnalyticsHooksConfig } from './analyticsHooks.js';
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED