@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/AGENTS.md +5 -1
- package/QUICK_START.md +6 -3
- package/README.md +2 -2
- package/dist/index.cjs +88 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +72 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -11
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-solid2/SKILL.md +6 -3
- package/src/index.ts +20 -0
- package/src/lib/__tests__/create-query.test.ts +117 -4
- package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
- package/src/lib/create-query.ts +73 -18
- package/src/lib/use-sync-activity.ts +99 -0
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('
|
|
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(
|
|
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(
|
|
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); //
|
|
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
|
+
});
|
package/src/lib/create-query.ts
CHANGED
|
@@ -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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
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 (
|
|
241
|
-
|
|
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
|
|
340
|
+
// settled by the same emission (or authority flip) that ends `isLoading`.
|
|
294
341
|
const readyGate = createMemo(
|
|
295
342
|
async (): Promise<true> => {
|
|
296
|
-
if (
|
|
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
|
|
320
|
-
|
|
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
|
+
}
|