@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.
Files changed (44) hide show
  1. package/AGENTS.md +81 -0
  2. package/QUICK_START.md +129 -0
  3. package/README.md +19 -0
  4. package/dist/index.cjs +1290 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +670 -0
  7. package/dist/index.d.cts.map +1 -0
  8. package/dist/index.d.ts +670 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +1266 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +63 -0
  13. package/skills/sp00ky-solid2/SKILL.md +71 -0
  14. package/src/index.ts +410 -0
  15. package/src/lib/Blurhash.ts +64 -0
  16. package/src/lib/BucketImage.ts +170 -0
  17. package/src/lib/Sp00kyProvider.ts +104 -0
  18. package/src/lib/__tests__/conflate.test.ts +120 -0
  19. package/src/lib/__tests__/create-query.test.ts +397 -0
  20. package/src/lib/__tests__/rc-semantics.test.ts +393 -0
  21. package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
  22. package/src/lib/conflate.ts +74 -0
  23. package/src/lib/context.ts +28 -0
  24. package/src/lib/create-preload.ts +116 -0
  25. package/src/lib/create-query.ts +388 -0
  26. package/src/lib/create-submission.ts +57 -0
  27. package/src/lib/from-subscription.ts +32 -0
  28. package/src/lib/merge-rows.test.ts +92 -0
  29. package/src/lib/merge-rows.ts +72 -0
  30. package/src/lib/models.ts +8 -0
  31. package/src/lib/use-app-release.ts +89 -0
  32. package/src/lib/use-blurhash.ts +82 -0
  33. package/src/lib/use-bucket-image.ts +108 -0
  34. package/src/lib/use-crdt-field.ts +57 -0
  35. package/src/lib/use-download-file.ts +181 -0
  36. package/src/lib/use-feature-flag.ts +43 -0
  37. package/src/lib/use-file-upload.ts +146 -0
  38. package/src/lib/use-storage-status.ts +44 -0
  39. package/src/lib/use-sync-activity.ts +99 -0
  40. package/src/lib/use-sync-status.ts +63 -0
  41. package/src/types/index.ts +83 -0
  42. package/tsconfig.json +27 -0
  43. package/tsdown.config.ts +20 -0
  44. 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
+ }