@uniflowed/query 0.0.0-alpha.10
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/cache.js +136 -0
- package/client.js +289 -0
- package/index.js +158 -0
- package/infinite.js +311 -0
- package/key.js +124 -0
- package/mutation.js +249 -0
- package/observer.js +501 -0
- package/package.json +37 -0
- package/presence.js +146 -0
- package/query.js +518 -0
- package/react.js +251 -0
- package/retry.js +192 -0
- package/structural.js +131 -0
package/infinite.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/infinite`: many pages, one entry.
|
|
4
|
+
//
|
|
5
|
+
// "Load more" is not a sequence of queries. If page two were its own cache
|
|
6
|
+
// entry, then the list on screen would be the concatenation of three entries
|
|
7
|
+
// that can be invalidated, refetched and garbage-collected independently — and
|
|
8
|
+
// the first time one of them refreshed on its own the reader would see a list
|
|
9
|
+
// with a hole in it, or the same row twice.
|
|
10
|
+
//
|
|
11
|
+
// So a paged query is *one* entry whose value happens to be a list of pages:
|
|
12
|
+
//
|
|
13
|
+
// ```js
|
|
14
|
+
// { pages: [[…], […]], pageParams: [undefined, "cursor-2"] }
|
|
15
|
+
// ```
|
|
16
|
+
//
|
|
17
|
+
// One key, one staleness clock, one invalidation, one garbage collection, and
|
|
18
|
+
// one thing to render. `pageParams` is kept beside the pages because a refetch
|
|
19
|
+
// has to be able to ask for the same pages again, and a cursor is not
|
|
20
|
+
// recoverable from the rows it returned.
|
|
21
|
+
//
|
|
22
|
+
// # Why this is a fetcher and not a second cache
|
|
23
|
+
//
|
|
24
|
+
// Everything else about a paged query — de-duplication, retries, cancellation,
|
|
25
|
+
// staleness, structural sharing — is identical to an ordinary one. The only
|
|
26
|
+
// difference is what "fetch this entry" means, so that is the only thing this
|
|
27
|
+
// module replaces: [`infinitePages`] builds the function [`Query`] calls, and
|
|
28
|
+
// [`InfiniteQueryObserver`] is the ordinary observer with the page controls
|
|
29
|
+
// added to its snapshot.
|
|
30
|
+
//
|
|
31
|
+
// Structural sharing is why appending a page is cheap: the new value is a new
|
|
32
|
+
// array with the *same* page objects in it, so the pages already rendered keep
|
|
33
|
+
// their identity and a memoised row component does not re-render because its
|
|
34
|
+
// neighbour arrived.
|
|
35
|
+
//
|
|
36
|
+
// # Why a refetch re-asks for every page
|
|
37
|
+
//
|
|
38
|
+
// The alternative is to refetch page one and keep the rest, which produces a
|
|
39
|
+
// list that is coherent nowhere: the first page is from now and the second is
|
|
40
|
+
// from ten minutes ago, and an item that moved between them appears twice or
|
|
41
|
+
// not at all. So a refetch walks the recorded `pageParams` in order and asks
|
|
42
|
+
// again for each. It is more expensive, and it is the only version that is
|
|
43
|
+
// correct.
|
|
44
|
+
//
|
|
45
|
+
// The recorded parameters are reused rather than recomputed from
|
|
46
|
+
// `getNextPageParam`, so a refetch asks for the pages the reader is looking
|
|
47
|
+
// at. A cursor that has since expired is the server's to reject, and the
|
|
48
|
+
// failure is reported rather than hidden behind a silently different list.
|
|
49
|
+
//
|
|
50
|
+
// # What is out of scope
|
|
51
|
+
//
|
|
52
|
+
// `client.fetchInfiniteQuery` — prefetching a paged query from outside React —
|
|
53
|
+
// is not implemented. It needs the same page walk with a different entry
|
|
54
|
+
// point, and until there is a server-rendering path that wants it, it would be
|
|
55
|
+
// an untested API. `useInfiniteQuery` inside a tree covers what applications
|
|
56
|
+
// do today.
|
|
57
|
+
|
|
58
|
+
import type { QueryKey } from "./key.js";
|
|
59
|
+
import { QueryObserver } from "./observer.js";
|
|
60
|
+
import type { QueryResult, ResolvedQueryOptions } from "./observer.js";
|
|
61
|
+
import type { FetchContext, FetchDirection, Fetcher, Query, QueryState } from "./query.js";
|
|
62
|
+
|
|
63
|
+
/** The value a paged entry holds. */
|
|
64
|
+
export type InfiniteData<TPage, TParam> = {|
|
|
65
|
+
readonly pages: $ReadOnlyArray<TPage>,
|
|
66
|
+
readonly pageParams: $ReadOnlyArray<TParam>,
|
|
67
|
+
|};
|
|
68
|
+
|
|
69
|
+
/** What the query function is called with, once per page. */
|
|
70
|
+
export type InfinitePageContext<TParam> = {|
|
|
71
|
+
readonly queryKey: QueryKey,
|
|
72
|
+
readonly signal: AbortSignal,
|
|
73
|
+
readonly pageParam: TParam,
|
|
74
|
+
readonly direction: FetchDirection,
|
|
75
|
+
|};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the next page starts, or `null` when there is no next page.
|
|
79
|
+
*
|
|
80
|
+
* Returning `null` is how the list ends, and it is why `hasNextPage` can be
|
|
81
|
+
* answered without an extra request: the server already said so in the answer
|
|
82
|
+
* it gave for the last page.
|
|
83
|
+
*/
|
|
84
|
+
export type PageParamFn<TPage, TParam> = (
|
|
85
|
+
lastPage: TPage,
|
|
86
|
+
allPages: $ReadOnlyArray<TPage>,
|
|
87
|
+
lastPageParam: TParam,
|
|
88
|
+
allPageParams: $ReadOnlyArray<TParam>,
|
|
89
|
+
) => TParam | null | void;
|
|
90
|
+
|
|
91
|
+
export type InfiniteQueryOptions<TPage, TParam, TSelected = InfiniteData<TPage, TParam>> = {|
|
|
92
|
+
readonly queryKey: QueryKey,
|
|
93
|
+
readonly queryFn: (context: InfinitePageContext<TParam>) => Promise<TPage>,
|
|
94
|
+
/** The parameter the first page is asked for with. */
|
|
95
|
+
readonly initialPageParam: TParam,
|
|
96
|
+
readonly getNextPageParam: PageParamFn<TPage, TParam>,
|
|
97
|
+
readonly getPreviousPageParam?: PageParamFn<TPage, TParam>,
|
|
98
|
+
/** Keep at most this many pages, dropping from the far end. */
|
|
99
|
+
readonly maxPages?: number,
|
|
100
|
+
readonly enabled?: boolean,
|
|
101
|
+
readonly staleTime?: number,
|
|
102
|
+
readonly gcTime?: number,
|
|
103
|
+
readonly retry?: mixed,
|
|
104
|
+
readonly retryDelay?: mixed,
|
|
105
|
+
readonly select?: (data: InfiniteData<TPage, TParam>) => TSelected,
|
|
106
|
+
readonly placeholderData?: mixed,
|
|
107
|
+
readonly refetchInterval?: number | null,
|
|
108
|
+
readonly refetchOnWindowFocus?: boolean,
|
|
109
|
+
readonly refetchOnReconnect?: boolean,
|
|
110
|
+
|};
|
|
111
|
+
|
|
112
|
+
/** An ordinary result, plus the two ends of the list. */
|
|
113
|
+
export type InfiniteQueryResult<TSelected> = {|
|
|
114
|
+
...QueryResult<TSelected>,
|
|
115
|
+
readonly hasNextPage: boolean,
|
|
116
|
+
readonly hasPreviousPage: boolean,
|
|
117
|
+
readonly isFetchingNextPage: boolean,
|
|
118
|
+
readonly isFetchingPreviousPage: boolean,
|
|
119
|
+
readonly fetchNextPage: () => Promise<void>,
|
|
120
|
+
readonly fetchPreviousPage: () => Promise<void>,
|
|
121
|
+
|};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The function the cache calls to fill a paged entry.
|
|
125
|
+
*
|
|
126
|
+
* `direction` decides which of the three things it means: extend the list
|
|
127
|
+
* forward, extend it backward, or — when there is no direction — refetch every
|
|
128
|
+
* page the entry already holds. A first fetch has nothing to extend, so it
|
|
129
|
+
* asks for `initialPageParam` whatever the direction says.
|
|
130
|
+
*/
|
|
131
|
+
export function infinitePages<TPage, TParam>(
|
|
132
|
+
options: InfiniteQueryOptions<TPage, TParam, mixed>,
|
|
133
|
+
direction: FetchDirection | null,
|
|
134
|
+
): Fetcher<InfiniteData<TPage, TParam>> {
|
|
135
|
+
return async (context: FetchContext<InfiniteData<TPage, TParam>>) => {
|
|
136
|
+
const previous = context.previousData;
|
|
137
|
+
const askFor = (pageParam: TParam, towards: FetchDirection): Promise<TPage> =>
|
|
138
|
+
options.queryFn({
|
|
139
|
+
queryKey: context.queryKey,
|
|
140
|
+
pageParam,
|
|
141
|
+
direction: towards,
|
|
142
|
+
// Delegated rather than read, so a paged query function that ignores
|
|
143
|
+
// the signal leaves the entry uncancellable in exactly the same way an
|
|
144
|
+
// ordinary one does. Reading it here instead would opt every paged
|
|
145
|
+
// query in on its behalf. See `query.js` for what the getter means.
|
|
146
|
+
// uf-lint-disable-next-line flow/unsafe-getters-setters
|
|
147
|
+
get signal(): AbortSignal {
|
|
148
|
+
return context.signal;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (previous == null || previous.pages.length === 0) {
|
|
153
|
+
const page = await askFor(options.initialPageParam, "forward");
|
|
154
|
+
return { pages: [page], pageParams: [options.initialPageParam] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (direction === "forward") {
|
|
158
|
+
const param = nextParam(options, previous);
|
|
159
|
+
if (param == null) {
|
|
160
|
+
return previous;
|
|
161
|
+
}
|
|
162
|
+
const page = await askFor(param, "forward");
|
|
163
|
+
return trim(
|
|
164
|
+
{ pages: [...previous.pages, page], pageParams: [...previous.pageParams, param] },
|
|
165
|
+
options.maxPages,
|
|
166
|
+
"forward",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (direction === "backward") {
|
|
171
|
+
const param = previousParam(options, previous);
|
|
172
|
+
if (param == null) {
|
|
173
|
+
return previous;
|
|
174
|
+
}
|
|
175
|
+
const page = await askFor(param, "backward");
|
|
176
|
+
return trim(
|
|
177
|
+
{ pages: [page, ...previous.pages], pageParams: [param, ...previous.pageParams] },
|
|
178
|
+
options.maxPages,
|
|
179
|
+
"backward",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// No direction: refresh what is on screen, page by page, in order.
|
|
184
|
+
const pages: Array<TPage> = [];
|
|
185
|
+
for (const pageParam of previous.pageParams) {
|
|
186
|
+
pages.push(await askFor(pageParam, "forward"));
|
|
187
|
+
}
|
|
188
|
+
return { pages, pageParams: previous.pageParams.slice() };
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Whether the server said there is another page after the ones held. */
|
|
193
|
+
export function hasMore<TPage, TParam>(
|
|
194
|
+
options: InfiniteQueryOptions<TPage, TParam, mixed>,
|
|
195
|
+
data: InfiniteData<TPage, TParam> | void,
|
|
196
|
+
towards: FetchDirection,
|
|
197
|
+
): boolean {
|
|
198
|
+
if (data == null || data.pages.length === 0) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return (towards === "forward" ? nextParam(options, data) : previousParam(options, data)) != null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The observer for a paged query.
|
|
206
|
+
*
|
|
207
|
+
* Everything about watching, fetching, retrying and snapshotting is inherited;
|
|
208
|
+
* the two differences are that the entry is filled a page at a time and that
|
|
209
|
+
* the snapshot carries the controls for the two ends of the list.
|
|
210
|
+
*/
|
|
211
|
+
export class InfiniteQueryObserver<TPage, TParam, TSelected> extends QueryObserver<
|
|
212
|
+
InfiniteData<TPage, TParam>,
|
|
213
|
+
TSelected,
|
|
214
|
+
> {
|
|
215
|
+
readonly fetchNextPage: () => Promise<void>;
|
|
216
|
+
readonly fetchPreviousPage: () => Promise<void>;
|
|
217
|
+
|
|
218
|
+
constructor(
|
|
219
|
+
client: $FlowFixMe,
|
|
220
|
+
getOptions: () => InfiniteQueryOptions<TPage, TParam, TSelected>,
|
|
221
|
+
) {
|
|
222
|
+
super(client, getOptions as $FlowFixMe);
|
|
223
|
+
// Stable for the observer's life: a snapshot that carried a new function
|
|
224
|
+
// every render would never compare equal to the one before it, and every
|
|
225
|
+
// notification would become a re-render.
|
|
226
|
+
// `cancelRefetch: true`, because a page control is a request for something
|
|
227
|
+
// the entry does not have yet. Joining whatever is in flight would return
|
|
228
|
+
// the refetch's answer and add no page at all — the directional fetcher
|
|
229
|
+
// would never run — so pressing "load more" while a background refetch was
|
|
230
|
+
// going did nothing, silently.
|
|
231
|
+
this.fetchNextPage = () => this.fetch({ cancelRefetch: true, direction: "forward" });
|
|
232
|
+
this.fetchPreviousPage = () => this.fetch({ cancelRefetch: true, direction: "backward" });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pageOptions(): InfiniteQueryOptions<TPage, TParam, mixed> {
|
|
236
|
+
return this.getOptions() as $FlowFixMe;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
buildFetcher(
|
|
240
|
+
_options: ResolvedQueryOptions<InfiniteData<TPage, TParam>, TSelected>,
|
|
241
|
+
direction: FetchDirection | null,
|
|
242
|
+
): Fetcher<InfiniteData<TPage, TParam>> {
|
|
243
|
+
return infinitePages(this.pageOptions(), direction);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// $FlowFixMe[incompatible-extend] the paged snapshot is the base snapshot plus
|
|
247
|
+
// the page controls. Flow has no way to state "the same exact object with
|
|
248
|
+
// more fields" for an override, and widening the base result to an inexact
|
|
249
|
+
// type to allow it would stop catching typos in every ordinary query.
|
|
250
|
+
buildResult(
|
|
251
|
+
query: Query<InfiniteData<TPage, TParam>> | void,
|
|
252
|
+
state: QueryState<InfiniteData<TPage, TParam>>,
|
|
253
|
+
options: ResolvedQueryOptions<InfiniteData<TPage, TParam>, TSelected>,
|
|
254
|
+
): InfiniteQueryResult<TSelected> {
|
|
255
|
+
const base = super.buildResult(query, state, options);
|
|
256
|
+
const pages = this.pageOptions();
|
|
257
|
+
// Asked of the raw pages rather than of `base.data`, which may have been
|
|
258
|
+
// narrowed by `select` into something with no pages in it at all.
|
|
259
|
+
const data = state.data;
|
|
260
|
+
const isFetching = state.fetchStatus === "fetching";
|
|
261
|
+
return {
|
|
262
|
+
...base,
|
|
263
|
+
hasNextPage: hasMore(pages, data, "forward"),
|
|
264
|
+
hasPreviousPage: hasMore(pages, data, "backward"),
|
|
265
|
+
isFetchingNextPage: isFetching && state.direction === "forward",
|
|
266
|
+
isFetchingPreviousPage: isFetching && state.direction === "backward",
|
|
267
|
+
fetchNextPage: this.fetchNextPage,
|
|
268
|
+
fetchPreviousPage: this.fetchPreviousPage,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function nextParam<TPage, TParam>(
|
|
274
|
+
options: InfiniteQueryOptions<TPage, TParam, mixed>,
|
|
275
|
+
data: InfiniteData<TPage, TParam>,
|
|
276
|
+
): TParam | null | void {
|
|
277
|
+
const index = data.pages.length - 1;
|
|
278
|
+
return options.getNextPageParam(
|
|
279
|
+
data.pages[index],
|
|
280
|
+
data.pages,
|
|
281
|
+
data.pageParams[index],
|
|
282
|
+
data.pageParams,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function previousParam<TPage, TParam>(
|
|
287
|
+
options: InfiniteQueryOptions<TPage, TParam, mixed>,
|
|
288
|
+
data: InfiniteData<TPage, TParam>,
|
|
289
|
+
): TParam | null | void {
|
|
290
|
+
const get = options.getPreviousPageParam;
|
|
291
|
+
if (get == null) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return get(data.pages[0], data.pages, data.pageParams[0], data.pageParams);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Keep at most `maxPages`, dropping from the end the reader is moving away from. */
|
|
298
|
+
function trim<TPage, TParam>(
|
|
299
|
+
data: InfiniteData<TPage, TParam>,
|
|
300
|
+
maxPages: number | void,
|
|
301
|
+
direction: FetchDirection,
|
|
302
|
+
): InfiniteData<TPage, TParam> {
|
|
303
|
+
if (maxPages == null || maxPages <= 0 || data.pages.length <= maxPages) {
|
|
304
|
+
return data;
|
|
305
|
+
}
|
|
306
|
+
const from = direction === "forward" ? data.pages.length - maxPages : 0;
|
|
307
|
+
return {
|
|
308
|
+
pages: data.pages.slice(from, from + maxPages),
|
|
309
|
+
pageParams: data.pageParams.slice(from, from + maxPages),
|
|
310
|
+
};
|
|
311
|
+
}
|
package/key.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/key`: what makes two requests the same request.
|
|
4
|
+
//
|
|
5
|
+
// Everything else in this package is downstream of one decision: when two
|
|
6
|
+
// components ask for `["user", 1]`, is that one entry or two? The answer has
|
|
7
|
+
// to be a *value* comparison — the arrays are written inline in two different
|
|
8
|
+
// files and will never be the same object — so a key is reduced to a string
|
|
9
|
+
// and the string is the identity.
|
|
10
|
+
//
|
|
11
|
+
// # Why the hash is order-stable
|
|
12
|
+
//
|
|
13
|
+
// `JSON.stringify` of the array would be enough if every caller wrote object
|
|
14
|
+
// members in the same order. They do not: `["users", {page: 1, size: 20}]` and
|
|
15
|
+
// `["users", {size: 20, page: 1}]` are the same request written by two people,
|
|
16
|
+
// and hashing them apart means two cache entries, two requests, and two
|
|
17
|
+
// answers that can disagree on screen. So object keys are sorted as the walk
|
|
18
|
+
// serialises them.
|
|
19
|
+
//
|
|
20
|
+
// Arrays are *not* sorted — order is meaning there — and neither is anything
|
|
21
|
+
// else. A `Date` or a class instance in a key serialises through its own
|
|
22
|
+
// `toJSON`, which is a reasonable default and a bad idea to rely on; keys
|
|
23
|
+
// should be built from strings, numbers and plain records.
|
|
24
|
+
//
|
|
25
|
+
// # Why `1` and `"1"` are different keys
|
|
26
|
+
//
|
|
27
|
+
// They come from different code paths — a route parameter is a string, a
|
|
28
|
+
// database id is a number — and treating them as one entry means a component
|
|
29
|
+
// reading `["user", 1]` is shown the answer fetched for `["user", "1"]`. That
|
|
30
|
+
// is a bug that surfaces as *the wrong data*, never as an exception, which is
|
|
31
|
+
// the worst failure mode a cache has. `JSON.stringify` keeps them apart for
|
|
32
|
+
// free, so the cheap behaviour is also the correct one.
|
|
33
|
+
//
|
|
34
|
+
// # Why matching is by prefix
|
|
35
|
+
//
|
|
36
|
+
// Invalidation is written as `["users"]` and has to reach `["users", 1]` and
|
|
37
|
+
// `["users", 2]` without the caller enumerating them, because after creating a
|
|
38
|
+
// user the caller does not know which ids are cached. [`matchesKey`] is that
|
|
39
|
+
// rule, and it is structural rather than a string `startsWith` on the hash:
|
|
40
|
+
// the string form has no idea where one member ends and the next begins, so
|
|
41
|
+
// `["user"]` would match `["users", 1]` for a large enough coincidence of
|
|
42
|
+
// punctuation. Comparing the arrays cannot make that mistake.
|
|
43
|
+
|
|
44
|
+
import { isPlainObject } from "./structural.js";
|
|
45
|
+
|
|
46
|
+
/** A key, as a caller writes it: `["user", id]`. */
|
|
47
|
+
export type QueryKey = $ReadOnlyArray<mixed>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A key as a string, stable across member order inside object members.
|
|
51
|
+
*
|
|
52
|
+
* Two keys with the same hash are the same request by definition. That is the
|
|
53
|
+
* contract every other module in this package relies on, including the one
|
|
54
|
+
* that decides a component does not need to resubscribe.
|
|
55
|
+
*/
|
|
56
|
+
export function hashKey(key: QueryKey): string {
|
|
57
|
+
return JSON.stringify(key, (_name, value) =>
|
|
58
|
+
isPlainObject(value)
|
|
59
|
+
? Object.keys(value)
|
|
60
|
+
.sort()
|
|
61
|
+
.reduce((sorted: $FlowFixMe, name: string) => {
|
|
62
|
+
sorted[name] = (value as $FlowFixMe)[name];
|
|
63
|
+
return sorted;
|
|
64
|
+
}, {})
|
|
65
|
+
: value,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether `key` is described by `pattern`.
|
|
71
|
+
*
|
|
72
|
+
* By default `pattern` is a prefix: `["users"]` matches `["users", 1]`, and
|
|
73
|
+
* `["users", {done: true}]` matches `["users", {done: true, page: 2}]` —
|
|
74
|
+
* object members match partially, so a filter can name the parts of a
|
|
75
|
+
* parameter record it cares about and ignore the rest.
|
|
76
|
+
*
|
|
77
|
+
* With `exact`, the two must hash identically. That is the option for "this
|
|
78
|
+
* one entry and not the list it belongs to", which is the difference between
|
|
79
|
+
* refreshing a row and refetching a table.
|
|
80
|
+
*/
|
|
81
|
+
export function matchesKey(key: QueryKey, pattern: QueryKey, exact: boolean = false): boolean {
|
|
82
|
+
if (exact) {
|
|
83
|
+
return hashKey(key) === hashKey(pattern);
|
|
84
|
+
}
|
|
85
|
+
if (pattern.length > key.length) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
89
|
+
if (!partiallyMatches(key[index], pattern[index])) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether `value` satisfies everything `pattern` states about it.
|
|
98
|
+
*
|
|
99
|
+
* Object patterns are partial — they constrain the members they name — while
|
|
100
|
+
* arrays must line up member for member. The asymmetry is deliberate: a record
|
|
101
|
+
* in a key is a bag of parameters and naming one of them is a useful filter,
|
|
102
|
+
* whereas a shorter array is a different list, not a laxer description of one.
|
|
103
|
+
*/
|
|
104
|
+
function partiallyMatches(value: mixed, pattern: mixed): boolean {
|
|
105
|
+
if (Object.is(value, pattern)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(pattern)) {
|
|
109
|
+
if (!Array.isArray(value) || value.length !== pattern.length) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return pattern.every((member, index) => partiallyMatches(value[index], member));
|
|
113
|
+
}
|
|
114
|
+
if (isPlainObject(pattern)) {
|
|
115
|
+
if (!isPlainObject(value)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const record = value as $FlowFixMe;
|
|
119
|
+
return Object.keys(pattern as $FlowFixMe).every((name) =>
|
|
120
|
+
partiallyMatches(record[name], (pattern as $FlowFixMe)[name]),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
package/mutation.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/mutation`: the write, and the lie you tell before it.
|
|
4
|
+
//
|
|
5
|
+
// A mutation is not a query with a different verb. A query can be repeated,
|
|
6
|
+
// abandoned, de-duplicated and garbage-collected because asking twice is free;
|
|
7
|
+
// none of that is true of "create the invoice". So this module shares the
|
|
8
|
+
// retry loop with queries and nothing else: no cache entry, no de-duplication,
|
|
9
|
+
// no staleness, and — deliberately — no cancellation.
|
|
10
|
+
//
|
|
11
|
+
// # Why an unmount does not cancel a mutation
|
|
12
|
+
//
|
|
13
|
+
// A request that has left the building cannot be un-sent. Aborting it would
|
|
14
|
+
// stop the *answer* arriving, not the write happening, and the application
|
|
15
|
+
// would be left unable to say whether the invoice exists. So a mutation runs
|
|
16
|
+
// to completion and its callbacks fire even if the component that started it
|
|
17
|
+
// has gone. The callbacks are where cache updates and invalidations live,
|
|
18
|
+
// which is exactly the work that still needs doing when the reader has moved
|
|
19
|
+
// on.
|
|
20
|
+
//
|
|
21
|
+
// # Why `onMutate` returns a context and `onError` is given it
|
|
22
|
+
//
|
|
23
|
+
// Optimistic updates are the reason this API has a shape at all. The sequence
|
|
24
|
+
// is fixed and each step exists because of a specific failure:
|
|
25
|
+
//
|
|
26
|
+
// ```js
|
|
27
|
+
// onMutate: async (next) => {
|
|
28
|
+
// // A refetch already in flight would land *after* the optimistic write and
|
|
29
|
+
// // put the server's old answer back on screen. Stop it first.
|
|
30
|
+
// await client.cancelQueries({ queryKey: ["todos"] });
|
|
31
|
+
// const previous = client.getQueryData(["todos"]);
|
|
32
|
+
// client.setQueryData(["todos"], (todos) => [...todos, next]);
|
|
33
|
+
// return { previous }; // ← the rollback, captured before the guess
|
|
34
|
+
// },
|
|
35
|
+
// onError: (_error, _next, context) => {
|
|
36
|
+
// client.setQueryData(["todos"], context.previous);
|
|
37
|
+
// },
|
|
38
|
+
// onSettled: () => client.invalidateQueries({ queryKey: ["todos"] }),
|
|
39
|
+
// ```
|
|
40
|
+
//
|
|
41
|
+
// The context has to be produced by `onMutate` and handed back to `onError`
|
|
42
|
+
// because nothing else can hold it: a `useRef` in the component is gone if the
|
|
43
|
+
// component unmounted, and a variable in the caller's closure belongs to one
|
|
44
|
+
// call and two mutations can be in flight at once. Passing it through the
|
|
45
|
+
// mutation itself is what makes the rollback correct for *this* call.
|
|
46
|
+
//
|
|
47
|
+
// `onError` runs before the state becomes `error`, and `onSettled` before
|
|
48
|
+
// either terminal state, so by the time a component is told the mutation
|
|
49
|
+
// failed, the rollback has already happened — the reader never sees the
|
|
50
|
+
// optimistic value and the failure message at the same time.
|
|
51
|
+
//
|
|
52
|
+
// # Why the state is an external store rather than `useState`
|
|
53
|
+
//
|
|
54
|
+
// So a snapshot is one immutable object with a stable identity, comparable by
|
|
55
|
+
// `Object.is`, which is what `useSyncExternalStore` requires and what keeps a
|
|
56
|
+
// re-render from being scheduled for a mutation that has not moved. It also
|
|
57
|
+
// means the state machine is testable without rendering anything.
|
|
58
|
+
|
|
59
|
+
import { asError, runWithRetry } from "./retry.js";
|
|
60
|
+
import type { RetryDelay, RetryPolicy } from "./retry.js";
|
|
61
|
+
|
|
62
|
+
export type MutationStatus = "idle" | "pending" | "success" | "error";
|
|
63
|
+
|
|
64
|
+
export type MutationState<TVariables, TData> = {|
|
|
65
|
+
readonly status: MutationStatus,
|
|
66
|
+
readonly data: TData | void,
|
|
67
|
+
readonly error: Error | null,
|
|
68
|
+
/** What the last call was given, so a retry button can repeat it. */
|
|
69
|
+
readonly variables: TVariables | void,
|
|
70
|
+
readonly failureCount: number,
|
|
71
|
+
readonly submittedAt: number,
|
|
72
|
+
|};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The callbacks a single call may add.
|
|
76
|
+
*
|
|
77
|
+
* Both sets run — the ones on the hook and the ones on the call — with the
|
|
78
|
+
* hook's first. The hook's are the ones that keep the cache correct and must
|
|
79
|
+
* not be skippable by a caller who only wanted a toast.
|
|
80
|
+
*/
|
|
81
|
+
export type MutationCallbacks<TVariables, TData, TContext> = {|
|
|
82
|
+
readonly onSuccess?: (data: TData, variables: TVariables, context: TContext | void) => mixed,
|
|
83
|
+
readonly onError?: (error: Error, variables: TVariables, context: TContext | void) => mixed,
|
|
84
|
+
readonly onSettled?: (
|
|
85
|
+
data: TData | void,
|
|
86
|
+
error: Error | null,
|
|
87
|
+
variables: TVariables,
|
|
88
|
+
context: TContext | void,
|
|
89
|
+
) => mixed,
|
|
90
|
+
|};
|
|
91
|
+
|
|
92
|
+
export type MutationOptions<TVariables, TData, TContext> = {|
|
|
93
|
+
readonly mutationFn: (variables: TVariables) => Promise<TData>,
|
|
94
|
+
/** Runs first; what it returns is handed to the other three. */
|
|
95
|
+
readonly onMutate?: (variables: TVariables) => Promise<TContext | void> | TContext | void,
|
|
96
|
+
readonly onSuccess?: (data: TData, variables: TVariables, context: TContext | void) => mixed,
|
|
97
|
+
readonly onError?: (error: Error, variables: TVariables, context: TContext | void) => mixed,
|
|
98
|
+
readonly onSettled?: (
|
|
99
|
+
data: TData | void,
|
|
100
|
+
error: Error | null,
|
|
101
|
+
variables: TVariables,
|
|
102
|
+
context: TContext | void,
|
|
103
|
+
) => mixed,
|
|
104
|
+
readonly retry?: RetryPolicy,
|
|
105
|
+
readonly retryDelay?: RetryDelay,
|
|
106
|
+
|};
|
|
107
|
+
|
|
108
|
+
/** The same options with the client's defaults filled in. */
|
|
109
|
+
export type ResolvedMutationOptions<TVariables, TData, TContext> = {|
|
|
110
|
+
...MutationOptions<TVariables, TData, TContext>,
|
|
111
|
+
readonly retry: RetryPolicy,
|
|
112
|
+
readonly retryDelay: RetryDelay,
|
|
113
|
+
|};
|
|
114
|
+
|
|
115
|
+
/** What a mutation looks like to a component. */
|
|
116
|
+
export type MutationResult<TVariables, TData, TContext> = {|
|
|
117
|
+
readonly data: TData | void,
|
|
118
|
+
readonly error: Error | null,
|
|
119
|
+
readonly status: MutationStatus,
|
|
120
|
+
readonly variables: TVariables | void,
|
|
121
|
+
readonly failureCount: number,
|
|
122
|
+
readonly isIdle: boolean,
|
|
123
|
+
readonly isPending: boolean,
|
|
124
|
+
readonly isSuccess: boolean,
|
|
125
|
+
readonly isError: boolean,
|
|
126
|
+
/** Fire and forget. The failure is in `error`, not in a rejected promise. */
|
|
127
|
+
readonly mutate: (
|
|
128
|
+
variables: TVariables,
|
|
129
|
+
callbacks?: MutationCallbacks<TVariables, TData, TContext>,
|
|
130
|
+
) => void,
|
|
131
|
+
/** The same call, awaited. Rejects, so a caller can branch on the failure. */
|
|
132
|
+
readonly mutateAsync: (
|
|
133
|
+
variables: TVariables,
|
|
134
|
+
callbacks?: MutationCallbacks<TVariables, TData, TContext>,
|
|
135
|
+
) => Promise<TData>,
|
|
136
|
+
readonly reset: () => void,
|
|
137
|
+
|};
|
|
138
|
+
|
|
139
|
+
const IDLE: MutationState<empty, empty> = Object.freeze({
|
|
140
|
+
status: "idle",
|
|
141
|
+
data: undefined,
|
|
142
|
+
error: null,
|
|
143
|
+
variables: undefined,
|
|
144
|
+
failureCount: 0,
|
|
145
|
+
submittedAt: 0,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export class Mutation<TVariables, TData, TContext> {
|
|
149
|
+
state: MutationState<TVariables, TData> = IDLE;
|
|
150
|
+
listeners: Set<() => void> = new Set();
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Which call owns the state.
|
|
154
|
+
*
|
|
155
|
+
* Two calls can be in flight — a reader who clicked twice — and the state
|
|
156
|
+
* describes the latest, not whichever settled last. The callbacks of both
|
|
157
|
+
* still run: each of them was a real write with real consequences, and the
|
|
158
|
+
* cache updates in them are not the loser's to skip.
|
|
159
|
+
*/
|
|
160
|
+
runId: number = 0;
|
|
161
|
+
|
|
162
|
+
subscribe(listener: () => void): () => void {
|
|
163
|
+
this.listeners.add(listener);
|
|
164
|
+
return () => {
|
|
165
|
+
this.listeners.delete(listener);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Back to idle, forgetting the last result. */
|
|
170
|
+
reset(): void {
|
|
171
|
+
this.runId += 1;
|
|
172
|
+
this.setState(IDLE as $FlowFixMe);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async execute(
|
|
176
|
+
variables: TVariables,
|
|
177
|
+
options: ResolvedMutationOptions<TVariables, TData, TContext>,
|
|
178
|
+
callbacks?: MutationCallbacks<TVariables, TData, TContext>,
|
|
179
|
+
): Promise<TData> {
|
|
180
|
+
const id = this.runId + 1;
|
|
181
|
+
this.runId = id;
|
|
182
|
+
this.setState({
|
|
183
|
+
status: "pending",
|
|
184
|
+
data: undefined,
|
|
185
|
+
error: null,
|
|
186
|
+
variables,
|
|
187
|
+
failureCount: 0,
|
|
188
|
+
submittedAt: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Declared outside the `try` so the rollback in `onError` can still be
|
|
192
|
+
// given whatever `onMutate` managed to record before things went wrong.
|
|
193
|
+
let context: TContext | void;
|
|
194
|
+
try {
|
|
195
|
+
context = await options.onMutate?.(variables);
|
|
196
|
+
const data = await runWithRetry({
|
|
197
|
+
attempt: () => options.mutationFn(variables),
|
|
198
|
+
retry: options.retry,
|
|
199
|
+
retryDelay: options.retryDelay,
|
|
200
|
+
// Never aborted: see the module docs on why a write is not cancelled.
|
|
201
|
+
signal: new AbortController().signal,
|
|
202
|
+
onFailure: (failureCount) => {
|
|
203
|
+
if (this.runId === id) {
|
|
204
|
+
this.setState({ failureCount });
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Before the state moves to `success`, so a component told the mutation
|
|
210
|
+
// succeeded is looking at a cache that already knows.
|
|
211
|
+
await options.onSuccess?.(data, variables, context);
|
|
212
|
+
await callbacks?.onSuccess?.(data, variables, context);
|
|
213
|
+
await options.onSettled?.(data, null, variables, context);
|
|
214
|
+
await callbacks?.onSettled?.(data, null, variables, context);
|
|
215
|
+
|
|
216
|
+
if (this.runId === id) {
|
|
217
|
+
this.setState({ status: "success", data, error: null });
|
|
218
|
+
}
|
|
219
|
+
return data;
|
|
220
|
+
} catch (thrown) {
|
|
221
|
+
const error = asError(thrown);
|
|
222
|
+
try {
|
|
223
|
+
await options.onError?.(error, variables, context);
|
|
224
|
+
await callbacks?.onError?.(error, variables, context);
|
|
225
|
+
await options.onSettled?.(undefined, error, variables, context);
|
|
226
|
+
await callbacks?.onSettled?.(undefined, error, variables, context);
|
|
227
|
+
} finally {
|
|
228
|
+
// Committed whatever the callbacks did. A callback that throws is the
|
|
229
|
+
// caller's bug and still reaches them — it comes out of `execute` in
|
|
230
|
+
// place of the rethrow below — but a mutation left `pending` for ever
|
|
231
|
+
// because a listener threw would be this module's bug, and the
|
|
232
|
+
// component showing a spinner has no way back from it.
|
|
233
|
+
if (this.runId === id) {
|
|
234
|
+
this.setState({ status: "error", error, data: undefined });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Rethrown so `mutateAsync` can be branched on. `mutate` swallows it,
|
|
238
|
+
// which is why the two exist.
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
setState(patch: { +[string]: mixed }): void {
|
|
244
|
+
this.state = { ...this.state, ...patch } as $FlowFixMe;
|
|
245
|
+
for (const listener of Array.from(this.listeners)) {
|
|
246
|
+
listener();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|