@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.
- package/DESIGN.md +378 -0
- package/README.md +427 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +51 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -0
- package/dist/middleware.d.ts +45 -0
- package/dist/middleware.js +25 -0
- package/dist/query-api.d.ts +114 -0
- package/dist/query-api.js +128 -0
- package/dist/query-client.d.ts +84 -0
- package/dist/query-client.js +152 -0
- package/dist/query-key.d.ts +50 -0
- package/dist/query-key.js +70 -0
- package/dist/tags.d.ts +28 -0
- package/dist/tags.js +53 -0
- package/dist/zod-response.d.ts +61 -0
- package/dist/zod-response.js +91 -0
- package/oxfmt.config.ts +5 -0
- package/oxlint.config.ts +5 -0
- package/package.json +49 -0
- package/src/deprecated.test.ts +27 -0
- package/src/errors.ts +74 -0
- package/src/index.tsx +100 -0
- package/src/middleware.test.ts +88 -0
- package/src/middleware.ts +84 -0
- package/src/query-api.test.ts +644 -0
- package/src/query-api.ts +512 -0
- package/src/query-api.types.test.ts +225 -0
- package/src/query-client.test.ts +315 -0
- package/src/query-client.ts +173 -0
- package/src/query-key.test.ts +121 -0
- package/src/query-key.ts +113 -0
- package/src/tags.ts +100 -0
- package/src/test-fixtures.ts +222 -0
- package/src/zod-response.ts +150 -0
- package/tsconfig.build.json +21 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +11 -0
|
@@ -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>;
|