@spooky-sync/client-solid2 0.0.1-alpha.1
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 +81 -0
- package/QUICK_START.md +129 -0
- package/README.md +19 -0
- package/dist/index.cjs +1290 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +670 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +670 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1266 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/skills/sp00ky-solid2/SKILL.md +71 -0
- package/src/index.ts +410 -0
- package/src/lib/Blurhash.ts +64 -0
- package/src/lib/BucketImage.ts +170 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/__tests__/conflate.test.ts +120 -0
- package/src/lib/__tests__/create-query.test.ts +397 -0
- package/src/lib/__tests__/rc-semantics.test.ts +393 -0
- package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +116 -0
- package/src/lib/create-query.ts +388 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/merge-rows.test.ts +92 -0
- package/src/lib/merge-rows.ts +72 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +82 -0
- package/src/lib/use-bucket-image.ts +108 -0
- package/src/lib/use-crdt-field.ts +57 -0
- package/src/lib/use-download-file.ts +181 -0
- package/src/lib/use-feature-flag.ts +43 -0
- package/src/lib/use-file-upload.ts +146 -0
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-activity.ts +99 -0
- package/src/lib/use-sync-status.ts +63 -0
- package/src/types/index.ts +83 -0
- package/tsconfig.json +27 -0
- package/tsdown.config.ts +20 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probes against solid-js 2.0.0-rc.1 semantics this package's design depends
|
|
3
|
+
* on. If any of these fail after a Solid version bump, the binding's
|
|
4
|
+
* assumptions are broken — fix the binding, don't delete the probe.
|
|
5
|
+
*
|
|
6
|
+
* Probed assumptions (see plan):
|
|
7
|
+
* 1. A projection compute given as an async generator restarts when a
|
|
8
|
+
* dependency read before the first `await` changes, and the superseded
|
|
9
|
+
* generator is terminated (its `finally` runs) so external subscriptions
|
|
10
|
+
* can be torn down.
|
|
11
|
+
* 2. Keyed reconcile of yielded arrays notifies coarse readers (whole-array
|
|
12
|
+
* tracking, e.g. `<For>`/mapArray) on add/remove even when the array length
|
|
13
|
+
* stays equal (windowed-list delete case).
|
|
14
|
+
* 3. `seedLoadingValue: true` births the store committed: readable
|
|
15
|
+
* immediately, `isPending` false during the first flight.
|
|
16
|
+
* 4. Signals created with `{ ownedWrite: true }` accept writes from plain
|
|
17
|
+
* callbacks fired outside any tracking scope after setup.
|
|
18
|
+
* 5. Class instances (RecordId-shaped) inside store rows: document whether the
|
|
19
|
+
* proxy wraps them and that instanceof/method access still works through it.
|
|
20
|
+
* 6. Generator teardown on owner dispose runs `finally` blocks.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
createProjection,
|
|
25
|
+
createRoot,
|
|
26
|
+
createSignal,
|
|
27
|
+
createEffect,
|
|
28
|
+
createMemo,
|
|
29
|
+
isPending,
|
|
30
|
+
flush,
|
|
31
|
+
mapArray,
|
|
32
|
+
isWrappable,
|
|
33
|
+
snapshot,
|
|
34
|
+
onCleanup,
|
|
35
|
+
} from 'solid-js';
|
|
36
|
+
import * as signals from '@solidjs/signals';
|
|
37
|
+
|
|
38
|
+
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
39
|
+
|
|
40
|
+
/** Minimal push-source with subscriber tracking, stand-in for sp00ky.subscribe. */
|
|
41
|
+
function pushSource<T>() {
|
|
42
|
+
let cb: ((v: T) => void) | undefined;
|
|
43
|
+
let subscribes = 0;
|
|
44
|
+
let unsubscribes = 0;
|
|
45
|
+
return {
|
|
46
|
+
subscribe(fn: (v: T) => void) {
|
|
47
|
+
cb = fn;
|
|
48
|
+
subscribes++;
|
|
49
|
+
return () => {
|
|
50
|
+
if (cb === fn) cb = undefined;
|
|
51
|
+
unsubscribes++;
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
emit(v: T) {
|
|
55
|
+
cb?.(v);
|
|
56
|
+
},
|
|
57
|
+
get counts() {
|
|
58
|
+
return { subscribes, unsubscribes };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Push → pull adapter (same shape as ../conflate, inlined to keep the probe
|
|
64
|
+
* self-contained). */
|
|
65
|
+
function iterate<T>(subscribe: (cb: (v: T) => void) => () => void): AsyncIterable<T> {
|
|
66
|
+
return {
|
|
67
|
+
[Symbol.asyncIterator]() {
|
|
68
|
+
let buffered: { v: T } | undefined;
|
|
69
|
+
let resolveNext: ((r: IteratorResult<T>) => void) | undefined;
|
|
70
|
+
let done = false;
|
|
71
|
+
const unsub = subscribe((v) => {
|
|
72
|
+
if (done) return;
|
|
73
|
+
if (resolveNext) {
|
|
74
|
+
const r = resolveNext;
|
|
75
|
+
resolveNext = undefined;
|
|
76
|
+
r({ value: v, done: false });
|
|
77
|
+
} else {
|
|
78
|
+
buffered = { v };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const finish = (): IteratorResult<T> => {
|
|
82
|
+
if (!done) {
|
|
83
|
+
done = true;
|
|
84
|
+
unsub();
|
|
85
|
+
resolveNext?.({ value: undefined, done: true });
|
|
86
|
+
resolveNext = undefined;
|
|
87
|
+
}
|
|
88
|
+
return { value: undefined, done: true };
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
next() {
|
|
92
|
+
if (done) return Promise.resolve<IteratorResult<T>>({ value: undefined, done: true });
|
|
93
|
+
if (buffered) {
|
|
94
|
+
const v = buffered.v;
|
|
95
|
+
buffered = undefined;
|
|
96
|
+
return Promise.resolve({ value: v, done: false });
|
|
97
|
+
}
|
|
98
|
+
return new Promise<IteratorResult<T>>((r) => (resolveNext = r));
|
|
99
|
+
},
|
|
100
|
+
return() {
|
|
101
|
+
return Promise.resolve(finish());
|
|
102
|
+
},
|
|
103
|
+
throw(e: unknown) {
|
|
104
|
+
finish();
|
|
105
|
+
return Promise.reject(e);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type Row = { id: string; n?: number };
|
|
113
|
+
|
|
114
|
+
describe('probe 1: async-generator projection restart on dep change', () => {
|
|
115
|
+
it('abandons the superseded generator: teardown must be manual via onCleanup', async () => {
|
|
116
|
+
// rc.0 semantics: a dep change restarts the compute (a new generator
|
|
117
|
+
// starts) but the superseded generator is NOT terminated — no return(),
|
|
118
|
+
// no finally. Anything it subscribed to leaks unless the compute registers
|
|
119
|
+
// onCleanup (synchronously, before the first await) to tear it down.
|
|
120
|
+
// conflate() + create-query rely on exactly that onCleanup contract.
|
|
121
|
+
const src1 = pushSource<Row[]>();
|
|
122
|
+
const src2 = pushSource<Row[]>();
|
|
123
|
+
const finished: string[] = [];
|
|
124
|
+
|
|
125
|
+
await createRoot(async (dispose) => {
|
|
126
|
+
const [which, setWhich] = createSignal<'a' | 'b'>('a');
|
|
127
|
+
|
|
128
|
+
const rows = createProjection(
|
|
129
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
130
|
+
const w = which(); // tracked read BEFORE first await
|
|
131
|
+
const src = w === 'a' ? src1 : src2;
|
|
132
|
+
const it = iterate<Row[]>((cb) => src.subscribe(cb))[Symbol.asyncIterator]();
|
|
133
|
+
onCleanup(() => void it.return?.()); // manual teardown — Solid won't do it
|
|
134
|
+
try {
|
|
135
|
+
while (true) {
|
|
136
|
+
const r = await it.next();
|
|
137
|
+
if (r.done) break;
|
|
138
|
+
yield r.value;
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
finished.push(w);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
[] as Row[],
|
|
145
|
+
{ key: 'id' }
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// touch the projection so it computes
|
|
149
|
+
createEffect(
|
|
150
|
+
() => rows.length,
|
|
151
|
+
() => {}
|
|
152
|
+
);
|
|
153
|
+
flush();
|
|
154
|
+
await tick();
|
|
155
|
+
expect(src1.counts.subscribes).toBe(1);
|
|
156
|
+
|
|
157
|
+
src1.emit([{ id: 'x', n: 1 }]);
|
|
158
|
+
await tick();
|
|
159
|
+
flush();
|
|
160
|
+
expect(rows.map((r) => r.id)).toEqual(['x']);
|
|
161
|
+
|
|
162
|
+
// dep change → onCleanup fires for generator A, its finally runs, B subscribes
|
|
163
|
+
setWhich('b');
|
|
164
|
+
flush();
|
|
165
|
+
await tick();
|
|
166
|
+
await tick();
|
|
167
|
+
expect(finished).toContain('a');
|
|
168
|
+
expect(src1.counts.unsubscribes).toBe(1);
|
|
169
|
+
expect(src2.counts.subscribes).toBe(1);
|
|
170
|
+
|
|
171
|
+
src2.emit([{ id: 'y', n: 2 }]);
|
|
172
|
+
await tick();
|
|
173
|
+
flush();
|
|
174
|
+
expect(rows.map((r) => r.id)).toEqual(['y']);
|
|
175
|
+
|
|
176
|
+
dispose();
|
|
177
|
+
await tick();
|
|
178
|
+
// dispose runs the live generator's onCleanup too
|
|
179
|
+
expect(finished).toContain('b');
|
|
180
|
+
expect(src2.counts.unsubscribes).toBe(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('probe 2: keyed reconcile notifies coarse readers on same-length change', () => {
|
|
186
|
+
it('mapArray over the projection re-runs when a row is swapped', async () => {
|
|
187
|
+
const src = pushSource<Row[]>();
|
|
188
|
+
await createRoot(async (dispose) => {
|
|
189
|
+
const rows = createProjection(
|
|
190
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
191
|
+
for await (const v of iterate<Row[]>((cb) => src.subscribe(cb))) yield v;
|
|
192
|
+
},
|
|
193
|
+
[] as Row[],
|
|
194
|
+
{ key: 'id' }
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const seen: string[][] = [];
|
|
198
|
+
const mapped = mapArray(
|
|
199
|
+
() => rows,
|
|
200
|
+
(r) => r.id
|
|
201
|
+
);
|
|
202
|
+
createEffect(
|
|
203
|
+
() => mapped(),
|
|
204
|
+
(ids) => {
|
|
205
|
+
seen.push([...ids]);
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
flush();
|
|
209
|
+
await tick();
|
|
210
|
+
|
|
211
|
+
src.emit([
|
|
212
|
+
{ id: 'a', n: 1 },
|
|
213
|
+
{ id: 'b', n: 2 },
|
|
214
|
+
]);
|
|
215
|
+
await tick();
|
|
216
|
+
flush();
|
|
217
|
+
|
|
218
|
+
// same length, one row swapped (windowed-list delete: c shifts in for b)
|
|
219
|
+
src.emit([
|
|
220
|
+
{ id: 'a', n: 1 },
|
|
221
|
+
{ id: 'c', n: 3 },
|
|
222
|
+
]);
|
|
223
|
+
await tick();
|
|
224
|
+
flush();
|
|
225
|
+
|
|
226
|
+
expect(seen.at(-1)).toEqual(['a', 'c']);
|
|
227
|
+
|
|
228
|
+
// row identity for the surviving row must be stable across emissions
|
|
229
|
+
const a1 = rows[0];
|
|
230
|
+
src.emit([
|
|
231
|
+
{ id: 'a', n: 99 },
|
|
232
|
+
{ id: 'c', n: 3 },
|
|
233
|
+
]);
|
|
234
|
+
await tick();
|
|
235
|
+
flush();
|
|
236
|
+
expect(rows[0]).toBe(a1);
|
|
237
|
+
expect(rows[0].n).toBe(99);
|
|
238
|
+
|
|
239
|
+
dispose();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('probe 3: seedLoadingValue commits the seed', () => {
|
|
245
|
+
it('store is readable and not pending during first flight', async () => {
|
|
246
|
+
const src = pushSource<Row[]>();
|
|
247
|
+
await createRoot(async (dispose) => {
|
|
248
|
+
const rows = createProjection(
|
|
249
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
250
|
+
for await (const v of iterate<Row[]>((cb) => src.subscribe(cb))) yield v;
|
|
251
|
+
},
|
|
252
|
+
[] as Row[],
|
|
253
|
+
{ key: 'id', seedLoadingValue: true }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
let pendingDuringFlight: boolean | undefined;
|
|
257
|
+
let lenDuringFlight: number | undefined;
|
|
258
|
+
createEffect(
|
|
259
|
+
() => {
|
|
260
|
+
lenDuringFlight = rows.length;
|
|
261
|
+
pendingDuringFlight = isPending(() => rows.length);
|
|
262
|
+
return undefined;
|
|
263
|
+
},
|
|
264
|
+
() => {}
|
|
265
|
+
);
|
|
266
|
+
flush();
|
|
267
|
+
await tick();
|
|
268
|
+
|
|
269
|
+
expect(lenDuringFlight).toBe(0); // readable, no NotReadyError
|
|
270
|
+
expect(pendingDuringFlight).toBe(false); // commit #0, not pending
|
|
271
|
+
|
|
272
|
+
src.emit([{ id: 'a' }]);
|
|
273
|
+
await tick();
|
|
274
|
+
flush();
|
|
275
|
+
expect(rows.length).toBe(1);
|
|
276
|
+
dispose();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('probe 4: ownedWrite signals accept external-callback writes', () => {
|
|
282
|
+
it('does not throw when written from a later plain callback', async () => {
|
|
283
|
+
let later: (() => void) | undefined;
|
|
284
|
+
await createRoot(async (dispose) => {
|
|
285
|
+
const [v, setV] = createSignal(0, { ownedWrite: true });
|
|
286
|
+
later = () => setV(1);
|
|
287
|
+
createEffect(
|
|
288
|
+
() => v(),
|
|
289
|
+
() => {}
|
|
290
|
+
);
|
|
291
|
+
flush();
|
|
292
|
+
// fire from outside any reactive scope, as a subscription callback would
|
|
293
|
+
await tick();
|
|
294
|
+
expect(() => later!()).not.toThrow();
|
|
295
|
+
flush();
|
|
296
|
+
expect(v()).toBe(1);
|
|
297
|
+
dispose();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('probe 5: class instances inside store rows', () => {
|
|
303
|
+
class FakeRecordId {
|
|
304
|
+
constructor(
|
|
305
|
+
public tb: string,
|
|
306
|
+
public id: string
|
|
307
|
+
) {}
|
|
308
|
+
toString() {
|
|
309
|
+
return `${this.tb}:${this.id}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('documents wrapping behavior and that instanceof/methods survive', async () => {
|
|
314
|
+
const src = pushSource<{ id: string; rid: FakeRecordId }[]>();
|
|
315
|
+
await createRoot(async (dispose) => {
|
|
316
|
+
const rows = createProjection(
|
|
317
|
+
async function* (): AsyncGenerator<{ id: string; rid: FakeRecordId }[]> {
|
|
318
|
+
for await (const v of iterate<{ id: string; rid: FakeRecordId }[]>((cb) =>
|
|
319
|
+
src.subscribe(cb)
|
|
320
|
+
))
|
|
321
|
+
yield v;
|
|
322
|
+
},
|
|
323
|
+
[] as { id: string; rid: FakeRecordId }[],
|
|
324
|
+
{ key: 'id' }
|
|
325
|
+
);
|
|
326
|
+
createEffect(
|
|
327
|
+
() => rows.length,
|
|
328
|
+
() => {}
|
|
329
|
+
);
|
|
330
|
+
flush();
|
|
331
|
+
await tick();
|
|
332
|
+
|
|
333
|
+
src.emit([{ id: 'a', rid: new FakeRecordId('game', 'a') }]);
|
|
334
|
+
await tick();
|
|
335
|
+
flush();
|
|
336
|
+
|
|
337
|
+
const rid = rows[0].rid;
|
|
338
|
+
// Solid 2 wraps class instances (isWrappable true — unlike Solid 1).
|
|
339
|
+
expect(isWrappable(new FakeRecordId('t', 'i'))).toBe(true);
|
|
340
|
+
// Document what survives through the proxy. rc.0 served methods BOUND
|
|
341
|
+
// and `constructor.name` read 'bound FakeRecordId'; rc.1 hides the
|
|
342
|
+
// prototype entirely: no `instanceof`, no `constructor`, but own fields
|
|
343
|
+
// and prototype methods still resolve. SyncedDb.delete therefore
|
|
344
|
+
// unwraps with `snapshot` BEFORE any RecordId detection.
|
|
345
|
+
expect(rid instanceof FakeRecordId).toBe(false);
|
|
346
|
+
expect((rid as any).constructor?.name).toBeUndefined();
|
|
347
|
+
expect(rid.toString()).toBe('game:a');
|
|
348
|
+
expect(`${rid.tb}:${rid.id}`).toBe('game:a');
|
|
349
|
+
// `snapshot` must unwrap back to the raw instance so payloads passed to
|
|
350
|
+
// surrealdb (which checks instanceof) can be de-proxied at the boundary.
|
|
351
|
+
const snap = snapshot(rows[0]);
|
|
352
|
+
expect(snap.rid instanceof FakeRecordId).toBe(true);
|
|
353
|
+
expect(snapshot(rid) instanceof FakeRecordId).toBe(true);
|
|
354
|
+
dispose();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('rc.0 packaging: markRaw is typed but absent from the runtime build', () => {
|
|
359
|
+
// store.d.ts declares markRaw, but dist/dev.js does not export it. If this
|
|
360
|
+
// starts passing after a bump, adopt markRaw for RecordId/CrdtField
|
|
361
|
+
// instances at the ingest boundary and drop the snapshot() workaround.
|
|
362
|
+
expect((signals as any).markRaw).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('probe: async-generator memo (fromSubscription shape)', () => {
|
|
367
|
+
it('memo over async generator with loadingValue serves values', async () => {
|
|
368
|
+
const src = pushSource<number>();
|
|
369
|
+
await createRoot(async (dispose) => {
|
|
370
|
+
const v = createMemo(
|
|
371
|
+
async function* (): AsyncGenerator<number> {
|
|
372
|
+
for await (const x of iterate<number>((cb) => src.subscribe(cb))) yield x;
|
|
373
|
+
},
|
|
374
|
+
{ loadingValue: -1 }
|
|
375
|
+
);
|
|
376
|
+
let latest: number | undefined;
|
|
377
|
+
createEffect(
|
|
378
|
+
() => v(),
|
|
379
|
+
(x) => {
|
|
380
|
+
latest = x;
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
flush();
|
|
384
|
+
await tick();
|
|
385
|
+
expect(latest).toBe(-1); // loadingValue committed
|
|
386
|
+
src.emit(42);
|
|
387
|
+
await tick();
|
|
388
|
+
flush();
|
|
389
|
+
expect(latest).toBe(42);
|
|
390
|
+
dispose();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest-wins async iterable over a subscribe-callback source.
|
|
3
|
+
*
|
|
4
|
+
* Bridges spooky's push-callback subscriptions into the AsyncIterable shape
|
|
5
|
+
* Solid 2 computations consume natively. Each spooky emission is a full result
|
|
6
|
+
* set, so intermediate values are droppable: only the newest unconsumed value
|
|
7
|
+
* is buffered, and a pending pull resolves with it immediately.
|
|
8
|
+
*
|
|
9
|
+
* Teardown contract (probed in rc-semantics.test.ts): Solid 2 does NOT
|
|
10
|
+
* terminate a superseded/disposed computation's async generator — no
|
|
11
|
+
* `return()`, no `finally`. Consumers MUST call `it.return()` themselves from
|
|
12
|
+
* an `onCleanup` registered synchronously in the compute scope. `return()`
|
|
13
|
+
* unsubscribes (awaiting the unsubscribe if the subscribe returned a promise,
|
|
14
|
+
* as `sp00ky.subscribe` does) and resolves any parked pull as done.
|
|
15
|
+
*/
|
|
16
|
+
export function conflate<T>(
|
|
17
|
+
subscribe: (cb: (v: T) => void) => (() => void) | Promise<() => void>
|
|
18
|
+
): AsyncIterable<T> {
|
|
19
|
+
return {
|
|
20
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
21
|
+
let buffered: { v: T } | undefined;
|
|
22
|
+
let resolveNext: ((r: IteratorResult<T>) => void) | undefined;
|
|
23
|
+
let done = false;
|
|
24
|
+
|
|
25
|
+
const unsubMaybe = subscribe((v) => {
|
|
26
|
+
if (done) return;
|
|
27
|
+
if (resolveNext) {
|
|
28
|
+
const r = resolveNext;
|
|
29
|
+
resolveNext = undefined;
|
|
30
|
+
r({ value: v, done: false });
|
|
31
|
+
} else {
|
|
32
|
+
buffered = { v };
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (done) return;
|
|
38
|
+
done = true;
|
|
39
|
+
buffered = undefined;
|
|
40
|
+
// Unsubscribe may still be in flight (async registration); chain it.
|
|
41
|
+
Promise.resolve(unsubMaybe)
|
|
42
|
+
.then((unsub) => unsub())
|
|
43
|
+
.catch(() => {
|
|
44
|
+
// Registration failed — there is nothing to unsubscribe.
|
|
45
|
+
});
|
|
46
|
+
if (resolveNext) {
|
|
47
|
+
const r = resolveNext;
|
|
48
|
+
resolveNext = undefined;
|
|
49
|
+
r({ value: undefined as never, done: true });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
next(): Promise<IteratorResult<T>> {
|
|
55
|
+
if (done) return Promise.resolve({ value: undefined as never, done: true });
|
|
56
|
+
if (buffered) {
|
|
57
|
+
const v = buffered.v;
|
|
58
|
+
buffered = undefined;
|
|
59
|
+
return Promise.resolve({ value: v, done: false });
|
|
60
|
+
}
|
|
61
|
+
return new Promise<IteratorResult<T>>((r) => (resolveNext = r));
|
|
62
|
+
},
|
|
63
|
+
return(): Promise<IteratorResult<T>> {
|
|
64
|
+
finish();
|
|
65
|
+
return Promise.resolve({ value: undefined as never, done: true });
|
|
66
|
+
},
|
|
67
|
+
throw(e: unknown): Promise<IteratorResult<T>> {
|
|
68
|
+
finish();
|
|
69
|
+
return Promise.reject(e);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext, type Accessor } from 'solid-js';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { SyncedDb } from '../index';
|
|
4
|
+
import { fromSubscription } from './from-subscription';
|
|
5
|
+
|
|
6
|
+
// Solid 2: the context object doubles as its provider component —
|
|
7
|
+
// <Sp00kyContext value={db}>{children}</Sp00kyContext>.
|
|
8
|
+
export const Sp00kyContext = createContext<SyncedDb<any>>();
|
|
9
|
+
|
|
10
|
+
export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
|
|
11
|
+
try {
|
|
12
|
+
return useContext(Sp00kyContext) as SyncedDb<S>;
|
|
13
|
+
} catch {
|
|
14
|
+
// Solid 2 throws ContextNotFoundError; rethrow with actionable guidance.
|
|
15
|
+
throw new Error(
|
|
16
|
+
'useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.'
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count of locally-committed mutations not yet acknowledged by the server.
|
|
23
|
+
* Drive an "unsaved changes" indicator off this.
|
|
24
|
+
*/
|
|
25
|
+
export function usePendingMutations(): Accessor<number> {
|
|
26
|
+
const db = useDb();
|
|
27
|
+
return fromSubscription((cb) => db.subscribeToPendingMutations(cb), db.pendingMutationCount);
|
|
28
|
+
}
|