@spooky-sync/client-solid2 0.0.1-canary.237 → 0.0.1-canary.239

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/src/index.ts CHANGED
@@ -47,6 +47,11 @@ export { createQuery, useQuery, type CreateQueryResult, type QueryOptions } from
47
47
  export { createPreload } from './lib/create-preload';
48
48
  export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
49
49
  export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
50
+ export {
51
+ useSyncActivity,
52
+ type UseSyncActivity,
53
+ type UseSyncActivityOptions,
54
+ } from './lib/use-sync-activity';
50
55
  export type {
51
56
  SyncHealth,
52
57
  SyncHealthStatus,
@@ -345,6 +350,21 @@ export class SyncedDb<S extends SchemaStructure> {
345
350
  return this.sp00ky.subscribeToPendingMutations(cb);
346
351
  }
347
352
 
353
+ /** Queries mid-fetch right now. See {@link useSyncActivity}. */
354
+ get fetchingQueryCount(): number {
355
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
356
+ return this.sp00ky.fetchingQueryCount;
357
+ }
358
+
359
+ /**
360
+ * Observe how many queries are fetching from the server. Fires immediately
361
+ * and on every change. Prefer the `useSyncActivity` hook in components.
362
+ */
363
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
364
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
365
+ return this.sp00ky.subscribeToFetchActivity(cb);
366
+ }
367
+
348
368
  /** Current sync-health snapshot. See {@link useSyncStatus}. */
349
369
  get syncHealth(): SyncHealth {
350
370
  if (!this.sp00ky) throw new Error('SyncedDb not initialized');
@@ -15,9 +15,17 @@ type Emission = Record<string, any>[];
15
15
  function mockEngine() {
16
16
  const subs = new Map<string, (e: Emission) => void>();
17
17
  const statusSubs = new Map<string, (s: string) => void>();
18
+ const authoritySubs = new Map<string, (known: boolean) => void>();
18
19
  const unsubscribed: string[] = [];
19
20
  const deregistered: string[] = [];
20
21
  const sp00ky = {
22
+ subscribeQueryAuthority: vi.fn(
23
+ (hash: string, cb: (known: boolean) => void, o?: { immediate?: boolean }) => {
24
+ authoritySubs.set(hash, cb);
25
+ if (o?.immediate) cb(false);
26
+ return () => authoritySubs.delete(hash);
27
+ }
28
+ ),
21
29
  subscribe: vi.fn(async (hash: string, cb: (e: Emission) => void, _o?: unknown) => {
22
30
  subs.set(hash, cb);
23
31
  return () => {
@@ -42,6 +50,7 @@ function mockEngine() {
42
50
  sp00ky,
43
51
  emit: (hash: string, e: Emission) => subs.get(hash)?.(e),
44
52
  setStatus: (hash: string, s: string) => statusSubs.get(hash)?.(s),
53
+ setAuthority: (hash: string, known: boolean) => authoritySubs.get(hash)?.(known),
45
54
  unsubscribed,
46
55
  deregistered,
47
56
  hasSub: (hash: string) => subs.has(hash),
@@ -79,6 +88,10 @@ describe('createQuery', () => {
79
88
 
80
89
  expect(q.data().map((r: any) => r.id)).toEqual(['a', 'b']);
81
90
  expect(q.isLoading()).toBe(false);
91
+ // Rows alone do not settle a query; the server's membership does.
92
+ expect(q.isSettled()).toBe(false);
93
+ eng.setAuthority('h1', true);
94
+ await settle();
82
95
  expect(q.isSettled()).toBe(true);
83
96
 
84
97
  const rowA = q.data()[0];
@@ -96,7 +109,55 @@ describe('createQuery', () => {
96
109
  });
97
110
  });
98
111
 
99
- it('first empty emission does not mark fetched; second does', async () => {
112
+ it('an empty emission before the server answers keeps loading', async () => {
113
+ // Nothing cached and nothing heard from the server: the only honest state
114
+ // is "loading". A second `[]` does not change that; only authority does.
115
+ const eng = mockEngine();
116
+ await createRoot(async (dispose) => {
117
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
118
+ createEffect(
119
+ () => q.data(),
120
+ () => {}
121
+ );
122
+ flush();
123
+ await settle();
124
+
125
+ eng.emit('h1', []);
126
+ eng.emit('h1', []);
127
+ await settle();
128
+ expect(q.isLoading()).toBe(true);
129
+ expect(q.hasData()).toBe(false);
130
+ expect(q.isEmpty()).toBe(false);
131
+ expect(q.isAuthoritative()).toBe(false);
132
+ dispose();
133
+ });
134
+ });
135
+
136
+ it('the server answering with no rows ends loading as empty', async () => {
137
+ const eng = mockEngine();
138
+ await createRoot(async (dispose) => {
139
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
140
+ createEffect(
141
+ () => q.data(),
142
+ () => {}
143
+ );
144
+ flush();
145
+ await settle();
146
+
147
+ eng.emit('h1', []);
148
+ eng.setAuthority('h1', true);
149
+ await settle();
150
+ expect(q.isLoading()).toBe(false);
151
+ expect(q.isEmpty()).toBe(true);
152
+ expect(q.isAuthoritative()).toBe(true);
153
+ expect(q.data()).toEqual([]);
154
+ dispose();
155
+ });
156
+ });
157
+
158
+ it('cached rows end loading before the server answers, without settling', async () => {
159
+ // Local-first paint: rows from the store are shown at once. They are not
160
+ // authoritative yet, so a windowed list must not read them as the end.
100
161
  const eng = mockEngine();
101
162
  await createRoot(async (dispose) => {
102
163
  const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
@@ -107,13 +168,64 @@ describe('createQuery', () => {
107
168
  flush();
108
169
  await settle();
109
170
 
171
+ eng.emit('h1', [{ id: 'a' }]);
172
+ await settle();
173
+ expect(q.isLoading()).toBe(false);
174
+ expect(q.hasData()).toBe(true);
175
+ expect(q.isAuthoritative()).toBe(false);
176
+ expect(q.isSettled()).toBe(false);
177
+ expect(q.isEmpty()).toBe(false);
178
+
179
+ eng.setAuthority('h1', true);
180
+ await settle();
181
+ expect(q.isSettled()).toBe(true);
182
+ dispose();
183
+ });
184
+ });
185
+
186
+ it('a one() query the server answered as absent is empty, not loading', async () => {
187
+ const eng = mockEngine();
188
+ await createRoot(async (dispose) => {
189
+ const q = createQuery<any, any, any, any, true, any>(eng.db, mockQuery('h1', true));
190
+ createEffect(
191
+ () => q.data(),
192
+ () => {}
193
+ );
194
+ flush();
195
+ await settle();
196
+
110
197
  eng.emit('h1', []);
198
+ eng.setAuthority('h1', true);
111
199
  await settle();
112
- expect(q.isLoading()).toBe(true); // still loading: local DB likely not synced
200
+ expect(q.isLoading()).toBe(false);
201
+ expect(q.isEmpty()).toBe(true);
202
+ expect(q.data()).toBeNull();
203
+ dispose();
204
+ });
205
+ });
113
206
 
207
+ it('losing authority with no rows (a bucket switch) returns to loading', async () => {
208
+ const eng = mockEngine();
209
+ await createRoot(async (dispose) => {
210
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
211
+ createEffect(
212
+ () => q.data(),
213
+ () => {}
214
+ );
215
+ flush();
216
+ await settle();
217
+
218
+ eng.emit('h1', [{ id: 'a' }]);
219
+ eng.setAuthority('h1', true);
220
+ await settle();
221
+ expect(q.isLoading()).toBe(false);
222
+
223
+ // The rebind empties the rows and resets authority for the new principal.
114
224
  eng.emit('h1', []);
225
+ eng.setAuthority('h1', false);
115
226
  await settle();
116
- expect(q.isLoading()).toBe(false); // a later empty emission is authoritative
227
+ expect(q.isLoading()).toBe(true);
228
+ expect(q.isEmpty()).toBe(false);
117
229
  dispose();
118
230
  });
119
231
  });
@@ -154,8 +266,9 @@ describe('createQuery', () => {
154
266
  expect(q.isFetching()).toBe(true);
155
267
 
156
268
  eng.emit('h1', [{ id: 'a' }]);
269
+ eng.setAuthority('h1', true);
157
270
  await settle();
158
- expect(q.isSettled()).toBe(false); // fetched but still fetching
271
+ expect(q.isSettled()).toBe(false); // authoritative but still fetching
159
272
 
160
273
  eng.setStatus('h1', 'idle');
161
274
  flush();
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createRoot, flush } from 'solid-js';
3
+ import { useSyncActivity } from '../use-sync-activity';
4
+ import { SyncedDb } from '../../index';
5
+
6
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
7
+ const settle = async () => {
8
+ await tick();
9
+ flush();
10
+ await tick();
11
+ };
12
+
13
+ /** A SyncedDb double exposing just the two channels the hook subscribes to. */
14
+ function mockDb(initialFetching = 0, initialPending = 0) {
15
+ let fetchingCb: ((n: number) => void) | undefined;
16
+ let pendingCb: ((n: number) => void) | undefined;
17
+ const db = Object.create(SyncedDb.prototype) as SyncedDb<any>;
18
+ Object.defineProperty(db, 'fetchingQueryCount', { get: () => initialFetching });
19
+ Object.defineProperty(db, 'pendingMutationCount', { get: () => initialPending });
20
+ (db as any).subscribeToFetchActivity = vi.fn((cb: (n: number) => void) => {
21
+ fetchingCb = cb;
22
+ cb(initialFetching);
23
+ return () => {
24
+ fetchingCb = undefined;
25
+ };
26
+ });
27
+ (db as any).subscribeToPendingMutations = vi.fn((cb: (n: number) => void) => {
28
+ pendingCb = cb;
29
+ cb(initialPending);
30
+ return () => {
31
+ pendingCb = undefined;
32
+ };
33
+ });
34
+ return {
35
+ db,
36
+ setFetching: (n: number) => fetchingCb?.(n),
37
+ setPending: (n: number) => pendingCb?.(n),
38
+ };
39
+ }
40
+
41
+ describe('useSyncActivity', () => {
42
+ beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
43
+ afterEach(() => vi.useRealTimers());
44
+
45
+ it('turns isDownloading on only after the delay, and off at once', async () => {
46
+ const m = mockDb();
47
+ await createRoot(async (dispose) => {
48
+ const a = useSyncActivity(m.db, { downloadDelayMs: 200 });
49
+ await settle();
50
+ expect(a.fetchingQueries()).toBe(0);
51
+ expect(a.isDownloading()).toBe(false);
52
+
53
+ m.setFetching(2);
54
+ await settle();
55
+ expect(a.fetchingQueries()).toBe(2);
56
+ expect(a.isDownloading()).toBe(false);
57
+ await vi.advanceTimersByTimeAsync(150);
58
+ flush();
59
+ expect(a.isDownloading()).toBe(false);
60
+ await vi.advanceTimersByTimeAsync(60);
61
+ flush();
62
+ expect(a.isDownloading()).toBe(true);
63
+
64
+ m.setFetching(0);
65
+ await settle();
66
+ expect(a.isDownloading()).toBe(false);
67
+ dispose();
68
+ });
69
+ });
70
+
71
+ it('a fetch shorter than the delay never shows', async () => {
72
+ const m = mockDb();
73
+ await createRoot(async (dispose) => {
74
+ const a = useSyncActivity(m.db, { downloadDelayMs: 200 });
75
+ await settle();
76
+ m.setFetching(1);
77
+ await settle();
78
+ await vi.advanceTimersByTimeAsync(100);
79
+ m.setFetching(0);
80
+ await settle();
81
+ await vi.advanceTimersByTimeAsync(300);
82
+ flush();
83
+ expect(a.isDownloading()).toBe(false);
84
+ dispose();
85
+ });
86
+ });
87
+
88
+ it('isUploading needs more than the threshold of queued writes', async () => {
89
+ const m = mockDb(0, 1);
90
+ await createRoot(async (dispose) => {
91
+ const a = useSyncActivity(m.db);
92
+ await settle();
93
+ expect(a.pendingMutations()).toBe(1);
94
+ expect(a.isUploading()).toBe(false);
95
+ m.setPending(2);
96
+ await settle();
97
+ expect(a.isUploading()).toBe(true);
98
+ m.setPending(0);
99
+ await settle();
100
+ expect(a.isUploading()).toBe(false);
101
+ dispose();
102
+ });
103
+ });
104
+ });
@@ -59,13 +59,35 @@ export type CreateQueryResult<TData> = {
59
59
  */
60
60
  ready: Accessor<TData>;
61
61
  error: Accessor<Error | undefined>;
62
+ /**
63
+ * True only on a COLD first load: nothing has painted for this identity,
64
+ * the server has not answered yet, and registration has not failed. A query
65
+ * that painted from the local cache is not loading, however long the server
66
+ * takes; a query the server answered with zero rows is not loading either
67
+ * (see `isEmpty`). Resets when the query identity changes.
68
+ */
62
69
  isLoading: Accessor<boolean>;
70
+ /** True while a fetch cycle is in flight (registration, a sync round). */
63
71
  isFetching: Accessor<boolean>;
64
72
  /**
65
- * True once the query has delivered a result AND no fetch cycle is in
66
- * flight (registration + initial sync included). While settled, results are
67
- * authoritative: a windowed query returning fewer rows than its LIMIT
68
- * really is the end of the list. Resets when the query identity changes.
73
+ * True once server membership is known for this identity: a registration or
74
+ * poll landed, or the durable membership of a previous session was read on
75
+ * boot. From then on `data()` is the server's answer, not a cache guess.
76
+ * Latched until the identity changes (or a bucket switch resets it).
77
+ */
78
+ isAuthoritative: Accessor<boolean>;
79
+ /** Whether `data()` currently holds anything: rows for a list, a row for `one()`. */
80
+ hasData: Accessor<boolean>;
81
+ /**
82
+ * The server answered and the answer is "no rows". The signal to render an
83
+ * empty state on; a bare `!hasData()` cannot tell empty from not-yet-loaded.
84
+ */
85
+ isEmpty: Accessor<boolean>;
86
+ /**
87
+ * True once server membership is known AND no fetch cycle is in flight.
88
+ * While settled, results are authoritative: a windowed query returning fewer
89
+ * rows than its LIMIT really is the end of the list. Not monotonic: a later
90
+ * sync round re-enters `isFetching`; latch it if a verdict must stick.
69
91
  */
70
92
  isSettled: Accessor<boolean>;
71
93
  };
@@ -132,7 +154,12 @@ export function createQuery<
132
154
  // continuations, which run outside any tracking scope — `ownedWrite` opts
133
155
  // these signals out of Solid 2's owned-scope write guard.
134
156
  const [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true });
135
- const [isFetched, setIsFetched] = createSignal(false, { ownedWrite: true });
157
+ // Server membership known for the current identity (core `membershipKnown`).
158
+ const [isAuthoritative, setIsAuthoritative] = createSignal(false, { ownedWrite: true });
159
+ // An emission WITH data has been applied for the current identity. Together
160
+ // with `isAuthoritative` this is what ends the cold-load state: a cached
161
+ // paint ends it without the server, a server answer ends it without rows.
162
+ const [hasPainted, setHasPainted] = createSignal(false, { ownedWrite: true });
136
163
  const [isFetching, setIsFetching] = createSignal(false, { ownedWrite: true });
137
164
 
138
165
  // The hash of the currently-installed subscription, for opt-in deregister on
@@ -180,7 +207,8 @@ export function createQuery<
180
207
  const myRun = ++runId;
181
208
  // A new identity starts clean: a previous identity's failure must not
182
209
  // keep this one out of its loading state.
183
- setIsFetched(false);
210
+ setIsAuthoritative(false);
211
+ setHasPainted(false);
184
212
  setError(undefined);
185
213
  if (!enabled || !query) return;
186
214
 
@@ -224,24 +252,43 @@ export function createQuery<
224
252
  })
225
253
  );
226
254
 
227
- let isFirstCall = true;
255
+ // Mirror whether the server's membership is known. This is the
256
+ // channel that ends a cold load for a query whose honest answer is
257
+ // zero rows: the core emits no second `[]` for it, but it does flip
258
+ // authority once the registration (or a poll) has landed. A flip
259
+ // back to false is a bucket switch (a different principal), whose
260
+ // rebind also empties the rows, so the paint latch goes with it.
261
+ addCleanup(
262
+ sp00ky.subscribeQueryAuthority(
263
+ hash,
264
+ (known) => {
265
+ if (disposed || myRun !== runId) return;
266
+ setIsAuthoritative(known);
267
+ if (known) wakeReady();
268
+ else setHasPainted(false);
269
+ },
270
+ { immediate: true }
271
+ )
272
+ );
273
+
228
274
  addCleanup(
229
275
  sp00ky.subscribe(
230
276
  hash,
231
277
  (rows: Record<string, any>[]) => {
232
278
  if (disposed || myRun !== runId) return;
233
279
  const queryData = (query.isOne ? (rows[0] ?? null) : rows) as TData;
234
- // The first (immediate) callback with no data likely means the
235
- // local DB has not synced yet — don't mark as fetched, so the
236
- // UI keeps showing its loading state.
237
- const hasData = query.isOne
280
+ // Every emission is applied as-is: the core only emits what
281
+ // it has decided the query holds (cached rows on mount, the
282
+ // server's set once membership is known), and it never emits
283
+ // a wrongful `[]` for a lost or still-publishing view. An
284
+ // emission WITH data ends the cold-load state on its own.
285
+ const hasRows = query.isOne
238
286
  ? queryData !== null && queryData !== undefined
239
287
  : rows.length > 0;
240
- if (!isFirstCall || hasData) {
241
- setIsFetched(true);
288
+ if (hasRows) {
289
+ setHasPainted(true);
242
290
  wakeReady();
243
291
  }
244
- isFirstCall = false;
245
292
 
246
293
  const t0 = performance.now();
247
294
  setStore((s) => {
@@ -290,10 +337,10 @@ export function createQuery<
290
337
  // async node stays PENDING, every queue flush carries pending nodes forward,
291
338
  // and a navigation transition waits on them. Lazy means only a query whose
292
339
  // `ready()` is read creates the node at all, and the deferred below is
293
- // settled by the same emission that flips `isFetched`.
340
+ // settled by the same emission (or authority flip) that ends `isLoading`.
294
341
  const readyGate = createMemo(
295
342
  async (): Promise<true> => {
296
- if (isFetched() || error()) return true;
343
+ if (isAuthoritative() || hasPainted() || error()) return true;
297
344
  await new Promise<void>((resolve) => readyWaiters.push(resolve));
298
345
  return true;
299
346
  },
@@ -316,8 +363,13 @@ export function createQuery<
316
363
  }
317
364
  });
318
365
 
319
- const isLoading = () => !isFetched() && error() === undefined;
320
- const isSettled = () => isFetched() && !isFetching();
366
+ const hasData = () => {
367
+ const v = store.value as unknown;
368
+ return Array.isArray(v) ? v.length > 0 : v !== null && v !== undefined;
369
+ };
370
+ const isLoading = () => !isAuthoritative() && !hasPainted() && error() === undefined;
371
+ const isEmpty = () => isAuthoritative() && !hasData();
372
+ const isSettled = () => isAuthoritative() && !isFetching();
321
373
 
322
374
  return {
323
375
  data,
@@ -325,6 +377,9 @@ export function createQuery<
325
377
  error,
326
378
  isLoading,
327
379
  isFetching,
380
+ isAuthoritative,
381
+ hasData,
382
+ isEmpty,
328
383
  isSettled,
329
384
  };
330
385
  }
@@ -0,0 +1,99 @@
1
+ import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import { fromSubscription } from './from-subscription';
4
+ import type { SyncedDb } from '../index';
5
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
6
+
7
+ export interface UseSyncActivityOptions {
8
+ /**
9
+ * How long queries must be fetching before `isDownloading()` turns on.
10
+ * Filters the sub-frame fetches a warm cache produces, so the indicator only
11
+ * shows work the user could notice. Default 200 ms.
12
+ */
13
+ downloadDelayMs?: number;
14
+ /**
15
+ * `isUploading()` turns on once MORE than this many mutations are waiting in
16
+ * the outbox. Default 1: a single write acknowledged within a round trip is
17
+ * not worth an animation; a backlog is.
18
+ */
19
+ uploadThreshold?: number;
20
+ }
21
+
22
+ export interface UseSyncActivity {
23
+ /** Queries inside a fetch cycle right now (core `fetchingQueryCount`). */
24
+ fetchingQueries: Accessor<number>;
25
+ /** Locally committed writes the server has not acknowledged yet. */
26
+ pendingMutations: Accessor<number>;
27
+ /** Fetching for longer than `downloadDelayMs`. Drives a "downloading" mark. */
28
+ isDownloading: Accessor<boolean>;
29
+ /** More than `uploadThreshold` writes queued. Drives an "uploading" mark. */
30
+ isUploading: Accessor<boolean>;
31
+ }
32
+
33
+ /**
34
+ * The two directions of sync traffic, for an indicator in the app chrome.
35
+ *
36
+ * `fetchingQueries` is one subscription on the engine's aggregate fetch count,
37
+ * not one per query; `pendingMutations` is the outbox depth. `isDownloading`
38
+ * is the fetch count debounced ON by `downloadDelayMs` (and off at once), so a
39
+ * local-first page that answers from cache and confirms with the server in a
40
+ * few milliseconds never flickers. Must be used within a `<Sp00kyProvider>`, or
41
+ * pass the `SyncedDb` explicitly.
42
+ */
43
+ export function useSyncActivity<S extends SchemaStructure = any>(
44
+ dbOrOptions?: SyncedDb<S> | UseSyncActivityOptions,
45
+ maybeOptions?: UseSyncActivityOptions
46
+ ): UseSyncActivity {
47
+ const explicitDb =
48
+ dbOrOptions && typeof (dbOrOptions as SyncedDb<S>).subscribeToFetchActivity === 'function'
49
+ ? (dbOrOptions as SyncedDb<S>)
50
+ : undefined;
51
+ const options = (explicitDb ? maybeOptions : (dbOrOptions as UseSyncActivityOptions)) ?? {};
52
+ const db = explicitDb ?? useDb<S>();
53
+ const downloadDelayMs = options.downloadDelayMs ?? 200;
54
+ const uploadThreshold = options.uploadThreshold ?? 1;
55
+
56
+ const fetchingQueries = fromSubscription<number>(
57
+ (cb) => db.subscribeToFetchActivity(cb),
58
+ db.fetchingQueryCount
59
+ );
60
+ const pendingMutations = fromSubscription<number>(
61
+ (cb) => db.subscribeToPendingMutations(cb),
62
+ db.pendingMutationCount
63
+ );
64
+
65
+ // ON after the delay, OFF immediately. Written from a timer, hence ownedWrite.
66
+ const [isDownloading, setIsDownloading] = createSignal(false, { ownedWrite: true });
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+ createEffect(
69
+ () => fetchingQueries() > 0,
70
+ (busy) => {
71
+ if (timer !== undefined) {
72
+ clearTimeout(timer);
73
+ timer = undefined;
74
+ }
75
+ if (!busy) {
76
+ setIsDownloading(false);
77
+ return;
78
+ }
79
+ if (downloadDelayMs <= 0) {
80
+ setIsDownloading(true);
81
+ return;
82
+ }
83
+ timer = setTimeout(() => {
84
+ timer = undefined;
85
+ setIsDownloading(true);
86
+ }, downloadDelayMs);
87
+ }
88
+ );
89
+ onCleanup(() => {
90
+ if (timer !== undefined) clearTimeout(timer);
91
+ });
92
+
93
+ return {
94
+ fetchingQueries,
95
+ pendingMutations,
96
+ isDownloading,
97
+ isUploading: () => pendingMutations() > uploadThreshold,
98
+ };
99
+ }