@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/query.js ADDED
@@ -0,0 +1,518 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/query`: one key, and everything that can happen to it.
4
+ //
5
+ // A [`Query`] is the entry behind a single key: the last answer, the last
6
+ // failure, whether a request is in flight, who is watching, and the timer that
7
+ // will throw it away when nobody is. It is deliberately ignorant of React —
8
+ // every decision here is one a cache has to make whether or not anything is
9
+ // rendering, and keeping them here is what makes them testable without a DOM.
10
+ //
11
+ // # Why the state is replaced rather than edited
12
+ //
13
+ // `state` is a new object on every transition and is never mutated in place.
14
+ // That is not ceremony: React reads this store through
15
+ // `useSyncExternalStore`, whose contract is that a snapshot is immutable and
16
+ // comparable by identity. An entry edited in place would hand React the same
17
+ // object it already had, React would see no change and skip the render, and
18
+ // the screen would sit on data the cache had already replaced. The bug looks
19
+ // like "sometimes it doesn't update", which is the hardest kind to find.
20
+ //
21
+ // # Why one entry can only have one request in flight
22
+ //
23
+ // Two components mounting in the same tick both want `["user", 1]`. If each
24
+ // gets its own request the application makes two calls, and worse, the answers
25
+ // can arrive in either order — so the two components can end up rendering
26
+ // different versions of the same key. Joining the second caller to the first
27
+ // request is what makes a shared cache *coherent*, not just cheaper.
28
+ //
29
+ // # Why a superseded fetch cannot write
30
+ //
31
+ // Every request carries the `fetchId` it was started with, and only the
32
+ // current one may touch the state. Without that, a refetch that overtakes the
33
+ // request it replaced writes the *older* answer last and it sticks. This is
34
+ // the single most common way a hand-rolled cache goes wrong, and it does not
35
+ // reproduce on a fast connection.
36
+ //
37
+ // # Why cancelling depends on whether the query function looked at the signal
38
+ //
39
+ // `signal` on the fetch context is a getter, and reading it is treated as the
40
+ // query function opting in to cancellation. When the last observer leaves, an
41
+ // opted-in request is aborted — that is the point of `fetch(url, {signal})`.
42
+ // A query function that ignores the signal cannot be stopped, so aborting it
43
+ // would only throw away an answer that is already on its way and that the next
44
+ // mount would have to ask for again. The flag is how one mechanism serves both
45
+ // without asking the caller to configure it.
46
+ //
47
+ // An explicit `cancelQueries` aborts either way: there the caller has said
48
+ // what they want, and the cache does not get to second-guess it.
49
+
50
+ import { hashKey } from "./key.js";
51
+ import type { QueryKey } from "./key.js";
52
+ import { CancelledError, asError, runWithRetry } from "./retry.js";
53
+ import type { RetryDelay, RetryPolicy } from "./retry.js";
54
+ import { structuralShare } from "./structural.js";
55
+
56
+ // Type-only: the cache constructs queries and queries ask it to drop them, but
57
+ // nothing here needs the class at runtime, so the modules do not form a cycle.
58
+ import type { QueryCache } from "./cache.js";
59
+
60
+ /** Whether the entry has an answer yet, and whether that answer is a failure. */
61
+ export type QueryStatus = "pending" | "success" | "error";
62
+
63
+ /** Whether a request is in flight, independent of whether there is data. */
64
+ export type FetchStatus = "idle" | "fetching";
65
+
66
+ /** Which end of a paged query is being extended. */
67
+ export type FetchDirection = "forward" | "backward";
68
+
69
+ /**
70
+ * Everything known about one key.
71
+ *
72
+ * `dataUpdatedAt` and `checkedAt` are two different facts and conflating them
73
+ * is a mistake with a visible cost. `checkedAt` is when the server was last
74
+ * asked and answered; it is what staleness is measured from. `dataUpdatedAt`
75
+ * is when the answer last *changed*. A poll that confirms the same rows moves
76
+ * `checkedAt` and leaves `dataUpdatedAt` alone — which is exactly what lets a
77
+ * component that displays "updated 2 minutes ago" stay still while the cache
78
+ * quietly keeps itself fresh underneath.
79
+ */
80
+ export type QueryState<T> = {|
81
+ readonly status: QueryStatus,
82
+ readonly fetchStatus: FetchStatus,
83
+ readonly data: T | void,
84
+ readonly dataUpdatedAt: number,
85
+ readonly checkedAt: number,
86
+ readonly error: Error | null,
87
+ readonly errorUpdatedAt: number,
88
+ /** Failed attempts in the current request, reset when one is started. */
89
+ readonly failureCount: number,
90
+ readonly failureReason: Error | null,
91
+ /** Set by invalidation: stale regardless of the clock. */
92
+ readonly invalidated: boolean,
93
+ readonly direction: FetchDirection | null,
94
+ |};
95
+
96
+ /**
97
+ * What a query function is called with.
98
+ *
99
+ * `signal` is a getter; see the module docs for what reading it means.
100
+ * `previousData` is what the cache holds right now, which a paged query needs
101
+ * and an ordinary one can use for a conditional request.
102
+ */
103
+ export type FetchContext<T> = {|
104
+ readonly queryKey: QueryKey,
105
+ readonly signal: AbortSignal,
106
+ readonly previousData: T | void,
107
+ readonly failureCount: number,
108
+ |};
109
+
110
+ /** How an entry is refilled. One attempt; retrying is [`runWithRetry`]'s job. */
111
+ export type Fetcher<T> = (context: FetchContext<T>) => Promise<T>;
112
+
113
+ /**
114
+ * What a query needs from the thing watching it.
115
+ *
116
+ * An interface rather than a concrete observer type, so this module does not
117
+ * depend on the React-facing one. A query has to be able to say "somebody
118
+ * refresh me" during invalidation, and it must not have to know what a React
119
+ * hook is to say it.
120
+ */
121
+ export interface QueryWatcher {
122
+ /** The state changed. Whether that is worth a render is the watcher's call. */
123
+ onQueryUpdate(): void;
124
+ /** Whether this watcher currently wants the key fetched at all. */
125
+ isEnabled(): boolean;
126
+ /** Refetch now, superseding anything in flight. Never rejects. */
127
+ refetchNow(): Promise<void>;
128
+ }
129
+
130
+ /** How long an unobserved entry is kept, so a quick navigation back is free. */
131
+ export const DEFAULT_GC_TIME: number = 5 * 60_000;
132
+
133
+ /**
134
+ * What is known about a key nobody has fetched.
135
+ *
136
+ * One shared object rather than one per entry, and safe to share precisely
137
+ * because state is replaced rather than edited. It is also what a component
138
+ * reads on its first render, before the effect that builds the entry has run —
139
+ * reading a key that does not exist yet must not create it.
140
+ */
141
+ export const EMPTY_STATE: QueryState<empty> = Object.freeze({
142
+ status: "pending",
143
+ fetchStatus: "idle",
144
+ data: undefined,
145
+ dataUpdatedAt: 0,
146
+ checkedAt: 0,
147
+ error: null,
148
+ errorUpdatedAt: 0,
149
+ failureCount: 0,
150
+ failureReason: null,
151
+ invalidated: false,
152
+ direction: null,
153
+ });
154
+
155
+ export class Query<T> {
156
+ readonly cache: QueryCache;
157
+ readonly key: QueryKey;
158
+ readonly hash: string;
159
+
160
+ state: QueryState<T>;
161
+ gcTime: number;
162
+
163
+ observers: Array<QueryWatcher> = [];
164
+
165
+ /** The request in flight, which a second caller joins rather than repeats. */
166
+ pending: Promise<T | void> | null = null;
167
+ controller: AbortController | null = null;
168
+ /** Whether the query function read `signal`. See the module docs. */
169
+ signalConsumed: boolean = false;
170
+ /** Only the current request may write. Bumped by every start and cancel. */
171
+ fetchId: number = 0;
172
+ /** The state to put back if the request in flight is cancelled and reverted. */
173
+ restore: QueryState<T> | null = null;
174
+
175
+ gcTimer: TimeoutID | null = null;
176
+
177
+ constructor(cache: QueryCache, key: QueryKey, gcTime: number = DEFAULT_GC_TIME) {
178
+ this.cache = cache;
179
+ this.key = key;
180
+ this.hash = hashKey(key);
181
+ this.gcTime = gcTime;
182
+ this.state = EMPTY_STATE;
183
+ // Scheduled from the moment it exists, not from the moment an observer
184
+ // leaves: an entry created by a prefetch that nothing ever mounted has no
185
+ // observer to lose, and without this it would sit in the map forever. The
186
+ // first `addObserver` clears it.
187
+ this.scheduleGc();
188
+ }
189
+
190
+ /**
191
+ * Fill the entry, or join the request that is already doing it.
192
+ *
193
+ * `cancelRefetch` is the difference between "somebody mounted and wants this
194
+ * key" and "somebody pressed refresh". The first joins whatever is in
195
+ * flight; the second replaces it, because a reader who asked for fresh data
196
+ * after typing into a filter must not be given the answer to the previous
197
+ * filter just because it was already on its way.
198
+ */
199
+ fetch(
200
+ fetcher: Fetcher<T>,
201
+ options: {|
202
+ readonly retry: RetryPolicy,
203
+ readonly retryDelay: RetryDelay,
204
+ readonly cancelRefetch?: boolean,
205
+ readonly direction?: FetchDirection | null,
206
+ |},
207
+ ): Promise<T | void> {
208
+ if (this.pending != null) {
209
+ if (options.cancelRefetch !== true) {
210
+ return this.pending;
211
+ }
212
+ this.cancel({ revert: false });
213
+ }
214
+
215
+ const id = this.fetchId + 1;
216
+ this.fetchId = id;
217
+ const controller = new AbortController();
218
+ this.controller = controller;
219
+ this.signalConsumed = false;
220
+ this.restore = this.state;
221
+ this.setState({
222
+ fetchStatus: "fetching",
223
+ direction: options.direction ?? null,
224
+ failureCount: 0,
225
+ failureReason: null,
226
+ });
227
+
228
+ const promise = this.run(id, controller, fetcher, options);
229
+ this.pending = promise;
230
+ return promise;
231
+ }
232
+
233
+ /**
234
+ * The body of one request, from the first attempt to the state it leaves.
235
+ *
236
+ * Separate from [`fetch`] only because it is `async`: `fetch` has to assign
237
+ * `pending` synchronously — a second caller in the same tick is exactly the
238
+ * case de-duplication exists for — and an `async` function would not have
239
+ * returned yet at the point that assignment has to happen.
240
+ */
241
+ async run(
242
+ id: number,
243
+ controller: AbortController,
244
+ fetcher: Fetcher<T>,
245
+ options: {| readonly retry: RetryPolicy, readonly retryDelay: RetryDelay |},
246
+ ): Promise<T | void> {
247
+ try {
248
+ const value = await runWithRetry({
249
+ attempt: (failureCount) => fetcher(this.contextFor(controller, failureCount)),
250
+ retry: options.retry,
251
+ retryDelay: options.retryDelay,
252
+ signal: controller.signal,
253
+ onFailure: (failureCount, error) => {
254
+ if (this.fetchId === id) {
255
+ this.setState({ failureCount, failureReason: error });
256
+ }
257
+ },
258
+ });
259
+
260
+ // Superseded while it was in flight. The newer request owns the entry;
261
+ // writing here would put the older answer down last.
262
+ if (this.fetchId !== id) {
263
+ return this.state.data;
264
+ }
265
+ if (value === undefined) {
266
+ // Almost always a query function that forgot to return. Reported as a
267
+ // failure rather than stored, because `undefined` in the cache is
268
+ // indistinguishable from "nothing has been fetched" and would leave the
269
+ // entry loading forever.
270
+ throw new Error(
271
+ `the query function for ${this.hash} resolved with undefined; return null for "there is no such thing"`,
272
+ );
273
+ }
274
+ this.settle(id);
275
+ this.setData(value, Date.now());
276
+ return this.state.data;
277
+ } catch (thrown) {
278
+ const error = asError(thrown);
279
+ if (this.fetchId !== id) {
280
+ throw error;
281
+ }
282
+ this.settle(id);
283
+ // A cancellation is not a failure to report: `cancel` has already put the
284
+ // entry back the way the reader asked for, and recording an error would
285
+ // paint a message over data that is still perfectly good.
286
+ if (!(error instanceof CancelledError)) {
287
+ this.setState({
288
+ status: "error",
289
+ error,
290
+ errorUpdatedAt: Date.now(),
291
+ fetchStatus: "idle",
292
+ direction: null,
293
+ });
294
+ }
295
+ throw error;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Stop the request in flight.
301
+ *
302
+ * `revert` puts the entry back as it was before the request started, which
303
+ * is what an interrupted refresh should look like: the reader keeps what
304
+ * they were reading and no spinner is left running. Without it the entry
305
+ * would sit at `fetching` forever, because the request that was going to
306
+ * clear that flag is the one being thrown away.
307
+ */
308
+ cancel(options?: {| readonly revert?: boolean |}): void {
309
+ const controller = this.controller;
310
+ const restore = this.restore;
311
+
312
+ // Before the abort, so the rejection it causes already sees an id that
313
+ // says "you no longer own this entry".
314
+ this.fetchId += 1;
315
+ this.pending = null;
316
+ this.controller = null;
317
+ this.restore = null;
318
+ controller?.abort(new CancelledError());
319
+
320
+ if (options?.revert === true && restore != null) {
321
+ this.state = restore;
322
+ this.notify();
323
+ return;
324
+ }
325
+ if (this.state.fetchStatus !== "idle") {
326
+ this.setState({ fetchStatus: "idle", direction: null });
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Put a value in without asking anybody.
332
+ *
333
+ * The value goes through structural sharing first, so writing data that is
334
+ * deeply equal to what is already there changes nothing observable — no new
335
+ * identity, no re-render, no memoised child re-running. That is what makes
336
+ * an optimistic update that guessed right free, and it is why `checkedAt`
337
+ * moves while `dataUpdatedAt` does not.
338
+ */
339
+ setData(value: T, at: number = Date.now()): T | void {
340
+ const shared = structuralShare(this.state.data, value);
341
+ const changed = shared !== this.state.data || this.state.status !== "success";
342
+ this.setState({
343
+ data: shared,
344
+ status: "success",
345
+ error: null,
346
+ errorUpdatedAt: 0,
347
+ fetchStatus: "idle",
348
+ direction: null,
349
+ invalidated: false,
350
+ failureCount: 0,
351
+ failureReason: null,
352
+ dataUpdatedAt: changed ? at : this.state.dataUpdatedAt,
353
+ checkedAt: at,
354
+ });
355
+ return this.state.data;
356
+ }
357
+
358
+ /** Mark the entry stale whatever the clock says. */
359
+ invalidate(): void {
360
+ if (!this.state.invalidated) {
361
+ this.setState({ invalidated: true });
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Whether the entry is old enough to be worth asking again.
367
+ *
368
+ * An entry that has never been answered is stale, an invalidated one is
369
+ * stale, and `Infinity` means never. Note that this is measured from
370
+ * `checkedAt`: a refresh that returned identical data still counts as having
371
+ * checked, or a poll over unchanging data would refetch on every render.
372
+ */
373
+ isStale(staleTime: number): boolean {
374
+ if (this.state.invalidated || this.state.checkedAt === 0) {
375
+ return true;
376
+ }
377
+ if (staleTime === Number.POSITIVE_INFINITY) {
378
+ return false;
379
+ }
380
+ return Date.now() - this.state.checkedAt >= staleTime;
381
+ }
382
+
383
+ /** Whether anything currently wants this key. */
384
+ isActive(): boolean {
385
+ return this.observers.some((watcher) => watcher.isEnabled());
386
+ }
387
+
388
+ /**
389
+ * Refetch on behalf of whoever is watching.
390
+ *
391
+ * Invalidation reaches entries, not components, so the entry has to be able
392
+ * to ask. The first enabled watcher's request speaks for the key: they all
393
+ * fetch the same key, and de-duplication joins the rest to it.
394
+ */
395
+ refetch(): Promise<void> {
396
+ const watcher = this.observers.find((candidate) => candidate.isEnabled());
397
+ return watcher == null ? Promise.resolve() : watcher.refetchNow();
398
+ }
399
+
400
+ addObserver(watcher: QueryWatcher, gcTime: number): void {
401
+ this.clearGc();
402
+ // The longest-lived observer wins, because dropping the entry while a
403
+ // component that asked to keep it is still mounted is the one outcome
404
+ // neither of them wanted.
405
+ this.gcTime = Math.max(this.gcTime, gcTime);
406
+ if (!this.observers.includes(watcher)) {
407
+ this.observers.push(watcher);
408
+ }
409
+ }
410
+
411
+ removeObserver(watcher: QueryWatcher): void {
412
+ const index = this.observers.indexOf(watcher);
413
+ if (index < 0) {
414
+ return;
415
+ }
416
+ this.observers.splice(index, 1);
417
+ if (this.observers.length > 0) {
418
+ return;
419
+ }
420
+ if (this.pending != null && this.signalConsumed) {
421
+ this.cancel({ revert: true });
422
+ }
423
+ // Unless this entry has already been dropped — which is how the last
424
+ // observer usually leaves a removed one. Scheduling its collection would
425
+ // leave a timer holding a dead entry for the whole `gcTime`, to collect
426
+ // something the cache stopped answering for long ago.
427
+ if (this.cache.get(this.hash) === this) {
428
+ this.scheduleGc();
429
+ }
430
+ }
431
+
432
+ /** Replace the state and tell everybody. */
433
+ setState(patch: { +[string]: mixed }): void {
434
+ this.state = { ...this.state, ...patch } as $FlowFixMe;
435
+ this.notify();
436
+ }
437
+
438
+ notify(): void {
439
+ // A copy, because a watcher may well unsubscribe in response — a component
440
+ // unmounting on the error it was just told about is an ordinary thing.
441
+ for (const watcher of this.observers.slice()) {
442
+ watcher.onQueryUpdate();
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Schedule the entry's removal, now that nobody is watching.
448
+ *
449
+ * Kept for a while rather than dropped immediately, because navigating away
450
+ * and straight back is the common case and it should not cost a request.
451
+ */
452
+ scheduleGc(): void {
453
+ this.clearGc();
454
+ if (this.gcTime === Number.POSITIVE_INFINITY) {
455
+ return;
456
+ }
457
+ this.gcTimer = setTimeout(() => {
458
+ this.gcTimer = null;
459
+ if (this.observers.length === 0) {
460
+ this.cache.remove(this);
461
+ }
462
+ }, this.gcTime);
463
+ // A pending collection must not hold the process open: a script that has
464
+ // finished its work should exit, not wait five minutes to throw away a
465
+ // cache it is about to lose anyway.
466
+ (this.gcTimer as $FlowFixMe)?.unref?.();
467
+ }
468
+
469
+ clearGc(): void {
470
+ if (this.gcTimer != null) {
471
+ clearTimeout(this.gcTimer);
472
+ this.gcTimer = null;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Give up on the entry entirely: no request, no timer, nothing kept.
478
+ *
479
+ * The watchers are told, and told *after* the cache has stopped answering
480
+ * for this key. Usually there are none — collection only happens once the
481
+ * last one has gone. When there are, it is because `removeQueries` reached a
482
+ * key somebody is looking at, and this notification is how they find out
483
+ * that the entry they hold is not the one their key means any more.
484
+ */
485
+ destroy(): void {
486
+ this.clearGc();
487
+ this.cancel({ revert: false });
488
+ this.notify();
489
+ }
490
+
491
+ settle(id: number): void {
492
+ if (this.fetchId === id) {
493
+ this.pending = null;
494
+ this.controller = null;
495
+ this.restore = null;
496
+ }
497
+ }
498
+
499
+ contextFor(controller: AbortController, failureCount: number): FetchContext<T> {
500
+ const query = this;
501
+ return {
502
+ queryKey: this.key,
503
+ previousData: this.state.data,
504
+ failureCount,
505
+ // The one getter in this package, and the effect it hides is the point:
506
+ // asking for the signal *is* how a query function says it can be
507
+ // cancelled. The alternative is an option the caller has to remember to
508
+ // set beside the `signal` they already passed to `fetch`, which would be
509
+ // wrong by default in whichever direction it defaulted. Nothing that
510
+ // renders ever reads this object.
511
+ // uf-lint-disable-next-line flow/unsafe-getters-setters
512
+ get signal(): AbortSignal {
513
+ query.signalConsumed = true;
514
+ return controller.signal;
515
+ },
516
+ };
517
+ }
518
+ }