@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.
- package/README.md +205 -274
- package/dist/analyticsHooks.d.ts +34 -0
- package/dist/analyticsHooks.d.ts.map +1 -0
- package/dist/analyticsHooks.js +27 -0
- package/dist/analyticsHooks.test.d.ts +2 -0
- package/dist/analyticsHooks.test.d.ts.map +1 -0
- package/dist/analyticsHooks.test.js +98 -0
- package/dist/createHooks.d.ts +31 -2
- package/dist/createHooks.d.ts.map +1 -1
- package/dist/createHooks.js +89 -20
- package/dist/createHooks.test.js +190 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +54 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyticsHooks.test.d.ts","sourceRoot":"","sources":["../src/analyticsHooks.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { createAnalyticsHooks } from './analyticsHooks.js';
|
|
6
|
+
function createWrapper() {
|
|
7
|
+
const queryClient = new QueryClient({
|
|
8
|
+
defaultOptions: {
|
|
9
|
+
queries: { retry: false },
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
return ({ children }) => (_jsx(QueryClientProvider, { client: queryClient, children: children }));
|
|
13
|
+
}
|
|
14
|
+
function mockSuccessResponse(data) {
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
status: 200,
|
|
18
|
+
json: () => Promise.resolve(data),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe('createAnalyticsHooks', () => {
|
|
22
|
+
const fetchMock = vi.fn();
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
fetchMock.mockReset();
|
|
25
|
+
});
|
|
26
|
+
it('queries metrics through useMetric with metric names only', async () => {
|
|
27
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ totalRevenue: 42 }] }));
|
|
28
|
+
const { useMetric } = createAnalyticsHooks({
|
|
29
|
+
baseUrl: '/api/analytics',
|
|
30
|
+
fetchFn: fetchMock,
|
|
31
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
32
|
+
config: {
|
|
33
|
+
totalRevenue: { method: 'POST', path: '/api/analytics/metrics/totalRevenue' },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const { result } = renderHook(() => useMetric('totalRevenue', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(result.current.data).toEqual({ data: [{ totalRevenue: 42 }] });
|
|
39
|
+
});
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/analytics/metrics/totalRevenue', expect.objectContaining({
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body: JSON.stringify({ dimensions: ['country'] }),
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
it('queries datasets through useDataset without exposing dataset: keys', async () => {
|
|
46
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ country: 'US', revenue: 120 }] }));
|
|
47
|
+
const { useDataset } = createAnalyticsHooks({
|
|
48
|
+
baseUrl: '/api/analytics',
|
|
49
|
+
fetchFn: fetchMock,
|
|
50
|
+
metrics: [],
|
|
51
|
+
config: {
|
|
52
|
+
'dataset:orders': { method: 'POST', path: '/api/analytics/datasets/orders/query' },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const { result } = renderHook(() => useDataset('orders', { dimensions: ['country'], measures: ['revenue'] }), { wrapper: createWrapper() });
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(result.current.data).toEqual({ data: [{ country: 'US', revenue: 120 }] });
|
|
58
|
+
});
|
|
59
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/analytics/datasets/orders/query', expect.objectContaining({
|
|
60
|
+
method: 'POST',
|
|
61
|
+
body: JSON.stringify({ dimensions: ['country'], measures: ['revenue'] }),
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
it('supports metric names from the declared semantic metric list', async () => {
|
|
65
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ period: '2025-01-01', monthlyRevenue: 90 }] }));
|
|
66
|
+
const { useMetric } = createAnalyticsHooks({
|
|
67
|
+
baseUrl: '/api/analytics',
|
|
68
|
+
fetchFn: fetchMock,
|
|
69
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
70
|
+
config: {
|
|
71
|
+
monthlyRevenue: { method: 'POST', path: '/api/analytics/metrics/monthlyRevenue' },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const { result } = renderHook(() => useMetric('monthlyRevenue', { by: 'month' }), { wrapper: createWrapper() });
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(result.current.data).toEqual({ data: [{ period: '2025-01-01', monthlyRevenue: 90 }] });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('resolves semantic config paths against an absolute baseUrl host', async () => {
|
|
80
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ data: [{ totalRevenue: 42 }] }));
|
|
81
|
+
const { useMetric } = createAnalyticsHooks({
|
|
82
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
83
|
+
fetchFn: fetchMock,
|
|
84
|
+
metrics: ['totalRevenue', 'monthlyRevenue'],
|
|
85
|
+
config: {
|
|
86
|
+
totalRevenue: { method: 'POST', path: '/api/analytics/metrics/totalRevenue' },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const { result } = renderHook(() => useMetric('totalRevenue', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(result.current.data).toEqual({ data: [{ totalRevenue: 42 }] });
|
|
92
|
+
});
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/metrics/totalRevenue', expect.objectContaining({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: JSON.stringify({ dimensions: ['country'] }),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
});
|
package/dist/createHooks.d.ts
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
|
-
import { type UseQueryOptions as TanstackUseQueryOptions, type UseMutationOptions as TanstackUseMutationOptions, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query';
|
|
1
|
+
import { type UseQueryOptions as TanstackUseQueryOptions, type UseMutationOptions as TanstackUseMutationOptions, type UseInfiniteQueryOptions as TanstackUseInfiniteQueryOptions, type UseMutationResult, type UseQueryResult, type UseInfiniteQueryResult, type InfiniteData } from '@tanstack/react-query';
|
|
2
2
|
import type { ExtractNames, QueryInput, QueryOutput } from './types.js';
|
|
3
3
|
import { HttpError } from './errors.js';
|
|
4
4
|
export interface QueryMethodConfig {
|
|
5
5
|
method?: string;
|
|
6
|
+
path?: string;
|
|
6
7
|
}
|
|
8
|
+
/** A single route as produced by `@hypequery/serve`'s `api.manifest()`. */
|
|
9
|
+
export interface RouteManifestEntry {
|
|
10
|
+
method?: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Map of query/metric/dataset keys to their HTTP method and full path. Pair
|
|
15
|
+
* with `InferAPIType` and `api.manifest()` from `@hypequery/serve` to resolve
|
|
16
|
+
* routes on the client without importing server code into the bundle.
|
|
17
|
+
*/
|
|
18
|
+
export type RouteManifest = Record<string, RouteManifestEntry>;
|
|
7
19
|
type HeaderMap = Record<string, string | undefined>;
|
|
8
|
-
type HeadersInput = HeaderMap | (() => HeaderMap);
|
|
20
|
+
type HeadersInput = HeaderMap | (() => HeaderMap | Promise<HeaderMap>);
|
|
9
21
|
export interface CreateHooksConfig<TApi = Record<string, {
|
|
10
22
|
input: unknown;
|
|
11
23
|
output: unknown;
|
|
12
24
|
}>> {
|
|
13
25
|
baseUrl: string;
|
|
14
26
|
fetchFn?: typeof fetch;
|
|
27
|
+
/**
|
|
28
|
+
* Static headers, or a (optionally async) function returning headers. The
|
|
29
|
+
* function is invoked per request, so it can supply a fresh/short-lived token.
|
|
30
|
+
*/
|
|
15
31
|
headers?: HeadersInput;
|
|
16
32
|
config?: Record<string, QueryMethodConfig>;
|
|
33
|
+
/**
|
|
34
|
+
* Route manifest from `@hypequery/serve`'s `api.manifest()`. Resolves the
|
|
35
|
+
* method and full path for each key — required for metric/dataset endpoints,
|
|
36
|
+
* which are POST routes whose paths differ from their map keys.
|
|
37
|
+
*/
|
|
38
|
+
manifest?: RouteManifest;
|
|
39
|
+
/**
|
|
40
|
+
* Called when a request returns 401. Use it to refresh credentials (e.g. a
|
|
41
|
+
* token). If it resolves without throwing, the request is retried once with
|
|
42
|
+
* freshly resolved headers.
|
|
43
|
+
*/
|
|
44
|
+
onUnauthorized?: () => void | Promise<void>;
|
|
17
45
|
api?: TApi;
|
|
18
46
|
}
|
|
19
47
|
declare const OPTIONS_SYMBOL: unique symbol;
|
|
@@ -26,6 +54,7 @@ export declare function createHooks<Api extends Record<string, {
|
|
|
26
54
|
}>>(config: CreateHooksConfig<Api>): {
|
|
27
55
|
readonly useQuery: <Name extends ExtractNames<Api>>(...args: QueryInput<Api, Name> extends never ? [name: Name, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined] : [name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined]) => UseQueryResult<QueryOutput<Api, Name>, HttpError>;
|
|
28
56
|
readonly useMutation: <Name extends ExtractNames<Api>>(name: Name, options?: Omit<TanstackUseMutationOptions<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>, unknown>, "mutationFn">) => UseMutationResult<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>>;
|
|
57
|
+
readonly useInfiniteQuery: <Name extends ExtractNames<Api>>(name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseInfiniteQueryOptions<QueryOutput<Api, Name>, HttpError, InfiniteData<QueryOutput<Api, Name>, number>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>], number>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam">) => UseInfiniteQueryResult<InfiniteData<QueryOutput<Api, Name>, number>, HttpError>;
|
|
29
58
|
};
|
|
30
59
|
export {};
|
|
31
60
|
//# sourceMappingURL=createHooks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.tsx"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,eAAe,IAAI,uBAAuB,EAC/C,KAAK,kBAAkB,IAAI,0BAA0B,EACrD,KAAK,uBAAuB,IAAI,+BAA+B,EAC/D,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAUxC,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,2EAA2E;AAC3E,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;AAE/D,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AACpD,KAAK,YAAY,GACb,SAAS,GACT,CAAC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;AAE3C,MAAM,WAAW,iBAAiB,CAAC,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;IAC3F,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,QAAA,MAAM,cAAc,eAAkC,CAAC;AAEvD,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG;IAAE,CAAC,cAAc,CAAC,EAAE,IAAI,CAAA;CAAE,CAEtF;AAqGD,wBAAgB,WAAW,CAAC,GAAG,SAAS,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EACjF,MAAM,EAAE,iBAAiB,CAAC,GAAG,CAAC;wBAgGZ,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,+kBAE7C,cAAc,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC;2BAwC/B,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,QAC3C,IAAI,kIAET,iBAAiB,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;gCAwBpD,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,QAChD,IAAI,SACH,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,yTAE3B,sBAAsB,CAAC,YAAY,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC;EA4BnF"}
|
package/dist/createHooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useQuery as useTanstackQuery, useMutation as useTanstackMutation, } from '@tanstack/react-query';
|
|
1
|
+
import { useQuery as useTanstackQuery, useMutation as useTanstackMutation, useInfiniteQuery as useTanstackInfiniteQuery, } from '@tanstack/react-query';
|
|
2
2
|
import { HttpError } from './errors.js';
|
|
3
3
|
const OPTIONS_SYMBOL = Symbol.for('hypequery-options');
|
|
4
4
|
export function queryOptions(opts) {
|
|
@@ -7,12 +7,19 @@ export function queryOptions(opts) {
|
|
|
7
7
|
const normalizeMethodConfig = (source) => {
|
|
8
8
|
if (!source)
|
|
9
9
|
return {};
|
|
10
|
-
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, { method: value.method ?? 'GET' }]));
|
|
10
|
+
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, { method: value.method ?? 'GET', path: value.path }]));
|
|
11
11
|
};
|
|
12
12
|
const deriveMethodConfig = (api) => {
|
|
13
13
|
if (typeof api !== 'object' || api === null) {
|
|
14
14
|
return {};
|
|
15
15
|
}
|
|
16
|
+
// Preferred: the serve API exposes a manifest() with full method + path.
|
|
17
|
+
if (typeof api.manifest === 'function') {
|
|
18
|
+
const manifest = api.manifest();
|
|
19
|
+
if (manifest && typeof manifest === 'object') {
|
|
20
|
+
return normalizeMethodConfig(manifest);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
16
23
|
if ('_routeConfig' in api &&
|
|
17
24
|
typeof api._routeConfig === 'object' &&
|
|
18
25
|
api._routeConfig !== null) {
|
|
@@ -25,11 +32,28 @@ const deriveMethodConfig = (api) => {
|
|
|
25
32
|
}
|
|
26
33
|
return {};
|
|
27
34
|
};
|
|
28
|
-
const
|
|
35
|
+
const isAbsoluteHttpUrl = (value) => /^https?:\/\//.test(value);
|
|
36
|
+
const ensureTrailingSlash = (value) => value.endsWith('/') ? value : `${value}/`;
|
|
37
|
+
const buildUrl = (baseUrl, name, path) => {
|
|
38
|
+
if (path) {
|
|
39
|
+
if (isAbsoluteHttpUrl(path)) {
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
if (isAbsoluteHttpUrl(baseUrl)) {
|
|
43
|
+
return new URL(path, ensureTrailingSlash(baseUrl)).toString();
|
|
44
|
+
}
|
|
45
|
+
if (path.startsWith('/')) {
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
if (!baseUrl) {
|
|
49
|
+
throw new Error('baseUrl is required');
|
|
50
|
+
}
|
|
51
|
+
return `${ensureTrailingSlash(baseUrl)}${path}`;
|
|
52
|
+
}
|
|
29
53
|
if (!baseUrl) {
|
|
30
54
|
throw new Error('baseUrl is required');
|
|
31
55
|
}
|
|
32
|
-
return
|
|
56
|
+
return `${ensureTrailingSlash(baseUrl)}${name}`;
|
|
33
57
|
};
|
|
34
58
|
const parseResponse = async (res) => {
|
|
35
59
|
const text = await res.text();
|
|
@@ -52,18 +76,31 @@ const looksLikeQueryOptions = (value) => {
|
|
|
52
76
|
const matches = optionKeys.filter((key) => key in value).length;
|
|
53
77
|
return matches >= 2;
|
|
54
78
|
};
|
|
55
|
-
const resolveHeaders = (headers) => {
|
|
79
|
+
const resolveHeaders = async (headers) => {
|
|
56
80
|
if (!headers)
|
|
57
81
|
return {};
|
|
58
|
-
const raw = typeof headers === 'function' ? headers() : headers;
|
|
82
|
+
const raw = typeof headers === 'function' ? await headers() : headers;
|
|
59
83
|
return Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== undefined));
|
|
60
84
|
};
|
|
61
85
|
export function createHooks(config) {
|
|
62
|
-
const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, api } = config;
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, manifest, onUnauthorized, api } = config;
|
|
87
|
+
// Precedence: explicit config > manifest > derived from a runtime api object.
|
|
88
|
+
const finalConfig = {
|
|
89
|
+
...deriveMethodConfig(api),
|
|
90
|
+
...(manifest ? normalizeMethodConfig(manifest) : {}),
|
|
91
|
+
...explicitConfig,
|
|
92
|
+
};
|
|
93
|
+
const fetchQuery = async (name, input, defaultMethod = 'GET', extraHeaders) => {
|
|
94
|
+
const methodConfig = finalConfig[name];
|
|
95
|
+
// Semantic endpoints (dataset:<name>) live at paths that differ from their
|
|
96
|
+
// map key, so without a resolved path we'd call the wrong URL. Fail loudly
|
|
97
|
+
// instead of silently requesting `${baseUrl}/dataset:<name>`.
|
|
98
|
+
if (name.includes(':') && !methodConfig?.path) {
|
|
99
|
+
throw new Error(`No route configured for "${name}". Pass \`manifest\` (from the serve ` +
|
|
100
|
+
`api.manifest()) or an explicit \`config\` entry to createHooks().`);
|
|
101
|
+
}
|
|
102
|
+
const url = buildUrl(baseUrl, name, methodConfig?.path);
|
|
103
|
+
const method = methodConfig?.method ?? defaultMethod;
|
|
67
104
|
let finalUrl = url;
|
|
68
105
|
let body;
|
|
69
106
|
if (method === 'GET' && input && typeof input === 'object') {
|
|
@@ -84,15 +121,24 @@ export function createHooks(config) {
|
|
|
84
121
|
else if (input !== undefined) {
|
|
85
122
|
body = JSON.stringify(input);
|
|
86
123
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
const attempt = async () => {
|
|
125
|
+
const resolvedHeaders = await resolveHeaders(headers);
|
|
126
|
+
return fetchFn(finalUrl, {
|
|
127
|
+
method,
|
|
128
|
+
headers: {
|
|
129
|
+
...resolvedHeaders,
|
|
130
|
+
...(extraHeaders ?? {}),
|
|
131
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
132
|
+
},
|
|
133
|
+
body,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
let res = await attempt();
|
|
137
|
+
// On 401, give the caller a chance to refresh credentials and retry once.
|
|
138
|
+
if (res.status === 401 && onUnauthorized) {
|
|
139
|
+
await onUnauthorized();
|
|
140
|
+
res = await attempt();
|
|
141
|
+
}
|
|
96
142
|
if (!res.ok) {
|
|
97
143
|
const errorBody = await parseResponse(res);
|
|
98
144
|
throw new HttpError(`${method} request to ${finalUrl} failed with status ${res.status}`, res.status, errorBody);
|
|
@@ -133,8 +179,31 @@ export function createHooks(config) {
|
|
|
133
179
|
...options,
|
|
134
180
|
});
|
|
135
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Offset-paginated query for semantic endpoints. Pages are advanced using the
|
|
184
|
+
* `meta.pagination` returned by the server (the request opts into meta via the
|
|
185
|
+
* `x-include-meta` header). `input.limit` is the page size; `input.offset`, if
|
|
186
|
+
* provided, is the starting offset.
|
|
187
|
+
*/
|
|
188
|
+
function useInfiniteQuery(name, input, options) {
|
|
189
|
+
const initialOffset = input?.offset ?? 0;
|
|
190
|
+
const queryKey = ['hypequery', name, input];
|
|
191
|
+
return useTanstackInfiniteQuery({
|
|
192
|
+
queryKey,
|
|
193
|
+
initialPageParam: initialOffset,
|
|
194
|
+
queryFn: ({ pageParam }) => fetchQuery(name, { ...input, offset: pageParam }, 'POST', { 'x-include-meta': 'true' }),
|
|
195
|
+
getNextPageParam: (lastPage) => {
|
|
196
|
+
const pagination = lastPage.meta?.pagination;
|
|
197
|
+
if (!pagination || !pagination.hasMore)
|
|
198
|
+
return undefined;
|
|
199
|
+
return pagination.offset + pagination.limit;
|
|
200
|
+
},
|
|
201
|
+
...(options ?? {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
136
204
|
return {
|
|
137
205
|
useQuery,
|
|
138
206
|
useMutation,
|
|
207
|
+
useInfiniteQuery,
|
|
139
208
|
};
|
|
140
209
|
}
|
package/dist/createHooks.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
-
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
4
4
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
5
|
import { createHooks, queryOptions } from './createHooks.js';
|
|
6
6
|
import { HttpError } from './errors.js';
|
|
@@ -93,6 +93,57 @@ describe('createHooks', () => {
|
|
|
93
93
|
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ 'x-token': 'first' });
|
|
94
94
|
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ 'x-token': 'second' });
|
|
95
95
|
});
|
|
96
|
+
it('supports an async header factory', async () => {
|
|
97
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ name: 'User' }));
|
|
98
|
+
const { useQuery } = createHooks({
|
|
99
|
+
baseUrl: 'https://example.com/api',
|
|
100
|
+
fetchFn: fetchMock,
|
|
101
|
+
headers: async () => ({ authorization: 'Bearer fresh-token' }),
|
|
102
|
+
});
|
|
103
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
104
|
+
wrapper: createWrapper(),
|
|
105
|
+
});
|
|
106
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
107
|
+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ authorization: 'Bearer fresh-token' });
|
|
108
|
+
});
|
|
109
|
+
it('calls onUnauthorized and retries once on 401', async () => {
|
|
110
|
+
fetchMock
|
|
111
|
+
.mockResolvedValueOnce(mockErrorResponse(401, { error: 'expired' }))
|
|
112
|
+
.mockResolvedValueOnce(mockSuccessResponse({ name: 'User' }));
|
|
113
|
+
let token = 'stale';
|
|
114
|
+
const onUnauthorized = vi.fn(async () => { token = 'refreshed'; });
|
|
115
|
+
const { useQuery } = createHooks({
|
|
116
|
+
baseUrl: 'https://example.com/api',
|
|
117
|
+
fetchFn: fetchMock,
|
|
118
|
+
headers: () => ({ authorization: `Bearer ${token}` }),
|
|
119
|
+
onUnauthorized,
|
|
120
|
+
});
|
|
121
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
122
|
+
wrapper: createWrapper(),
|
|
123
|
+
});
|
|
124
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
125
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
127
|
+
// The retry uses freshly resolved headers.
|
|
128
|
+
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ authorization: 'Bearer refreshed' });
|
|
129
|
+
expect(result.current.data).toEqual({ name: 'User' });
|
|
130
|
+
});
|
|
131
|
+
it('throws after a single 401 retry still fails', async () => {
|
|
132
|
+
fetchMock.mockResolvedValue(mockErrorResponse(401, { error: 'nope' }));
|
|
133
|
+
const onUnauthorized = vi.fn(async () => { });
|
|
134
|
+
const { useQuery } = createHooks({
|
|
135
|
+
baseUrl: 'https://example.com/api',
|
|
136
|
+
fetchFn: fetchMock,
|
|
137
|
+
onUnauthorized,
|
|
138
|
+
});
|
|
139
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '1' }), {
|
|
140
|
+
wrapper: createWrapper(),
|
|
141
|
+
});
|
|
142
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
143
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
145
|
+
expect(result.current.error?.status).toBe(401);
|
|
146
|
+
});
|
|
96
147
|
});
|
|
97
148
|
describe('HTTP Method Handling', () => {
|
|
98
149
|
const fetchMock = vi.fn();
|
|
@@ -186,6 +237,108 @@ describe('createHooks', () => {
|
|
|
186
237
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
187
238
|
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({ method: 'POST' }));
|
|
188
239
|
});
|
|
240
|
+
it('resolves root-relative config paths against an absolute baseUrl host', async () => {
|
|
241
|
+
const { useQuery } = createHooks({
|
|
242
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
243
|
+
fetchFn: fetchMock,
|
|
244
|
+
config: {
|
|
245
|
+
getUser: { method: 'POST', path: '/api/analytics/queries/getUser' },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
249
|
+
wrapper: createWrapper(),
|
|
250
|
+
});
|
|
251
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
252
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({
|
|
253
|
+
method: 'POST',
|
|
254
|
+
body: JSON.stringify({ id: '123' }),
|
|
255
|
+
}));
|
|
256
|
+
});
|
|
257
|
+
it('resolves relative config paths against baseUrl', async () => {
|
|
258
|
+
const { useQuery } = createHooks({
|
|
259
|
+
baseUrl: 'https://api.example.com/hypequery',
|
|
260
|
+
fetchFn: fetchMock,
|
|
261
|
+
config: {
|
|
262
|
+
getUser: { method: 'POST', path: 'queries/getUser' },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
266
|
+
wrapper: createWrapper(),
|
|
267
|
+
});
|
|
268
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
269
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/hypequery/queries/getUser', expect.objectContaining({
|
|
270
|
+
method: 'POST',
|
|
271
|
+
body: JSON.stringify({ id: '123' }),
|
|
272
|
+
}));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('Route manifest', () => {
|
|
276
|
+
const fetchMock = vi.fn();
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
fetchMock.mockReset();
|
|
279
|
+
fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
|
|
280
|
+
});
|
|
281
|
+
it('resolves method and path from a manifest', async () => {
|
|
282
|
+
const { useQuery } = createHooks({
|
|
283
|
+
baseUrl: 'https://api.example.com',
|
|
284
|
+
fetchFn: fetchMock,
|
|
285
|
+
manifest: {
|
|
286
|
+
getUser: { method: 'POST', path: '/api/analytics/metrics/getUser' },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
290
|
+
wrapper: createWrapper(),
|
|
291
|
+
});
|
|
292
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
293
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/metrics/getUser', expect.objectContaining({
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: JSON.stringify({ id: '123' }),
|
|
296
|
+
}));
|
|
297
|
+
});
|
|
298
|
+
it('lets explicit config override the manifest', async () => {
|
|
299
|
+
const { useQuery } = createHooks({
|
|
300
|
+
baseUrl: 'https://api.example.com',
|
|
301
|
+
fetchFn: fetchMock,
|
|
302
|
+
manifest: {
|
|
303
|
+
getUser: { method: 'POST', path: '/manifest/getUser' },
|
|
304
|
+
},
|
|
305
|
+
config: {
|
|
306
|
+
getUser: { method: 'POST', path: '/override/getUser' },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
310
|
+
wrapper: createWrapper(),
|
|
311
|
+
});
|
|
312
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
313
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/override/getUser', expect.objectContaining({ method: 'POST' }));
|
|
314
|
+
});
|
|
315
|
+
it('derives method config from an api object exposing manifest()', async () => {
|
|
316
|
+
const mockApi = {
|
|
317
|
+
manifest: () => ({
|
|
318
|
+
getUser: { method: 'PATCH', path: '/api/analytics/queries/getUser' },
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
321
|
+
const { useQuery } = createHooks({
|
|
322
|
+
baseUrl: 'https://api.example.com',
|
|
323
|
+
fetchFn: fetchMock,
|
|
324
|
+
api: mockApi,
|
|
325
|
+
});
|
|
326
|
+
const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
|
|
327
|
+
wrapper: createWrapper(),
|
|
328
|
+
});
|
|
329
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
330
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/analytics/queries/getUser', expect.objectContaining({ method: 'PATCH' }));
|
|
331
|
+
});
|
|
332
|
+
it('throws for an unresolved semantic (dataset:) key', async () => {
|
|
333
|
+
const { useQuery } = createHooks({
|
|
334
|
+
baseUrl: 'https://api.example.com',
|
|
335
|
+
fetchFn: fetchMock,
|
|
336
|
+
});
|
|
337
|
+
const { result } = renderHook(() => useQuery('dataset:orders', { dimensions: ['country'] }), { wrapper: createWrapper() });
|
|
338
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
339
|
+
expect(result.current.error?.message).toContain('No route configured for "dataset:orders"');
|
|
340
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
189
342
|
});
|
|
190
343
|
describe('Query Parameter Serialization', () => {
|
|
191
344
|
const fetchMock = vi.fn();
|
|
@@ -378,7 +531,7 @@ describe('createHooks', () => {
|
|
|
378
531
|
expect(fetchMock).toHaveBeenCalled();
|
|
379
532
|
fetchMock.mockClear();
|
|
380
533
|
// Options object should disable the query
|
|
381
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,62 @@
|
|
|
1
1
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
2
|
-
export type
|
|
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
|