@hypequery/react 0.1.1 → 0.3.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.
@@ -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();
@@ -378,7 +531,7 @@ describe('createHooks', () => {
378
531
  expect(fetchMock).toHaveBeenCalled();
379
532
  fetchMock.mockClear();
380
533
  // Options object should disable the query
381
- const { result: result2 } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 1000 }), { wrapper: createWrapper() });
534
+ renderHook(() => useQuery('noInput', { enabled: false, staleTime: 1000 }), { wrapper: createWrapper() });
382
535
  expect(fetchMock).not.toHaveBeenCalled();
383
536
  });
384
537
  it('uses queryOptions() helper for explicit option marking', async () => {
@@ -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/dist/types.d.ts CHANGED
@@ -1,9 +1,62 @@
1
1
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2
- export type ExtractNames<Api> = Extract<keyof Api, string>;
2
+ export type KnownStringKeys<T> = {
3
+ [K in keyof T]: string extends K ? never : K extends string ? K : never;
4
+ }[keyof T];
5
+ export type ExtractNames<Api> = [KnownStringKeys<Api>] extends [never] ? Extract<keyof Api, string> : KnownStringKeys<Api>;
3
6
  export type QueryInput<Api, Name extends ExtractNames<Api>> = Api[Name] extends {
4
7
  input: infer Input;
5
8
  } ? Input : never;
6
9
  export type QueryOutput<Api, Name extends ExtractNames<Api>> = Api[Name] extends {
7
10
  output: infer Output;
8
11
  } ? Output : never;
12
+ type SemanticInfo<Api, Name extends ExtractNames<Api>> = Api[Name] extends {
13
+ readonly __hypequerySemantic?: infer Info;
14
+ } ? NonNullable<Info> : never;
15
+ type DimensionValue<TDefinition> = TDefinition extends {
16
+ fieldType: 'string';
17
+ } ? string : TDefinition extends {
18
+ fieldType: 'number';
19
+ } ? number : TDefinition extends {
20
+ fieldType: 'boolean';
21
+ } ? boolean : TDefinition extends {
22
+ fieldType: 'timestamp';
23
+ } ? string : unknown;
24
+ type SelectedDimensions<TDimensions, TInput> = TInput extends {
25
+ dimensions: readonly (infer TName)[];
26
+ } ? Extract<TName, KnownStringKeys<TDimensions>> : never;
27
+ type SelectedMeasures<TMeasures, TInput> = TInput extends {
28
+ measures: readonly (infer TName)[];
29
+ } ? Extract<TName, KnownStringKeys<TMeasures>> : KnownStringKeys<TMeasures>;
30
+ type PeriodSelection<TInput> = TInput extends {
31
+ by: string;
32
+ } ? {
33
+ period?: string;
34
+ } : {};
35
+ type WithTypedData<TOutput, TRow> = Omit<TOutput, 'data'> & {
36
+ data: TRow[];
37
+ };
38
+ type DatasetOutputForInput<TInfo, TInput, TFallback> = TInfo extends {
39
+ kind: 'dataset';
40
+ dimensions: infer TDimensions;
41
+ measures: infer TMeasures;
42
+ } ? WithTypedData<TFallback, {
43
+ [K in SelectedDimensions<TDimensions, TInput>]?: DimensionValue<TDimensions[K]>;
44
+ } & {
45
+ [K in SelectedMeasures<TMeasures, TInput>]?: number;
46
+ } & PeriodSelection<TInput>> : TFallback;
47
+ type MetricOutputForInput<TInfo, TInput, TFallback> = TInfo extends {
48
+ kind: 'metric';
49
+ dimensions: infer TDimensions;
50
+ metricName: infer TMetricName extends string;
51
+ } ? WithTypedData<TFallback, {
52
+ [K in SelectedDimensions<TDimensions, TInput>]?: DimensionValue<TDimensions[K]>;
53
+ } & {
54
+ [K in TMetricName]?: number;
55
+ } & PeriodSelection<TInput>> : TFallback;
56
+ export type QueryOutputForInput<Api, Name extends ExtractNames<Api>, TInput> = SemanticInfo<Api, Name> extends infer Info ? Info extends {
57
+ kind: 'dataset';
58
+ } ? DatasetOutputForInput<Info, TInput, QueryOutput<Api, Name>> : Info extends {
59
+ kind: 'metric';
60
+ } ? MetricOutputForInput<Info, TInput, QueryOutput<Api, Name>> : QueryOutput<Api, Name> : QueryOutput<Api, Name>;
61
+ export {};
9
62
  //# sourceMappingURL=types.d.ts.map