@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 CHANGED
@@ -1,377 +1,306 @@
1
1
  # @hypequery/react
2
2
 
3
- Type-safe React hooks for hypequery APIs. Wraps your generated API definition in thin TanStack Query shims so you can call `useQuery('weeklyRevenue')` with end-to-end type safety.
3
+ React hooks for typed Hypequery HTTP endpoints, built on TanStack Query.
4
4
 
5
- ## Installation
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
- ```ts
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
- export const { useQuery, useMutation } = createHooks<Api>({
21
- baseUrl: '/api',
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
- Wrap your app with TanStack Query's provider:
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
- ```tsx
28
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
41
+ export const api = serve({
42
+ queries: { weeklyRevenue },
43
+ });
29
44
 
30
- const queryClient = new QueryClient();
45
+ api.route('/weeklyRevenue', api.queries.weeklyRevenue);
31
46
 
32
- export function App({ children }: { children: React.ReactNode }) {
33
- return (
34
- <QueryClientProvider client={queryClient}>
35
- {children}
36
- </QueryClientProvider>
37
- );
38
- }
47
+ export type AnalyticsApi = typeof api;
39
48
  ```
40
49
 
41
- Use the hooks in your components:
50
+ Create hooks on the client:
42
51
 
43
52
  ```tsx
44
- // Query
45
- const { data, error, isLoading } = useQuery('weeklyRevenue', { startDate: '2025-01-01' });
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
- ```ts
88
- const { useQuery, useMutation } = createHooks<Api>({
89
- baseUrl: 'https://api.example.com',
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
- Type-safe wrapper around TanStack Query's `useQuery`. Automatically infers input and output types from your API definition.
105
-
106
- **Signatures:**
65
+ ```tsx
66
+ function RevenuePanel() {
67
+ const revenue = useQuery('weeklyRevenue', {
68
+ startDate: '2026-01-01',
69
+ });
107
70
 
108
- ```ts
109
- // No input required
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
- // Input required
114
- useQuery(name: Name, input: Input)
115
- useQuery(name: Name, input: Input, options: QueryOptions)
74
+ return <pre>{JSON.stringify(revenue.data, null, 2)}</pre>;
75
+ }
116
76
  ```
117
77
 
118
- **Parameters:**
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
- **Examples:**
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
- // Simple query with no input
130
- const { data } = useQuery('healthCheck');
83
+ import { createAnalyticsHooks } from '@hypequery/react';
84
+ import type { AnalyticsApi } from '../server/api.js';
131
85
 
132
- // Query with input
133
- const { data, error, isLoading } = useQuery('getUser', { id: '123' });
86
+ import { manifest } from '../server/api.js'; // serve api.manifest(), serialized
134
87
 
135
- // Query with options
136
- const { data } = useQuery('getUser', { id: '123' }, {
137
- enabled: isLoggedIn,
138
- staleTime: 60000,
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
- // Query without input but with options
143
- const { data } = useQuery('healthCheck', { refetchInterval: 5000 });
144
- ```
145
-
146
- ---
147
-
148
- ### `useMutation(name, options?)`
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
- Type-safe wrapper around TanStack Query's `useMutation`.
102
+ const orders = useDataset('orders', {
103
+ dimensions: ['status'],
104
+ measures: ['revenue', 'orderCount'],
105
+ });
151
106
 
152
- **Parameters:**
153
-
154
- - `name` - Mutation name from your API definition
155
- - `options` - TanStack Query mutation options (`onSuccess`, `onError`, `retry`, etc.)
107
+ return (
108
+ <pre>
109
+ {JSON.stringify({ revenue: revenue.data, orders: orders.data }, null, 2)}
110
+ </pre>
111
+ );
112
+ }
113
+ ```
156
114
 
157
- **Returns:** TanStack Query's `UseMutationResult<Output, HttpError, Input>`
115
+ ## Pagination
158
116
 
159
- **Examples:**
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
- // Basic mutation
163
- const createUser = useMutation('createUser');
164
- createUser.mutate({ name: 'Alice', email: 'alice@example.com' });
165
-
166
- // Mutation with callbacks
167
- const updateProfile = useMutation('updateProfile', {
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
- updateProfile.mutate({ bio: 'New bio' });
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
- **Parameters:**
148
+ ## Route Configuration
187
149
 
188
- - `options` - TanStack Query options object
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
- **Returns:** The same object with an internal symbol marker
154
+ ### Route manifest (recommended)
191
155
 
192
- **When to use:**
193
-
194
- If your API input has properties like `enabled`, `staleTime`, etc., the library might misclassify input as options. Use `queryOptions()` to disambiguate:
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
- // ✅ Explicit - this is input
201
- useQuery('getConfig', { enabled: true, staleTime: 5000 });
161
+ ```ts
162
+ // server side (server-only module)
163
+ export const manifest = api.manifest();
202
164
 
203
- // Explicit - these are options
204
- import { queryOptions } from '@hypequery/react';
205
- useQuery('getConfig', queryOptions({ enabled: true, staleTime: 5000 }));
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
- ### `HttpError`
177
+ #### Generating the manifest at build time
211
178
 
212
- Custom error class thrown by failed HTTP requests. Extends the standard `Error` class with additional properties.
213
-
214
- **Properties:**
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
- class HttpError extends Error {
218
- readonly status: number; // HTTP status code (404, 500, etc.)
219
- readonly body: unknown; // Parsed response body (JSON or text)
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
- **Usage:**
189
+ writeFileSync('src/generated/manifest.json', JSON.stringify(api.manifest(), null, 2));
190
+ ```
224
191
 
225
- ```tsx
226
- const { error } = useQuery('getUser', { id: '123' });
227
-
228
- if (error) {
229
- console.log(error.message); // "GET request to /api/getUser failed with status 404"
230
- console.log(error.status); // 404
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
- **Error handling example:**
243
-
244
- ```tsx
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
- return <div>{data.name}</div>;
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
- ## Advanced Usage
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
- ### Custom fetch implementation
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<Api>({
277
- baseUrl: '/api',
278
- fetchFn: async (url, init) => {
279
- // Add authentication token
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
- ### Default headers
234
+ `path` may be relative to `baseUrl`, absolute within the same origin, or an absolute HTTP URL.
293
235
 
294
- ```ts
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
- ### Dynamic headers
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 { useQuery } = createHooks<Api>({
308
- baseUrl: '/api',
309
- headers: () => {
310
- const key = getTenantKey();
311
- return key ? { 'x-tenant-key': key } : {};
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
- ### HTTP method configuration
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 { useQuery } = createHooks<Api>({
320
- baseUrl: '/api',
321
- config: {
322
- // Override default GET for specific queries
323
- refreshCache: { method: 'POST' },
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
- ### Auto-extract methods from API object
330
-
331
- If your API definition includes HTTP method information, pass it to auto-configure:
263
+ ## Query Options
332
264
 
333
- ```ts
334
- import { api } from '@/analytics/queries';
265
+ TanStack Query options can be passed as the final argument.
335
266
 
336
- const { useQuery } = createHooks({
337
- baseUrl: '/api',
338
- api, // Automatically extracts method config from api object
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
- All hooks are fully typed based on your API definition:
280
+ ```tsx
281
+ import { queryOptions } from '@hypequery/react';
347
282
 
348
- ```ts
349
- interface Api {
350
- getUser: {
351
- input: { id: string };
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
- const { useQuery } = createHooks<Api>({ baseUrl: '/api' });
288
+ ## Mutations
361
289
 
362
- // TypeScript infers input and output types
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
- // ❌ TypeScript error: missing required input
367
- const { data } = useQuery('getUser');
292
+ ```tsx
293
+ const refresh = useMutation('refreshReport');
368
294
 
369
- // ❌ TypeScript error: invalid property
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
- MIT
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=analyticsHooks.test.d.ts.map
@@ -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
+ });
@@ -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,EAGL,KAAK,eAAe,IAAI,uBAAuB,EAC/C,KAAK,kBAAkB,IAAI,0BAA0B,EACrD,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACpB,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;AAExC,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AACpD,KAAK,YAAY,GAAG,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC,CAAC;AAElD,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,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC3C,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;AAqED,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;wBAmEZ,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;EAW/E"}
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"}
@@ -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 buildUrl = (baseUrl, name) => {
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 baseUrl.endsWith('/') ? `${baseUrl}${name}` : `${baseUrl}/${name}`;
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
- const finalConfig = { ...deriveMethodConfig(api), ...explicitConfig };
64
- const fetchQuery = async (name, input, defaultMethod = 'GET') => {
65
- const url = buildUrl(baseUrl, name);
66
- const method = finalConfig[name]?.method ?? defaultMethod;
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 resolvedHeaders = resolveHeaders(headers);
88
- const res = await fetchFn(finalUrl, {
89
- method,
90
- headers: {
91
- ...resolvedHeaders,
92
- ...(body ? { 'content-type': 'application/json' } : {}),
93
- },
94
- body,
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
  }
@@ -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
@@ -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
@@ -1,2 +1,3 @@
1
1
  export { createHooks, queryOptions } from './createHooks.js';
2
+ export { createAnalyticsHooks } from './analyticsHooks.js';
2
3
  export { HttpError } from './errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/react",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "React hooks for consuming hypequery APIs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",