@r0hitsharma/http-client-react 0.12.0-rohit-fork-ci.1

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,114 @@
1
+ import type { ApiClient, FetchResponse, HttpMethod, MaybeOptionalInit, MediaType, PathsWithMethod, RequiredKeysOf } from '@r0hitsharma/http-client-core';
2
+ import type { DataTag, QueryClient, QueryFilters, QueryKey, SkipToken, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query';
3
+ import { HttpRequestError } from './errors.js';
4
+ import { type QueryApiMiddleware } from './middleware.js';
5
+ import { type QueryApiKey } from './query-key.js';
6
+ /**
7
+ * The shape an `openapi-typescript`-generated `paths` type has: every path maps
8
+ * to an object carrying the HTTP methods that endpoint implements, plus the
9
+ * path-level `parameters`. This is the constraint on `createQueryApi` and on
10
+ * every option type below, and it is what makes `TPaths[TPath][TMethod]` legal.
11
+ *
12
+ * Every method is *optional* and typed `any` for two reasons that come straight
13
+ * from the generated output. `openapi-typescript` emits absent operations as
14
+ * `put?: never`, so a required key rejects real generated paths; and the
15
+ * operation payload has to widen to `any`, because narrowing it makes
16
+ * TypeScript resolve `TPaths[TPath][TMethod]` to `<payload> | undefined`, which
17
+ * then fails `FetchResponse`'s own `Record<string | number, any>` constraint.
18
+ * Nothing is lost by that: the operation types the api actually reports are
19
+ * read back off the caller's own `TPaths`, never off this shape.
20
+ *
21
+ * What it therefore does *not* do is reject a badly typed client. If the paths
22
+ * type behind `client` is not a paths map, inference finds no candidate for
23
+ * `TPaths` and falls back to this constraint, so `createQueryApi` still returns
24
+ * — as `QueryApi<QueryApiPaths>`, on which no `queryOptions` call typechecks.
25
+ * The rejection lands on the calls rather than on the construction.
26
+ */
27
+ export type QueryApiPaths = Record<string, {
28
+ [TMethod in HttpMethod]?: any;
29
+ } & {
30
+ parameters?: any;
31
+ }>;
32
+ /**
33
+ * Methods `queryOptions` accepts: the safe, bodyless, cacheable ones. A
34
+ * POST-backed read (`POST /search`) is deliberately out of v1 — see DESIGN.md.
35
+ */
36
+ export type QueryApiQueryMethod = Extract<HttpMethod, 'get' | 'head'>;
37
+ /** Methods `mutationOptions` accepts. */
38
+ export type QueryApiMutationMethod = Extract<HttpMethod, 'post' | 'put' | 'patch' | 'delete'>;
39
+ /**
40
+ * What a failed query or mutation rejects with. A request that reached the
41
+ * server rejects with {@link HttpRequestError} (status plus parsed error body);
42
+ * a transport failure or a middleware — response validation, for instance —
43
+ * rejects with whatever it threw. Narrow with `isHttpRequestError` before
44
+ * reading `status`.
45
+ */
46
+ export type QueryApiError<TErrorBody> = HttpRequestError<TErrorBody> | Error;
47
+ type InitWithUnknowns<TInit> = TInit & {
48
+ [key: string]: unknown;
49
+ };
50
+ type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer TSelected ? TSelected : TData;
51
+ /** Per-call options for `queryOptions`: react-query's, plus cache tags. */
52
+ export type QueryApiCallOptions<TQueryFnData, TError, TData, TKey extends QueryKey, TTag extends string> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TKey>, 'queryKey' | 'queryFn'> & {
53
+ /** Tags this endpoint belongs to, for mutation-driven invalidation. */
54
+ tags?: readonly TTag[];
55
+ /** Middleware appended to the instance chain for this call only. */
56
+ middleware?: readonly QueryApiMiddleware[];
57
+ };
58
+ /**
59
+ * A tag to invalidate on mutation success, either fixed or derived from the
60
+ * mutation's own result and variables.
61
+ */
62
+ export type MutationInvalidation<TTag extends string, TData, TVariables> = TTag | ((data: TData, variables: TVariables) => TTag | readonly TTag[]);
63
+ /** Per-call options for `mutationOptions`: react-query's, plus `invalidates`. */
64
+ export type QueryApiMutationCallOptions<TData, TError, TVariables, TOnMutateResult, TTag extends string> = Omit<UseMutationOptions<TData, TError, TVariables, TOnMutateResult>, 'mutationKey' | 'mutationFn'> & {
65
+ invalidates?: readonly MutationInvalidation<TTag, TData, TVariables>[];
66
+ /** Middleware appended to the instance chain for this call only. */
67
+ middleware?: readonly QueryApiMiddleware[];
68
+ };
69
+ export type QueryApiKeyFn<TPaths extends QueryApiPaths, TMedia extends MediaType> = <TMethod extends QueryApiQueryMethod, TPath extends PathsWithMethod<TPaths, TMethod>, TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>, TResponse extends Required<FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>>>(method: TMethod, path: TPath, ...[init]: RequiredKeysOf<TInit> extends never ? [init?: InitWithUnknowns<TInit>] : [init: InitWithUnknowns<TInit>]) => NoInfer<DataTag<QueryApiKey<TMethod, TPath>, TResponse['data'], QueryApiError<TResponse['error']>>>;
70
+ export type QueryApiQueryOptionsFn<TPaths extends QueryApiPaths, TTag extends string, TMedia extends MediaType> = <TMethod extends QueryApiQueryMethod, TPath extends PathsWithMethod<TPaths, TMethod>, TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>, TResponse extends Required<FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>>, TOptions extends QueryApiCallOptions<TResponse['data'], QueryApiError<TResponse['error']>, InferSelectReturnType<TResponse['data'], TOptions['select']>, QueryApiKey<TMethod, TPath>, TTag>>(method: TMethod, path: TPath, ...[init, options]: RequiredKeysOf<TInit> extends never ? [init?: InitWithUnknowns<TInit>, options?: TOptions] : [init: InitWithUnknowns<TInit>, options?: TOptions]) => NoInfer<Omit<UseQueryOptions<TResponse['data'], QueryApiError<TResponse['error']>, InferSelectReturnType<TResponse['data'], TOptions['select']>, QueryApiKey<TMethod, TPath>>, 'queryKey' | 'queryFn'> & {
71
+ queryKey: DataTag<QueryApiKey<TMethod, TPath>, TResponse['data'], QueryApiError<TResponse['error']>>;
72
+ queryFn: Exclude<UseQueryOptions<TResponse['data'], QueryApiError<TResponse['error']>, InferSelectReturnType<TResponse['data'], TOptions['select']>, QueryApiKey<TMethod, TPath>>['queryFn'], SkipToken | undefined>;
73
+ }>;
74
+ export type QueryApiMutationOptionsFn<TPaths extends QueryApiPaths, TTag extends string, TMedia extends MediaType> = <TMethod extends QueryApiMutationMethod, TPath extends PathsWithMethod<TPaths, TMethod>, TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>, TResponse extends Required<FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>>, TOnMutateResult = unknown>(method: TMethod, path: TPath, options?: QueryApiMutationCallOptions<TResponse['data'], QueryApiError<TResponse['error']>, InitWithUnknowns<TInit>, TOnMutateResult, TTag>) => NoInfer<Omit<UseMutationOptions<TResponse['data'], QueryApiError<TResponse['error']>, InitWithUnknowns<TInit>, TOnMutateResult>, 'mutationKey' | 'mutationFn'> & {
75
+ mutationKey: readonly [method: TMethod, path: TPath];
76
+ mutationFn: NonNullable<UseMutationOptions<TResponse['data'], QueryApiError<TResponse['error']>, InitWithUnknowns<TInit>, TOnMutateResult>['mutationFn']>;
77
+ }>;
78
+ /** Instance-level configuration for {@link createQueryApi}. */
79
+ export type QueryApiOptions<TTag extends string> = {
80
+ /**
81
+ * The tag vocabulary. Passing it both infers `TTag` (so `tags` and
82
+ * `invalidates` are checked against a closed set) and makes an unknown tag
83
+ * throw at runtime, which is what catches typos from untyped call sites.
84
+ */
85
+ tags?: readonly TTag[];
86
+ /** Middleware applied to every request from this instance, outermost first. */
87
+ middleware?: readonly QueryApiMiddleware[];
88
+ };
89
+ export type QueryApi<TPaths extends QueryApiPaths, TTag extends string = string, TMedia extends MediaType = MediaType> = {
90
+ /**
91
+ * The derived key for an operation, for targeting `getQueryData`,
92
+ * `setQueryData`, or `invalidateQueries` without a hand-built key factory.
93
+ * Slice it to `[method, path]` to target every cached variant of an endpoint.
94
+ */
95
+ queryKey: QueryApiKeyFn<TPaths, TMedia>;
96
+ queryOptions: QueryApiQueryOptionsFn<TPaths, TTag, TMedia>;
97
+ mutationOptions: QueryApiMutationOptionsFn<TPaths, TTag, TMedia>;
98
+ /** A react-query filter matching every query registered under `tag`. */
99
+ tagFilter: (tag: TTag) => QueryFilters;
100
+ invalidateTags: (client: QueryClient, tags: readonly TTag[]) => Promise<void>;
101
+ /** The `${method} ${path}` tokens currently registered under `tag`. */
102
+ taggedEndpoints: (tag: TTag) => readonly string[];
103
+ };
104
+ /**
105
+ * Binds a TanStack Query surface to an `openapi-fetch` client.
106
+ *
107
+ * The generated `TPaths` type is the only endpoint definition: methods, paths,
108
+ * params, request bodies, and response types are all read off it, and both
109
+ * `TPaths` and the tag vocabulary are inferred from the arguments — prefer
110
+ * `createQueryApi(client, { tags: [...] })` over passing type arguments
111
+ * explicitly, since naming one disables inference for the rest.
112
+ */
113
+ export declare function createQueryApi<TPaths extends QueryApiPaths, TTag extends string = string, TMedia extends MediaType = MediaType>(client: ApiClient<TPaths, TMedia>, options?: QueryApiOptions<TTag>): QueryApi<TPaths, TTag, TMedia>;
114
+ export {};
@@ -0,0 +1,128 @@
1
+ import { HttpRequestError } from './errors.js';
2
+ import { composeMiddleware, } from './middleware.js';
3
+ import { buildQueryApiKey } from './query-key.js';
4
+ import { createTagRegistry } from './tags.js';
5
+ /**
6
+ * Binds a TanStack Query surface to an `openapi-fetch` client.
7
+ *
8
+ * The generated `TPaths` type is the only endpoint definition: methods, paths,
9
+ * params, request bodies, and response types are all read off it, and both
10
+ * `TPaths` and the tag vocabulary are inferred from the arguments — prefer
11
+ * `createQueryApi(client, { tags: [...] })` over passing type arguments
12
+ * explicitly, since naming one disables inference for the rest.
13
+ */
14
+ export function createQueryApi(client, options = {}) {
15
+ const registry = createTagRegistry(options.tags);
16
+ const instanceMiddleware = options.middleware ?? [];
17
+ const methods = client;
18
+ const send = async (ctx) => {
19
+ const method = ctx.method.toUpperCase();
20
+ const call = methods[method];
21
+ if (!call) {
22
+ throw new Error(`http-client-react: client has no ${method} method`);
23
+ }
24
+ const { data, error, response } = await call(ctx.path, ctx.init);
25
+ if (!response.ok || error !== undefined) {
26
+ throw new HttpRequestError({
27
+ method: ctx.method,
28
+ path: ctx.path,
29
+ body: error,
30
+ response,
31
+ });
32
+ }
33
+ // A HEAD response has no body by definition, so `openapi-fetch` resolves
34
+ // `data: undefined` whatever the status and whatever `Content-Length`
35
+ // claims — and it claims the size the matching GET would have returned, so
36
+ // the emptiness checks below do not catch it. react-query rejects
37
+ // `undefined` as query data.
38
+ if (method === 'HEAD')
39
+ return data ?? null;
40
+ // Same conversion for the other two ways a 2xx legitimately has no body.
41
+ if (response.status === 204 ||
42
+ response.headers.get('Content-Length') === '0') {
43
+ return data ?? null;
44
+ }
45
+ return data;
46
+ };
47
+ const request = (operationType, method, path, init, callMiddleware) => {
48
+ const chain = callMiddleware?.length
49
+ ? [...instanceMiddleware, ...callMiddleware]
50
+ : instanceMiddleware;
51
+ return composeMiddleware(chain, send)({
52
+ method,
53
+ path,
54
+ operationType,
55
+ init,
56
+ });
57
+ };
58
+ const tagFilter = (tag) => {
59
+ registry.assertKnown(tag);
60
+ return { predicate: (query) => registry.matches(tag, query.queryKey) };
61
+ };
62
+ const invalidateTags = async (client_, tags) => {
63
+ await Promise.all(tags.map((tag) => client_.invalidateQueries(tagFilter(tag))));
64
+ };
65
+ /**
66
+ * Runs after the mutation has already succeeded, which is what makes the
67
+ * asymmetry below the right one: a literal tag was checked when
68
+ * `mutationOptions` was called, so it can still throw; a tag a callback
69
+ * derives from the result can only be checked here, and throwing here would
70
+ * turn a successful mutation into a failed one and skip the caller's own
71
+ * `onSuccess`. So an unknown derived tag is warned about and dropped.
72
+ */
73
+ const resolveInvalidations = (invalidates, data, variables) => {
74
+ const resolved = new Set();
75
+ for (const entry of invalidates) {
76
+ if (typeof entry !== 'function') {
77
+ resolved.add(entry);
78
+ continue;
79
+ }
80
+ const produced = entry(data, variables);
81
+ for (const tag of typeof produced === 'string' ? [produced] : produced) {
82
+ if (registry.acceptOrWarn(tag))
83
+ resolved.add(tag);
84
+ }
85
+ }
86
+ return [...resolved];
87
+ };
88
+ /**
89
+ * The public surface is the generic type above; the implementation is written
90
+ * against loose types and cast once, here. `query-api.types.test.ts` is what
91
+ * holds the two in agreement.
92
+ */
93
+ const api = {
94
+ queryKey: (method, path, init) => buildQueryApiKey(method, path, init),
95
+ queryOptions: (method, path, init, callOptions) => {
96
+ const { tags, middleware, ...queryRest } = callOptions ?? {};
97
+ for (const tag of tags ?? [])
98
+ registry.register(tag, method, path);
99
+ return {
100
+ ...queryRest,
101
+ queryKey: buildQueryApiKey(method, path, init),
102
+ queryFn: ({ signal }) => request('query', method, path, { ...init, signal }, middleware),
103
+ };
104
+ },
105
+ mutationOptions: (method, path, callOptions) => {
106
+ const { invalidates, middleware, onSuccess, ...mutationRest } = callOptions ?? {};
107
+ for (const entry of invalidates ?? []) {
108
+ if (typeof entry === 'string')
109
+ registry.assertKnown(entry);
110
+ }
111
+ return {
112
+ ...mutationRest,
113
+ mutationKey: [method, path],
114
+ mutationFn: (variables) => request('mutation', method, path, variables, middleware),
115
+ onSuccess: async (data, variables, onMutateResult, context) => {
116
+ if (invalidates?.length) {
117
+ await invalidateTags(context.client, resolveInvalidations(invalidates, data, variables));
118
+ }
119
+ return await onSuccess?.(data, variables, onMutateResult, context);
120
+ },
121
+ };
122
+ },
123
+ tagFilter,
124
+ invalidateTags,
125
+ taggedEndpoints: registry.endpoints,
126
+ };
127
+ return api;
128
+ }
@@ -0,0 +1,84 @@
1
+ import { QueryClient, type QueryClientConfig } from '@tanstack/react-query';
2
+ /**
3
+ * Whether a response status is worth asking for again.
4
+ *
5
+ * 5xx and the three transient 4xx statuses are; every other 4xx is not, and
6
+ * neither is anything below 400 — a 304 reaching the error path is a caching
7
+ * problem, not a flaky one.
8
+ */
9
+ export declare function isRetryableHttpStatus(status: number): boolean;
10
+ /**
11
+ * Whether a rejected query or mutation is worth retrying.
12
+ *
13
+ * A rejection carrying no status is retried by default: a DNS failure, a
14
+ * dropped connection, a CORS refusal, an abort, or a middleware that threw
15
+ * leaves no evidence beyond the request not completing, and treating an
16
+ * unrecognised rejection as fatal would make a single dropped socket a visible
17
+ * error.
18
+ *
19
+ * `ZodResponseValidationError` is the exception, and the reason this is not a
20
+ * bare `isHttpRequestError` check. It carries no status because
21
+ * `createZodResponseMiddleware` rejects *after* a 2xx arrived and parsed — the
22
+ * request completed, and the body it returned does not match the schema. That
23
+ * verdict is a property of the deployed server, so asking again produces the
24
+ * same mismatch two round trips later: exactly the cost this module's `retry`
25
+ * exists to avoid on a 422.
26
+ *
27
+ * Both checks narrow on `name` rather than `instanceof`, so they still hold
28
+ * when a consumer's module graph contains two copies of this package.
29
+ *
30
+ * Exported so a consumer can keep this status policy while changing how many
31
+ * times it asks again: `retry: (count, error) => count < 5 &&
32
+ * isRetryableError(error)` is five retries, so six attempts.
33
+ */
34
+ export declare function isRetryableError(error: unknown): boolean;
35
+ /**
36
+ * The retry predicate {@link createQueryClient} installs, in react-query's own
37
+ * `(failureCount, error)` shape. `failureCount` is the number of failures
38
+ * *before* this attempt, so it is 0 on the first rejection.
39
+ *
40
+ * Exported so a consumer can wrap it rather than restate it — dropping an
41
+ * application-specific error out of the policy, say:
42
+ *
43
+ * ```ts
44
+ * retry: (count, error) =>
45
+ * isSessionExpired(error) ? false : shouldRetryRequest(count, error)
46
+ * ```
47
+ */
48
+ export declare function shouldRetryRequest(failureCount: number, error: unknown): boolean;
49
+ /**
50
+ * A `QueryClient` with defaults suited to a data-dense application, in place of
51
+ * react-query's, which are tuned for a document-shaped app:
52
+ *
53
+ * - **`refetchOnWindowFocus: false`.** Alt-tabbing back to a dashboard should
54
+ * not reload every panel under the cursor. Freshness is the business of
55
+ * `staleTime` and explicit invalidation, both of which the app controls.
56
+ * - **A status-aware `retry`** — see {@link shouldRetryRequest}. React-query
57
+ * retries every rejection three times, so a 422 costs four round trips to
58
+ * report a validation error the server already decided on the first.
59
+ *
60
+ * Mutations are deliberately left un-retried — react-query's default, and the
61
+ * right one, because a POST that reached the server may well have applied
62
+ * before the failure and this package cannot tell which.
63
+ *
64
+ * Everything else is left at react-query's default deliberately, `staleTime`
65
+ * most of all: how long a given screen may show a stale number is a product
66
+ * decision, and a package-level guess would be wrong quietly.
67
+ *
68
+ * `config` is react-query's own `QueryClientConfig` and every field of it wins.
69
+ * The merge is per-option rather than wholesale, so overriding one default
70
+ * keeps the others:
71
+ *
72
+ * ```ts
73
+ * // Keeps `refetchOnWindowFocus: false`; replaces only the retry policy.
74
+ * const queryClient = createQueryClient({
75
+ * defaultOptions: { queries: { retry: 5 } },
76
+ * });
77
+ * ```
78
+ *
79
+ * A `queries` key present with the value `undefined` is *not* an override: it
80
+ * is dropped before the merge, so the else-branch of
81
+ * `retry: cond ? false : undefined` leaves the shipped policy standing rather
82
+ * than reverting it to react-query's `retry ?? 3`.
83
+ */
84
+ export declare function createQueryClient(config?: QueryClientConfig): QueryClient;
@@ -0,0 +1,152 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { isHttpRequestError } from './errors.js';
3
+ import { isZodResponseValidationError } from './zod-response.js';
4
+ /**
5
+ * The 4xx statuses that describe a *transient* condition rather than a bad
6
+ * request. Everything else in the 4xx range says the request itself is wrong —
7
+ * a 401, a 404, a 422 — and repeating it unchanged produces the same answer
8
+ * more slowly.
9
+ *
10
+ * - **408 Request Timeout** — the server gave up waiting for the request.
11
+ * - **425 Too Early** — a replay-risk refusal on an early-data TLS connection;
12
+ * the retry goes out on the completed handshake.
13
+ * - **429 Too Many Requests** — rate limited, which is by definition temporary.
14
+ */
15
+ const RETRYABLE_CLIENT_STATUSES = new Set([408, 425, 429]);
16
+ /**
17
+ * Retries before a query is reported as failed. With react-query's default
18
+ * exponential `retryDelay` (1s, then 2s) this puts an error on screen about
19
+ * three seconds after the first failure — react-query's own default of 3 takes
20
+ * roughly seven, which is a long time for a panel to sit in a pending state
21
+ * over a fault that is not going to clear.
22
+ */
23
+ const DEFAULT_MAX_RETRIES = 2;
24
+ /**
25
+ * Whether a response status is worth asking for again.
26
+ *
27
+ * 5xx and the three transient 4xx statuses are; every other 4xx is not, and
28
+ * neither is anything below 400 — a 304 reaching the error path is a caching
29
+ * problem, not a flaky one.
30
+ */
31
+ export function isRetryableHttpStatus(status) {
32
+ return status >= 500 || RETRYABLE_CLIENT_STATUSES.has(status);
33
+ }
34
+ /**
35
+ * Whether a rejected query or mutation is worth retrying.
36
+ *
37
+ * A rejection carrying no status is retried by default: a DNS failure, a
38
+ * dropped connection, a CORS refusal, an abort, or a middleware that threw
39
+ * leaves no evidence beyond the request not completing, and treating an
40
+ * unrecognised rejection as fatal would make a single dropped socket a visible
41
+ * error.
42
+ *
43
+ * `ZodResponseValidationError` is the exception, and the reason this is not a
44
+ * bare `isHttpRequestError` check. It carries no status because
45
+ * `createZodResponseMiddleware` rejects *after* a 2xx arrived and parsed — the
46
+ * request completed, and the body it returned does not match the schema. That
47
+ * verdict is a property of the deployed server, so asking again produces the
48
+ * same mismatch two round trips later: exactly the cost this module's `retry`
49
+ * exists to avoid on a 422.
50
+ *
51
+ * Both checks narrow on `name` rather than `instanceof`, so they still hold
52
+ * when a consumer's module graph contains two copies of this package.
53
+ *
54
+ * Exported so a consumer can keep this status policy while changing how many
55
+ * times it asks again: `retry: (count, error) => count < 5 &&
56
+ * isRetryableError(error)` is five retries, so six attempts.
57
+ */
58
+ export function isRetryableError(error) {
59
+ if (isHttpRequestError(error))
60
+ return isRetryableHttpStatus(error.status);
61
+ return !isZodResponseValidationError(error);
62
+ }
63
+ /**
64
+ * The retry predicate {@link createQueryClient} installs, in react-query's own
65
+ * `(failureCount, error)` shape. `failureCount` is the number of failures
66
+ * *before* this attempt, so it is 0 on the first rejection.
67
+ *
68
+ * Exported so a consumer can wrap it rather than restate it — dropping an
69
+ * application-specific error out of the policy, say:
70
+ *
71
+ * ```ts
72
+ * retry: (count, error) =>
73
+ * isSessionExpired(error) ? false : shouldRetryRequest(count, error)
74
+ * ```
75
+ */
76
+ export function shouldRetryRequest(failureCount, error) {
77
+ return failureCount < DEFAULT_MAX_RETRIES && isRetryableError(error);
78
+ }
79
+ /**
80
+ * `queries` with its explicitly-`undefined` keys dropped, so that spreading it
81
+ * over the defaults cannot replace one with nothing.
82
+ *
83
+ * A spread does not distinguish an absent key from one present with the value
84
+ * `undefined`, and the second shape is ordinary in conditional config:
85
+ * `{ retry: cond ? false : undefined }`. Spread raw, that copies
86
+ * `retry: undefined` over {@link shouldRetryRequest}, react-query reads it as
87
+ * `retry ?? 3`, and the policy silently becomes retry-everything — while
88
+ * `refetchOnWindowFocus: false` survives the same spread, so the client still
89
+ * looks configured.
90
+ *
91
+ * Dropping the key instead is lossless: react-query resolves every one of these
92
+ * options with `?? <default>` or an `=== undefined` check, so a key that is
93
+ * absent and a key that is `undefined` already mean the same thing to it. There
94
+ * is no option for which "present but undefined" says something an explicit
95
+ * value could not say more clearly.
96
+ */
97
+ function definedQueryOptions(queries) {
98
+ if (!queries)
99
+ return {};
100
+ return Object.fromEntries(Object.entries(queries).filter(([, value]) => value !== undefined));
101
+ }
102
+ /**
103
+ * A `QueryClient` with defaults suited to a data-dense application, in place of
104
+ * react-query's, which are tuned for a document-shaped app:
105
+ *
106
+ * - **`refetchOnWindowFocus: false`.** Alt-tabbing back to a dashboard should
107
+ * not reload every panel under the cursor. Freshness is the business of
108
+ * `staleTime` and explicit invalidation, both of which the app controls.
109
+ * - **A status-aware `retry`** — see {@link shouldRetryRequest}. React-query
110
+ * retries every rejection three times, so a 422 costs four round trips to
111
+ * report a validation error the server already decided on the first.
112
+ *
113
+ * Mutations are deliberately left un-retried — react-query's default, and the
114
+ * right one, because a POST that reached the server may well have applied
115
+ * before the failure and this package cannot tell which.
116
+ *
117
+ * Everything else is left at react-query's default deliberately, `staleTime`
118
+ * most of all: how long a given screen may show a stale number is a product
119
+ * decision, and a package-level guess would be wrong quietly.
120
+ *
121
+ * `config` is react-query's own `QueryClientConfig` and every field of it wins.
122
+ * The merge is per-option rather than wholesale, so overriding one default
123
+ * keeps the others:
124
+ *
125
+ * ```ts
126
+ * // Keeps `refetchOnWindowFocus: false`; replaces only the retry policy.
127
+ * const queryClient = createQueryClient({
128
+ * defaultOptions: { queries: { retry: 5 } },
129
+ * });
130
+ * ```
131
+ *
132
+ * A `queries` key present with the value `undefined` is *not* an override: it
133
+ * is dropped before the merge, so the else-branch of
134
+ * `retry: cond ? false : undefined` leaves the shipped policy standing rather
135
+ * than reverting it to react-query's `retry ?? 3`.
136
+ */
137
+ export function createQueryClient(config = {}) {
138
+ const { defaultOptions, ...rest } = config;
139
+ return new QueryClient({
140
+ ...rest,
141
+ defaultOptions: {
142
+ ...defaultOptions,
143
+ queries: {
144
+ refetchOnWindowFocus: false,
145
+ retry: shouldRetryRequest,
146
+ // Spread last: each `queries` option the caller actually gave a value
147
+ // replaces the default of the same name and leaves the rest standing.
148
+ ...definedQueryOptions(defaultOptions?.queries),
149
+ },
150
+ },
151
+ });
152
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The identity-bearing subset of an `openapi-fetch` request init, canonicalized
3
+ * so that two structurally equivalent requests produce one cache entry.
4
+ *
5
+ * Only path params, query params, and the request body identify a resource.
6
+ * Per-call transport concerns (`signal`, `fetch`, serializers, `headers`,
7
+ * `baseUrl`, `parseAs`) are deliberately excluded: they are either
8
+ * non-serializable, or — in the case of headers — carry credentials that have
9
+ * no business being visible in a cache key or in devtools.
10
+ */
11
+ export type SanitizedQueryInit = {
12
+ path?: Record<string, unknown>;
13
+ query?: Record<string, unknown>;
14
+ body?: unknown;
15
+ };
16
+ /**
17
+ * The derived key shape: `[method, path, sanitized(init)]`. The first two
18
+ * elements are the operation itself, so react-query's default prefix matching
19
+ * invalidates every cached variant of an endpoint via `[method, path]`.
20
+ */
21
+ export type QueryApiKey<TMethod = string, TPath = string> = readonly [
22
+ method: TMethod,
23
+ path: TPath,
24
+ init: SanitizedQueryInit
25
+ ];
26
+ /**
27
+ * Recursively rewrites plain objects with their keys in sorted order and their
28
+ * `undefined`-valued properties removed. Arrays keep their order (query-array
29
+ * order is significant on the wire) and every other value is passed through by
30
+ * reference.
31
+ *
32
+ * react-query's own `hashKey` already sorts object keys before hashing, so this
33
+ * is not what makes two orderings hit the same cache entry. What it buys is
34
+ * everything that compares keys *structurally* rather than by hash: partial
35
+ * `invalidateQueries` matching (`partialDeepEqual` is order- and
36
+ * `undefined`-sensitive), `exact` filters, and reading a key in devtools.
37
+ */
38
+ export declare function canonicalizeQueryKeyValue(value: unknown): unknown;
39
+ /**
40
+ * Reduces an `openapi-fetch` init to its canonical, identity-bearing form.
41
+ *
42
+ * Empty and all-`undefined` param records collapse to absent, so
43
+ * `undefined`, `{}`, and `{ params: { query: { after: undefined } } }` all
44
+ * sanitize to the same value and therefore share a cache entry.
45
+ */
46
+ export declare function sanitizeQueryInit(init: unknown): SanitizedQueryInit;
47
+ /** Builds the untyped `[method, path, sanitized(init)]` key. */
48
+ export declare function buildQueryApiKey<TMethod extends string, TPath extends string>(method: TMethod, path: TPath, init?: unknown): QueryApiKey<TMethod, TPath>;
49
+ /** The `${method} ${path}` token an endpoint is registered and matched under. */
50
+ export declare function operationToken(method: string, path: string): string;
@@ -0,0 +1,70 @@
1
+ function isPlainObject(value) {
2
+ if (typeof value !== 'object' || value === null)
3
+ return false;
4
+ const proto = Object.getPrototypeOf(value);
5
+ return proto === Object.prototype || proto === null;
6
+ }
7
+ /**
8
+ * Recursively rewrites plain objects with their keys in sorted order and their
9
+ * `undefined`-valued properties removed. Arrays keep their order (query-array
10
+ * order is significant on the wire) and every other value is passed through by
11
+ * reference.
12
+ *
13
+ * react-query's own `hashKey` already sorts object keys before hashing, so this
14
+ * is not what makes two orderings hit the same cache entry. What it buys is
15
+ * everything that compares keys *structurally* rather than by hash: partial
16
+ * `invalidateQueries` matching (`partialDeepEqual` is order- and
17
+ * `undefined`-sensitive), `exact` filters, and reading a key in devtools.
18
+ */
19
+ export function canonicalizeQueryKeyValue(value) {
20
+ if (Array.isArray(value)) {
21
+ return value.map(canonicalizeQueryKeyValue);
22
+ }
23
+ if (isPlainObject(value)) {
24
+ const canonical = {};
25
+ for (const key of Object.keys(value).sort()) {
26
+ const entry = canonicalizeQueryKeyValue(value[key]);
27
+ if (entry !== undefined)
28
+ canonical[key] = entry;
29
+ }
30
+ return canonical;
31
+ }
32
+ return value;
33
+ }
34
+ function canonicalizeRecord(value) {
35
+ if (!isPlainObject(value))
36
+ return undefined;
37
+ const canonical = canonicalizeQueryKeyValue(value);
38
+ return Object.keys(canonical).length > 0 ? canonical : undefined;
39
+ }
40
+ /**
41
+ * Reduces an `openapi-fetch` init to its canonical, identity-bearing form.
42
+ *
43
+ * Empty and all-`undefined` param records collapse to absent, so
44
+ * `undefined`, `{}`, and `{ params: { query: { after: undefined } } }` all
45
+ * sanitize to the same value and therefore share a cache entry.
46
+ */
47
+ export function sanitizeQueryInit(init) {
48
+ if (!isPlainObject(init))
49
+ return {};
50
+ const params = isPlainObject(init.params) ? init.params : undefined;
51
+ const sanitized = {};
52
+ const path = canonicalizeRecord(params?.path);
53
+ if (path)
54
+ sanitized.path = path;
55
+ const query = canonicalizeRecord(params?.query);
56
+ if (query)
57
+ sanitized.query = query;
58
+ const body = canonicalizeQueryKeyValue(init.body);
59
+ if (body !== undefined)
60
+ sanitized.body = body;
61
+ return sanitized;
62
+ }
63
+ /** Builds the untyped `[method, path, sanitized(init)]` key. */
64
+ export function buildQueryApiKey(method, path, init) {
65
+ return [method, path, sanitizeQueryInit(init)];
66
+ }
67
+ /** The `${method} ${path}` token an endpoint is registered and matched under. */
68
+ export function operationToken(method, path) {
69
+ return `${method} ${path}`;
70
+ }
package/dist/tags.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Maps tags to the endpoints that carry them.
3
+ *
4
+ * Membership is recorded where the tag is declared — on the `queryOptions`
5
+ * call — so the query itself stays the single place an endpoint's cache
6
+ * behaviour is described. Nothing has to be mirrored into a second registry.
7
+ *
8
+ * Granularity is per `${method} ${path}`, not per key: invalidating a tag
9
+ * invalidates every cached variant of the endpoints under it, whatever their
10
+ * params. That is what "refetch the user list" almost always means, and it
11
+ * keeps the registry bounded by endpoint count rather than by cache size.
12
+ */
13
+ export type TagRegistry<TTag extends string> = {
14
+ register: (tag: TTag, method: string, path: string) => void;
15
+ assertKnown: (tag: TTag) => void;
16
+ /**
17
+ * Whether `tag` may be acted on, warning once per unknown tag rather than
18
+ * throwing. For tags that only materialize *after* a request has already
19
+ * succeeded — the ones an `invalidates` callback derives from a mutation's
20
+ * result — where a throw would report the successful mutation as failed.
21
+ */
22
+ acceptOrWarn: (tag: TTag) => boolean;
23
+ /** Whether a `[method, path, …]` query key belongs to `tag`. */
24
+ matches: (tag: TTag, queryKey: readonly unknown[]) => boolean;
25
+ /** The `${method} ${path}` tokens currently registered under `tag`. */
26
+ endpoints: (tag: TTag) => readonly string[];
27
+ };
28
+ export declare function createTagRegistry<TTag extends string>(vocabulary?: readonly TTag[]): TagRegistry<TTag>;