@hypequery/react 0.1.0 → 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,365 +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> | (() => Record<string, string>); // 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
- ---
63
+ Use them inside a component:
101
64
 
102
- ### `useQuery(name, input?, options?)`
103
-
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.)
78
+ ## Dataset And Metric Hooks
123
79
 
124
- **Returns:** TanStack Query's `UseQueryResult<Output, HttpError>`
125
-
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
- ---
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
+ });
147
101
 
148
- ### `useMutation(name, options?)`
102
+ const orders = useDataset('orders', {
103
+ dimensions: ['status'],
104
+ measures: ['revenue', 'orderCount'],
105
+ });
149
106
 
150
- Type-safe wrapper around TanStack Query's `useMutation`.
151
-
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.
185
-
186
- **Parameters:**
146
+ `input.limit` is the page size; `input.offset`, if provided, is the starting offset.
187
147
 
188
- - `options` - TanStack Query options object
148
+ ## Route Configuration
189
149
 
190
- **Returns:** The same object with an internal symbol marker
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`.
191
153
 
192
- **When to use:**
154
+ ### Route manifest (recommended)
193
155
 
194
- If your API input has properties like `enabled`, `staleTime`, etc., the library might misclassify input as options. Use `queryOptions()` to disambiguate:
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.
195
160
 
196
- ```tsx
197
- // Ambiguous - could be input or options?
198
- useQuery('getConfig', { enabled: true, staleTime: 5000 });
199
-
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
- ---
209
-
210
- ### `HttpError`
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.
211
176
 
212
- Custom error class thrown by failed HTTP requests. Extends the standard `Error` class with additional properties.
177
+ #### Generating the manifest at build time
213
178
 
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.
215
+
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.
270
219
 
271
- ## Advanced Usage
220
+ ### Explicit config
272
221
 
273
- ### Custom fetch implementation
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.
235
+
236
+ ## Headers & auth
237
+
238
+ Pass static headers, or a function (sync or async) invoked per request — handy for
239
+ attaching a fresh/short-lived token.
293
240
 
294
241
  ```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
- },
242
+ const hooks = createHooks<AnalyticsApi>({
243
+ baseUrl: '/api/analytics',
244
+ headers: async () => ({
245
+ authorization: `Bearer ${await getAccessToken()}`,
246
+ }),
301
247
  });
302
248
  ```
303
249
 
304
- ### HTTP method configuration
250
+ Provide `onUnauthorized` to refresh credentials on a `401` and retry the request once
251
+ with freshly resolved headers:
305
252
 
306
253
  ```ts
307
- const { useQuery } = createHooks<Api>({
308
- baseUrl: '/api',
309
- config: {
310
- // Override default GET for specific queries
311
- refreshCache: { method: 'POST' },
312
- 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
313
259
  },
314
260
  });
315
261
  ```
316
262
 
317
- ### Auto-extract methods from API object
318
-
319
- If your API definition includes HTTP method information, pass it to auto-configure:
263
+ ## Query Options
320
264
 
321
- ```ts
322
- import { api } from '@/analytics/queries';
265
+ TanStack Query options can be passed as the final argument.
323
266
 
324
- const { useQuery } = createHooks({
325
- baseUrl: '/api',
326
- api, // Automatically extracts method config from api object
327
- });
267
+ ```tsx
268
+ const result = useQuery(
269
+ 'weeklyRevenue',
270
+ { startDate: '2026-01-01' },
271
+ {
272
+ staleTime: 60_000,
273
+ refetchOnWindowFocus: false,
274
+ },
275
+ );
328
276
  ```
329
277
 
330
- ---
331
-
332
- ## TypeScript
278
+ For no-input queries, use `queryOptions()` when an options object might look like input.
333
279
 
334
- All hooks are fully typed based on your API definition:
280
+ ```tsx
281
+ import { queryOptions } from '@hypequery/react';
335
282
 
336
- ```ts
337
- interface Api {
338
- getUser: {
339
- input: { id: string };
340
- output: { name: string; email: string };
341
- };
342
- weeklyRevenue: {
343
- input: { startDate: string };
344
- output: { total: number };
345
- };
346
- }
283
+ const result = useQuery('health', queryOptions({
284
+ staleTime: 30_000,
285
+ }));
286
+ ```
347
287
 
348
- const { useQuery } = createHooks<Api>({ baseUrl: '/api' });
288
+ ## Mutations
349
289
 
350
- // TypeScript infers input and output types
351
- const { data } = useQuery('getUser', { id: '123' });
352
- // ^? { 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.
353
291
 
354
- // ❌ TypeScript error: missing required input
355
- const { data } = useQuery('getUser');
292
+ ```tsx
293
+ const refresh = useMutation('refreshReport');
356
294
 
357
- // ❌ TypeScript error: invalid property
358
- const { data } = useQuery('getUser', { userId: '123' });
295
+ refresh.mutate({ reportId: 'revenue' });
359
296
  ```
360
297
 
361
- ---
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.
362
303
 
363
304
  ## License
364
305
 
365
- 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,17 +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>;
19
+ type HeaderMap = Record<string, string | undefined>;
20
+ type HeadersInput = HeaderMap | (() => HeaderMap | Promise<HeaderMap>);
7
21
  export interface CreateHooksConfig<TApi = Record<string, {
8
22
  input: unknown;
9
23
  output: unknown;
10
24
  }>> {
11
25
  baseUrl: string;
12
26
  fetchFn?: typeof fetch;
13
- headers?: Record<string, string> | (() => Record<string, string>);
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
+ */
31
+ headers?: HeadersInput;
14
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>;
15
45
  api?: TApi;
16
46
  }
17
47
  declare const OPTIONS_SYMBOL: unique symbol;
@@ -24,6 +54,7 @@ export declare function createHooks<Api extends Record<string, {
24
54
  }>>(config: CreateHooksConfig<Api>): {
25
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>;
26
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>;
27
58
  };
28
59
  export {};
29
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,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,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClE,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;AA6DD,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,12 +76,31 @@ const looksLikeQueryOptions = (value) => {
52
76
  const matches = optionKeys.filter((key) => key in value).length;
53
77
  return matches >= 2;
54
78
  };
79
+ const resolveHeaders = async (headers) => {
80
+ if (!headers)
81
+ return {};
82
+ const raw = typeof headers === 'function' ? await headers() : headers;
83
+ return Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== undefined));
84
+ };
55
85
  export function createHooks(config) {
56
- const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, api } = config;
57
- const finalConfig = { ...deriveMethodConfig(api), ...explicitConfig };
58
- const fetchQuery = async (name, input, defaultMethod = 'GET') => {
59
- const url = buildUrl(baseUrl, name);
60
- 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;
61
104
  let finalUrl = url;
62
105
  let body;
63
106
  if (method === 'GET' && input && typeof input === 'object') {
@@ -78,15 +121,24 @@ export function createHooks(config) {
78
121
  else if (input !== undefined) {
79
122
  body = JSON.stringify(input);
80
123
  }
81
- const resolvedHeaders = typeof headers === 'function' ? headers() : headers;
82
- const res = await fetchFn(finalUrl, {
83
- method,
84
- headers: {
85
- ...resolvedHeaders,
86
- ...(body ? { 'content-type': 'application/json' } : {}),
87
- },
88
- body,
89
- });
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
+ }
90
142
  if (!res.ok) {
91
143
  const errorBody = await parseResponse(res);
92
144
  throw new HttpError(`${method} request to ${finalUrl} failed with status ${res.status}`, res.status, errorBody);
@@ -127,8 +179,31 @@ export function createHooks(config) {
127
179
  ...options,
128
180
  });
129
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
+ }
130
204
  return {
131
205
  useQuery,
132
206
  useMutation,
207
+ useInfiniteQuery,
133
208
  };
134
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.0",
3
+ "version": "0.2.0",
4
4
  "description": "React hooks for consuming hypequery APIs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",