@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
package/DESIGN.md
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# http-client-react — design contract
|
|
2
|
+
|
|
3
|
+
`@r0hitsharma/http-client-react` binds TanStack Query to an `openapi-fetch`
|
|
4
|
+
client. This document is the authoritative statement of what the layer
|
|
5
|
+
guarantees, what it deliberately refuses to do, and why.
|
|
6
|
+
|
|
7
|
+
## Premise: the generated `paths` type is the endpoint definition
|
|
8
|
+
|
|
9
|
+
There is exactly one description of the API in a consuming app: the
|
|
10
|
+
`openapi-typescript` output. `createQueryApi` reads everything off it — which
|
|
11
|
+
methods exist on which paths, what params and body each takes, what its 2xx
|
|
12
|
+
response is, what its error responses are. Nothing is restated in a hand-written
|
|
13
|
+
endpoint registry, a key factory, or a hooks file, because every restatement is
|
|
14
|
+
a thing that can drift from the spec while still compiling.
|
|
15
|
+
|
|
16
|
+
The consequence worth naming: **a mock layer belongs on the same premise.** The
|
|
17
|
+
sibling `http-client-msw` package derives msw request handlers from the same
|
|
18
|
+
`paths` type, so a fixture that no longer matches the API fails to typecheck
|
|
19
|
+
rather than silently passing a test.
|
|
20
|
+
|
|
21
|
+
## Verdict on `openapi-react-query`
|
|
22
|
+
|
|
23
|
+
We considered depending on `openapi-react-query` (the openapi-ts ecosystem's own
|
|
24
|
+
react-query binding) and **hand-rolled instead**, reusing its type *shape* as
|
|
25
|
+
prior art.
|
|
26
|
+
|
|
27
|
+
`openapi-react-query` implements exactly two things: a query key of
|
|
28
|
+
`[method, path, init]`, and a `queryFn` that calls the client and re-throws
|
|
29
|
+
`error`. Every v1 requirement here replaces one of them:
|
|
30
|
+
|
|
31
|
+
1. **The key must be canonical.** Its key holds the caller's `init` verbatim, so
|
|
32
|
+
`{ limit, search }` and `{ search, limit }` are different keys for partial
|
|
33
|
+
matching, and a `signal` or a `headers` object in the init lands in the key.
|
|
34
|
+
We cannot fix that by overriding `queryKey`, because its `queryFn` reads the
|
|
35
|
+
init back *out of the key* — a sanitized key would change the request.
|
|
36
|
+
2. **Failures must carry status and the parsed body.** It rethrows the bare error
|
|
37
|
+
body, which loses the status code. Its `queryFn` is internal, so this is not
|
|
38
|
+
overridable.
|
|
39
|
+
3. **Middleware over the parsed result.** It has none, and `openapi-fetch`'s own
|
|
40
|
+
`use()` middleware works on `Request`/`Response`, which is the wrong altitude
|
|
41
|
+
for response validation.
|
|
42
|
+
4. **Tags and invalidation.** It has none.
|
|
43
|
+
|
|
44
|
+
So the dependency would have bought only the generic signature types — and those
|
|
45
|
+
we can get from `openapi-fetch`'s own exported helpers (`MaybeOptionalInit`,
|
|
46
|
+
`FetchResponse`) plus `openapi-typescript-helpers`, both already in the tree.
|
|
47
|
+
Against that, it adds a package whose react-query and `openapi-fetch` peer
|
|
48
|
+
ranges have to stay compatible with our exact pins, for code we override
|
|
49
|
+
entirely. The hand-rolled version is ~250 lines of types and ~120 of runtime.
|
|
50
|
+
|
|
51
|
+
What we *did* borrow, deliberately, is the generic signature shape:
|
|
52
|
+
`<TMethod, TPath, TInit, TResponse, TOptions>` with the conditional
|
|
53
|
+
`...[init, options]` tuple, the self-referential `TOptions` constraint that makes
|
|
54
|
+
`select` infer, and `NoInfer` on the return type. That shape is well-tested
|
|
55
|
+
upstream and reinventing it would have been strictly worse.
|
|
56
|
+
|
|
57
|
+
One dependency was added: `openapi-typescript-helpers@0.1.0`, a types-only
|
|
58
|
+
package that `openapi-fetch` already depends on, re-exported from
|
|
59
|
+
http-client-core so both packages type against one pinned copy of the OpenAPI
|
|
60
|
+
helper types.
|
|
61
|
+
|
|
62
|
+
## Query key contract
|
|
63
|
+
|
|
64
|
+
A key is always exactly three elements:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
[method, path, sanitizeQueryInit(init)]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `method` is the lowercase OpenAPI method; `path` is the path *template*, braces
|
|
71
|
+
intact. Together they are the operation, so `['get', '/users']` prefix-matches
|
|
72
|
+
every cached variant of that endpoint.
|
|
73
|
+
- The third element is the **identity-bearing** part of the init only: path
|
|
74
|
+
params, query params, and the body, with object keys sorted at every depth and
|
|
75
|
+
`undefined`-valued properties removed. Arrays keep their order.
|
|
76
|
+
- `undefined`, `{}`, `{ params: {} }`, and
|
|
77
|
+
`{ params: { query: { after: undefined } } }` all sanitize to `{}` and share a
|
|
78
|
+
cache entry.
|
|
79
|
+
- Per-call transport concerns — `signal`, `fetch`, `headers`, `baseUrl`,
|
|
80
|
+
`parseAs`, the serializers — are **excluded**. They are either
|
|
81
|
+
non-serializable or, in the case of headers, credentials that have no business
|
|
82
|
+
being visible in a cache key or in devtools.
|
|
83
|
+
|
|
84
|
+
react-query's own `hashKey` already sorts plain-object keys, so canonicalizing is
|
|
85
|
+
not what makes two orderings hit the same entry. What it buys is everything that
|
|
86
|
+
compares keys *structurally*: partial `invalidateQueries` matching
|
|
87
|
+
(`partialDeepEqual` is order- and `undefined`-sensitive), `exact` filters, and a
|
|
88
|
+
key you can read in devtools.
|
|
89
|
+
|
|
90
|
+
**Consequence for header-varying responses.** Because headers are not part of
|
|
91
|
+
identity, two requests that differ only by header share a cache entry. If a
|
|
92
|
+
response genuinely varies by header (a tenant selector, say), spread the options
|
|
93
|
+
and override `queryKey` yourself — the `queryFn` closes over the init rather
|
|
94
|
+
than reading it out of the key, so a custom key still issues the right request.
|
|
95
|
+
|
|
96
|
+
## Error contract
|
|
97
|
+
|
|
98
|
+
`openapi-fetch` resolves both outcomes into `{ data, error }`; react-query only
|
|
99
|
+
treats a rejection as failure. The `queryFn` converts:
|
|
100
|
+
|
|
101
|
+
- Non-2xx (or any `error`) rejects with `HttpRequestError`, carrying `status`,
|
|
102
|
+
`statusText`, the parsed `body` typed from the operation's error responses, the
|
|
103
|
+
`method`/`path`, and the raw `Response`. `body` is `TBody | undefined`, not
|
|
104
|
+
`TBody`: a 5xx from something in front of the API, or any non-2xx with
|
|
105
|
+
`Content-Length: 0`, leaves `openapi-fetch` nothing to parse. Narrowing with
|
|
106
|
+
`isHttpRequestError` gets you `status` for free but still not a body.
|
|
107
|
+
- A 204, or a response with `Content-Length: 0`, resolves to `null` — react-query
|
|
108
|
+
rejects `undefined` as query data.
|
|
109
|
+
- A `HEAD` resolves to `null` unconditionally. A HEAD response has no body by
|
|
110
|
+
definition, so `openapi-fetch` parses none; its `Content-Length` echoes the
|
|
111
|
+
size the matching `GET` would have returned, so neither check above catches it.
|
|
112
|
+
|
|
113
|
+
The declared error type is `HttpRequestError<TErrorBody> | Error`, not
|
|
114
|
+
`HttpRequestError` alone. That is honest rather than convenient: a transport
|
|
115
|
+
failure or a middleware (response validation, for instance) rejects with
|
|
116
|
+
something that is not an HTTP error, so `status` is reachable only after
|
|
117
|
+
`isHttpRequestError(error)`. The guard matches on `name` rather than `instanceof`
|
|
118
|
+
so it survives a consumer ending up with two copies of the package.
|
|
119
|
+
|
|
120
|
+
`createZodResponseMiddleware`'s rejection is the one of those with a guard of its
|
|
121
|
+
own, `isZodResponseValidationError`, because the retry policy has to tell it from
|
|
122
|
+
a transport failure — it is the only statusless rejection this package raises
|
|
123
|
+
after the request completed.
|
|
124
|
+
|
|
125
|
+
The guard takes **no body type parameter**. A check on `name` cannot say
|
|
126
|
+
anything about the body's shape, so a parameter would only have let a call site
|
|
127
|
+
name a type and get it back unchecked. It derives the body type from what the
|
|
128
|
+
argument already declares instead: narrowing a `QueryApiError<TErrorBody>` keeps
|
|
129
|
+
that operation's error body, and narrowing a `catch` binding keeps `unknown`.
|
|
130
|
+
|
|
131
|
+
## Middleware contract
|
|
132
|
+
|
|
133
|
+
`(ctx, next) => Promise<unknown>`, onion order: the instance chain outermost in
|
|
134
|
+
declared order, a per-call chain appended innermost. `ctx` carries `method`,
|
|
135
|
+
`path`, `operationType` (`'query' | 'mutation'`), and `init` — the init as it
|
|
136
|
+
will be handed to `openapi-fetch`, including react-query's abort signal.
|
|
137
|
+
|
|
138
|
+
Middleware sees the **parsed result**, not `Request`/`Response`. That is the
|
|
139
|
+
altitude response validation, timing, and result shimming want. Anything that
|
|
140
|
+
needs the raw HTTP objects belongs in `openapi-fetch`'s own `client.use()`.
|
|
141
|
+
|
|
142
|
+
`next()` with no argument passes the context through; `next(ctx)` rewrites it for
|
|
143
|
+
everything downstream. Not calling `next` at all short-circuits the chain with a
|
|
144
|
+
substitute result. Calling it twice rejects rather than issuing the request
|
|
145
|
+
twice — which also means **a middleware cannot retry**: it has no way to re-issue
|
|
146
|
+
the request it is wrapping. Whole-operation retry is react-query's `retry`/
|
|
147
|
+
`retryDelay`, which re-enter the chain from the top; transport-level retry
|
|
148
|
+
belongs in the `fetch` handed to `createApiClient`, which owns the HTTP call.
|
|
149
|
+
|
|
150
|
+
The one shipped middleware, `createZodResponseMiddleware`, validates bodies
|
|
151
|
+
against the OpenAPI document through http-client-core's
|
|
152
|
+
`getComponentSchemaFromOpenApi`, so the runtime schema and the static types come
|
|
153
|
+
from the same spec. It **passes the body through unmodified** — it never
|
|
154
|
+
substitutes zod's parse output, so the runtime value always matches the
|
|
155
|
+
statically inferred one and no coercion happens behind the caller's back.
|
|
156
|
+
Compiled schemas are memoized per middleware instance, because
|
|
157
|
+
`z.fromJSONSchema` is far too expensive per request.
|
|
158
|
+
|
|
159
|
+
## Tag contract
|
|
160
|
+
|
|
161
|
+
Tags are declared in two places and nowhere else:
|
|
162
|
+
|
|
163
|
+
- the vocabulary, on `createQueryApi(client, { tags })` — which both infers
|
|
164
|
+
`TTag` (so `tags` and `invalidates` are checked against a closed set) and makes
|
|
165
|
+
an unknown tag throw at runtime, catching typos from untyped call sites;
|
|
166
|
+
- membership, on the `queryOptions` call that belongs to the tag.
|
|
167
|
+
|
|
168
|
+
Membership is recorded where the query is described, so the query stays the
|
|
169
|
+
single place its cache behaviour lives. On mutation success, tags resolved from
|
|
170
|
+
`invalidates` are invalidated through the `QueryClient` react-query passes to the
|
|
171
|
+
mutation callback — no `useQueryClient` at the call site, no `QueryClient`
|
|
172
|
+
threaded into the api instance.
|
|
173
|
+
|
|
174
|
+
**An unknown tag fails where it is written, not where it is used.** A literal in
|
|
175
|
+
`tags` or `invalidates` is checked when the options are built — before anything
|
|
176
|
+
has run — so it throws, which is what catches a typo from an untyped call site.
|
|
177
|
+
A tag an `invalidates` callback derives from the mutation's result can only be
|
|
178
|
+
checked after the mutation has already succeeded, and throwing there would report
|
|
179
|
+
the succeeded mutation as failed and skip the caller's own `onSuccess`. So an
|
|
180
|
+
unknown derived tag is dropped, with one `console.warn` per distinct tag per api
|
|
181
|
+
instance in a non-production build. The mutation's outcome never depends on the
|
|
182
|
+
tag vocabulary.
|
|
183
|
+
|
|
184
|
+
**Granularity is per `${method} ${path}`, not per key.** Invalidating a tag
|
|
185
|
+
invalidates every cached variant of the endpoints under it, whatever their
|
|
186
|
+
params. That is what "refetch the user list" almost always means, and it keeps
|
|
187
|
+
the registry bounded by endpoint count rather than by cache size. The corollary:
|
|
188
|
+
two queries on one endpoint with different tags cannot be invalidated
|
|
189
|
+
independently — use the key directly for that.
|
|
190
|
+
|
|
191
|
+
### The registry's boundary
|
|
192
|
+
|
|
193
|
+
A tag matches only the endpoints some `queryOptions` call has registered on this
|
|
194
|
+
api instance, in this session. Registration happens as a side effect of building
|
|
195
|
+
the options, so the registry is empty until the first call that declares a tag —
|
|
196
|
+
and it is genuinely possible to have a cache entry that no `queryOptions` call
|
|
197
|
+
ever described:
|
|
198
|
+
|
|
199
|
+
- `queryClient.setQueryData(api.queryKey('get', '/positions'), …)` — a key
|
|
200
|
+
without options, written directly;
|
|
201
|
+
- `prefetchQuery`/`fetchQuery` against `api.queryKey(…)` rather than against
|
|
202
|
+
`api.queryOptions(…)` — which drops more than the tag, and is written up in
|
|
203
|
+
full in [the README](./README.md#prefetching-pass-queryoptions-never-querykey);
|
|
204
|
+
- SSR or persisted-cache **hydration**, which restores entries before any render
|
|
205
|
+
has run.
|
|
206
|
+
|
|
207
|
+
Those entries are cached under a `[method, path, …]` key like any other, but the
|
|
208
|
+
tag does not know the endpoint, so `invalidateTags` and `tagFilter` skip them
|
|
209
|
+
silently. This is a real hole, not a theoretical one: the invalidation looks
|
|
210
|
+
correct and simply does nothing.
|
|
211
|
+
|
|
212
|
+
Two ways out, both explicit:
|
|
213
|
+
|
|
214
|
+
1. **Register the endpoint once, at module scope.** Building the options is what
|
|
215
|
+
registers, so the result can be discarded — the call is the registration.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// Registers `get /positions` under `positions` at import time, so a later
|
|
219
|
+
// hydrated or hand-written entry for it is in scope for the tag.
|
|
220
|
+
void api.queryOptions('get', '/positions', undefined, { tags: ['positions'] });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
2. **Invalidate by key instead of by tag.** The first two elements of a key are
|
|
224
|
+
the operation and react-query prefix-matches, so
|
|
225
|
+
`invalidateQueries({ queryKey: ['get', '/positions'] })` reaches every cached
|
|
226
|
+
variant whether or not the registry has heard of it.
|
|
227
|
+
|
|
228
|
+
The registry is deliberately not fixed by scanning the cache for `[method, path]`
|
|
229
|
+
pairs: that would make a tag's membership depend on what happens to be cached at
|
|
230
|
+
the moment of invalidation, which is a worse contract than one that is empty
|
|
231
|
+
until declared.
|
|
232
|
+
|
|
233
|
+
## QueryClient defaults
|
|
234
|
+
|
|
235
|
+
`createQueryClient` exists because react-query's defaults are tuned for a
|
|
236
|
+
document-shaped app and this package's consumers are not building one. Two are
|
|
237
|
+
changed; the deliberate part is how few.
|
|
238
|
+
|
|
239
|
+
**`refetchOnWindowFocus: false`.** The default is `true`, which means returning
|
|
240
|
+
to a tab reissues every active query. On a dashboard that is a visible reload of
|
|
241
|
+
panels the user was reading, triggered by an action that expressed no intent to
|
|
242
|
+
reload. Freshness belongs to `staleTime` and to explicit invalidation, both of
|
|
243
|
+
which the app already controls.
|
|
244
|
+
|
|
245
|
+
The consequence is that **the app owns error recovery**: a mounted query that has
|
|
246
|
+
exhausted its retries makes no further attempt on its own while the tab stays
|
|
247
|
+
open and the connection stays up, and react-query keeps serving the last
|
|
248
|
+
successful `data` beside `status: 'error'` — so a session that expired under an
|
|
249
|
+
open dashboard reads as current numbers rather than as an empty panel.
|
|
250
|
+
`refetchOnReconnect` and `refetchOnMount` are left at react-query's defaults, so
|
|
251
|
+
a dropped connection returning or a remount still refetches; in between, showing
|
|
252
|
+
the error and offering a refetch is the app's job, not this package's.
|
|
253
|
+
|
|
254
|
+
**A status-aware `retry`.** The default retries every rejection three times. The
|
|
255
|
+
policy here splits on what the status *says*: a 5xx is a well-formed request
|
|
256
|
+
meeting an unwell server, so ask again; 408, 425, and 429 name transient
|
|
257
|
+
conditions — a server-side request timeout, a TLS early-data refusal, rate
|
|
258
|
+
limiting — so ask again; every other 4xx says the request itself is wrong, and
|
|
259
|
+
repeating it unchanged buys nothing but latency. Below 400 is not retried
|
|
260
|
+
either: a 304 on the error path is a caching bug.
|
|
261
|
+
|
|
262
|
+
A rejection carrying no status is retried. That covers a dropped connection, a
|
|
263
|
+
CORS refusal, an abort, and a middleware that threw, and it is the deliberately
|
|
264
|
+
optimistic branch — the alternative, treating an unrecognised rejection as fatal,
|
|
265
|
+
turns one dropped socket into an error state. The cost of being wrong is two
|
|
266
|
+
extra requests; the cost of the other default is a screen that fails on a blip.
|
|
267
|
+
|
|
268
|
+
One statusless rejection is exempt, and it is this package's own.
|
|
269
|
+
`ZodResponseValidationError` has no status because `createZodResponseMiddleware`
|
|
270
|
+
rejects *after* a 2xx arrived and parsed: the request completed, and the body the
|
|
271
|
+
server sent will not have changed by the next attempt. Retrying it costs two more
|
|
272
|
+
round trips to reach the same mismatch three seconds later — precisely the cost
|
|
273
|
+
the status policy above exists to avoid on a 422. `isZodResponseValidationError`
|
|
274
|
+
is what the predicate narrows with, on `name` rather than `instanceof`, for the
|
|
275
|
+
same duplicate-copy reason as `isHttpRequestError`.
|
|
276
|
+
|
|
277
|
+
Two retries rather than react-query's three, so that with the default
|
|
278
|
+
exponential `retryDelay` an error surfaces in about three seconds instead of
|
|
279
|
+
seven. A panel sitting pending over a fault that will not clear is worse than an
|
|
280
|
+
error state that arrives promptly.
|
|
281
|
+
|
|
282
|
+
**Mutations get nothing.** react-query already does not retry them, and it is
|
|
283
|
+
right: a `POST` that failed after reaching the server may have applied, and
|
|
284
|
+
nothing at this layer can distinguish that from one that did not.
|
|
285
|
+
|
|
286
|
+
### What is not in the defaults
|
|
287
|
+
|
|
288
|
+
**`staleTime`.** How long a screen may show a stale number is a product
|
|
289
|
+
decision. A package-level value would be wrong silently, in whichever direction
|
|
290
|
+
it was wrong.
|
|
291
|
+
|
|
292
|
+
**A `QueryCache.onError` driven by per-query `meta`.** Every consumer wants one,
|
|
293
|
+
which is an argument for shipping it, and the two reasons not to are both
|
|
294
|
+
concrete. The half with the value in it is the sink — the app's toast or logger
|
|
295
|
+
— which this package has no business owning. And typing the `meta` it reads
|
|
296
|
+
requires augmenting react-query's `Register` interface, a global declaration
|
|
297
|
+
that can be made exactly once in a module graph; making it here would collide
|
|
298
|
+
with the app's own augmentation and leave the consumer worse off than if the
|
|
299
|
+
package had stayed out of it. The README carries it as a recipe instead.
|
|
300
|
+
|
|
301
|
+
### Overriding
|
|
302
|
+
|
|
303
|
+
The retry policy has to be replaceable without giving up the rest, so
|
|
304
|
+
`createQueryClient` takes react-query's own `QueryClientConfig` and merges it one
|
|
305
|
+
option deep: a caller's `defaultOptions.queries.retry` replaces the default of
|
|
306
|
+
that name and leaves `refetchOnWindowFocus` standing. Anything outside
|
|
307
|
+
`defaultOptions` — `queryCache`, `mutationCache` — passes straight through.
|
|
308
|
+
|
|
309
|
+
The `queries` merge drops the caller's explicitly-`undefined` keys first, and
|
|
310
|
+
that step is the difference between a sound override and a silent one. A spread
|
|
311
|
+
cannot tell an absent key from one present with the value `undefined`, and the
|
|
312
|
+
second shape is how conditional config is ordinarily written:
|
|
313
|
+
`{ retry: cond ? false : undefined }`. Spread raw, the else-branch copies
|
|
314
|
+
`retry: undefined` over the predicate, react-query resolves it as `retry ?? 3`,
|
|
315
|
+
and a mounted query asks four times where it should ask once — reinstating the
|
|
316
|
+
retry-everything default this whole section exists to replace. Nothing looks
|
|
317
|
+
wrong from outside, either: `refetchOnWindowFocus: false` survives the same
|
|
318
|
+
spread, so the client still reads as configured. Dropping the key instead is
|
|
319
|
+
lossless, because react-query resolves each of these options with `?? <default>`
|
|
320
|
+
or an `=== undefined` check, so absent and `undefined` already mean the same
|
|
321
|
+
thing to it — there is no option where "present but undefined" says something an
|
|
322
|
+
explicit value could not say more plainly.
|
|
323
|
+
|
|
324
|
+
For the middle case, where the status policy is right but the shape around it is
|
|
325
|
+
not, the policy is exported in three pieces: `shouldRetryRequest` (the
|
|
326
|
+
react-query-shaped predicate, for wrapping), `isRetryableError` (the error
|
|
327
|
+
policy, for changing how many times a request is repeated), and
|
|
328
|
+
`isRetryableHttpStatus` (the status policy alone). A consumer composes rather
|
|
329
|
+
than restates, so a change to the status table reaches them.
|
|
330
|
+
|
|
331
|
+
## Type-level guarantees
|
|
332
|
+
|
|
333
|
+
`createQueryApi`'s implementation is written against loose types and cast once,
|
|
334
|
+
at the return. `src/query-api.types.test.ts` is what holds the cast and the
|
|
335
|
+
declared surface in agreement — it asserts data/error/param/variable inference
|
|
336
|
+
and `@ts-expect-error`s the calls that must not compile. It is checked by
|
|
337
|
+
`npm run type:check`, not by the vitest run.
|
|
338
|
+
|
|
339
|
+
`TPaths` is constrained by `QueryApiPaths` on `createQueryApi` itself, not only
|
|
340
|
+
inside the option types — the constraint is the same one either side, so there is
|
|
341
|
+
one statement of what a paths type is. Its shape is dictated by the generated
|
|
342
|
+
output: every method **optional**, because `openapi-typescript` emits absent
|
|
343
|
+
operations as `put?: never`, and every operation payload `any`, because
|
|
344
|
+
narrowing it makes `TPaths[TPath][TMethod]` resolve to `<payload> | undefined`,
|
|
345
|
+
which fails `FetchResponse`'s own `Record<string | number, any>` constraint.
|
|
346
|
+
Nothing is lost by the `any`: the types the api reports are read off the
|
|
347
|
+
caller's `TPaths`, never off the constraint.
|
|
348
|
+
|
|
349
|
+
The limit worth knowing: this rejects a bad *explicit* type argument, not a bad
|
|
350
|
+
*client*. Given a client whose paths type is not a paths map, inference finds no
|
|
351
|
+
candidate for `TPaths` and falls back to the constraint, so `createQueryApi`
|
|
352
|
+
returns `QueryApi<QueryApiPaths>` — an api on which no `queryOptions` call
|
|
353
|
+
typechecks. The error surfaces at the calls rather than at the construction.
|
|
354
|
+
|
|
355
|
+
Both `queryKey` and `queryOptions` wrap their return in `NoInfer`. Without it, a
|
|
356
|
+
contextual type on the result — the `queryKey` property of an
|
|
357
|
+
`invalidateQueries` filter, say — becomes an inference site that leaves `TInit`
|
|
358
|
+
unresolved and makes TypeScript demand the optional `init` argument.
|
|
359
|
+
|
|
360
|
+
## Deliberately out of v1
|
|
361
|
+
|
|
362
|
+
- **Optimistic updates and cache updaters.** No `onMutate` rollback helpers, no
|
|
363
|
+
string-DSL updaters. They need per-endpoint knowledge of how a mutation's
|
|
364
|
+
result maps into a query's shape, which is the one thing the OpenAPI document
|
|
365
|
+
does not describe. Invalidation is correct without that knowledge.
|
|
366
|
+
- **Cross-tab sync.** No `BroadcastChannel`; use react-query's own persistence
|
|
367
|
+
and broadcast plugins if a consumer needs it.
|
|
368
|
+
- **`POST`-backed reads.** `queryOptions` accepts `get` and `head` only. A
|
|
369
|
+
`POST /search` used as a read has to go through `mutationOptions` for now.
|
|
370
|
+
- **Infinite queries and suspense wrappers.** Neither has a consumer yet; both
|
|
371
|
+
are additive.
|
|
372
|
+
- **Header-varying cache identity.** See the query key contract above.
|
|
373
|
+
|
|
374
|
+
## Deprecated
|
|
375
|
+
|
|
376
|
+
`createQueryOptions(queryKey, queryFn)` — the pre-`createQueryApi` shim that
|
|
377
|
+
takes a hand-written key and function. Kept working for existing call sites,
|
|
378
|
+
marked `@deprecated`, and not to be used in new code.
|