@spooky-sync/client-solid2 0.0.1-canary.238 → 0.0.1-canary.240

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.
@@ -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
+ });
@@ -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
+ }