@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,104 @@
1
+ import type { Element } from 'solid-js';
2
+ import {
3
+ createSignal,
4
+ onSettled,
5
+ onCleanup,
6
+ createComponent,
7
+ createMemo,
8
+ merge,
9
+ } from 'solid-js';
10
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
11
+ import type { SyncedDbConfig } from '../types';
12
+ import { SyncedDb } from '../index';
13
+ import { Sp00kyContext } from './context';
14
+
15
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
16
+ config: SyncedDbConfig<S>;
17
+ fallback?: Element;
18
+ onError?: (error: Error) => void;
19
+ onReady?: (db: SyncedDb<S>) => void;
20
+ /**
21
+ * Prewarm data into the local cache before revealing the UI. Runs after
22
+ * `init()`; the `fallback` stays visible until it resolves. Use awaitable
23
+ * `db.preload(...)` calls here to gate first-load on essential data (e.g.
24
+ * config). On warm loads preload returns instantly, so there's no perceptible
25
+ * gate after the first run. Best-effort: a rejection is caught and the UI is
26
+ * revealed anyway.
27
+ */
28
+ preload?: (db: SyncedDb<S>) => Promise<void>;
29
+ children: Element;
30
+ }
31
+
32
+ export function Sp00kyProvider<S extends SchemaStructure>(
33
+ props: Sp00kyProviderProps<S>
34
+ ): Element {
35
+ const merged = merge({ fallback: undefined as Element | undefined }, props);
36
+
37
+ // Written from the async init continuation — outside any tracking scope.
38
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined, { ownedWrite: true });
39
+
40
+ // Init is async, so a dispose can land mid-init. Only that narrow race is
41
+ // handled here: an instance whose init finished AFTER the provider was
42
+ // already gone is closed, because nothing will ever reference it.
43
+ //
44
+ // A live, mounted client is deliberately NOT closed on cleanup. Doing that
45
+ // nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
46
+ // "SyncedDb not initialized" while reads keep rendering from state that is
47
+ // already subscribed — i.e. mutations die silently and the app looks fine. In
48
+ // a host app the provider wraps the whole tree and only unmounts with the
49
+ // page, where the browser reclaims the worker anyway, so the leak this was
50
+ // meant to fix is worth far less than that risk.
51
+ let disposed = false;
52
+
53
+ onCleanup(() => {
54
+ disposed = true;
55
+ });
56
+
57
+ // `onSettled` replaces Solid 1's `onMount`.
58
+ onSettled(() => {
59
+ void (async () => {
60
+ try {
61
+ const instance = new SyncedDb<S>(merged.config);
62
+ await instance.init();
63
+ if (disposed) {
64
+ await instance.close();
65
+ return;
66
+ }
67
+ // Gate first-load UI on prewarmed data. Best-effort: never let a
68
+ // preload failure keep the app stuck on the fallback.
69
+ if (merged.preload) {
70
+ try {
71
+ await merged.preload(instance);
72
+ } catch (e) {
73
+ // oxlint-disable-next-line no-console
74
+ console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
75
+ }
76
+ }
77
+ setDb(() => instance);
78
+ merged.onReady?.(instance);
79
+ } catch (e) {
80
+ const error = e instanceof Error ? e : new Error(String(e));
81
+ if (merged.onError) {
82
+ merged.onError(error);
83
+ } else {
84
+ // oxlint-disable-next-line no-console
85
+ console.error('Sp00kyProvider: Failed to initialize database', error);
86
+ }
87
+ }
88
+ })();
89
+ });
90
+
91
+ const content = createMemo(() => {
92
+ const instance = db();
93
+ if (!instance) return merged.fallback;
94
+ // Solid 2: the context object IS the provider component.
95
+ return createComponent(Sp00kyContext, {
96
+ value: instance,
97
+ get children() {
98
+ return merged.children;
99
+ },
100
+ });
101
+ });
102
+
103
+ return content as unknown as Element;
104
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createEffect, createRoot, flush } from 'solid-js';
3
+ import { conflate } from '../conflate';
4
+ import { fromSubscription } from '../from-subscription';
5
+
6
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
7
+
8
+ describe('conflate', () => {
9
+ it('delivers values pushed after a pull is parked', async () => {
10
+ let cb: ((v: number) => void) | undefined;
11
+ const it = conflate<number>((c) => {
12
+ cb = c;
13
+ return () => (cb = undefined);
14
+ })[Symbol.asyncIterator]();
15
+
16
+ const p = it.next();
17
+ cb!(1);
18
+ expect(await p).toEqual({ value: 1, done: false });
19
+ });
20
+
21
+ it('conflates: only the newest unconsumed value survives', async () => {
22
+ let cb: ((v: number) => void) | undefined;
23
+ const it = conflate<number>((c) => {
24
+ cb = c;
25
+ return () => (cb = undefined);
26
+ })[Symbol.asyncIterator]();
27
+
28
+ cb!(1);
29
+ cb!(2);
30
+ cb!(3);
31
+ expect(await it.next()).toEqual({ value: 3, done: false });
32
+ });
33
+
34
+ it('return() unsubscribes and resolves a parked pull as done', async () => {
35
+ let unsubscribed = false;
36
+ let cb: ((v: number) => void) | undefined;
37
+ const it = conflate<number>((c) => {
38
+ cb = c;
39
+ return () => {
40
+ unsubscribed = true;
41
+ cb = undefined;
42
+ };
43
+ })[Symbol.asyncIterator]();
44
+
45
+ const parked = it.next();
46
+ await it.return!();
47
+ await tick();
48
+ expect(unsubscribed).toBe(true);
49
+ expect(await parked).toEqual({ value: undefined, done: true });
50
+ expect(await it.next()).toEqual({ value: undefined, done: true });
51
+ });
52
+
53
+ it('supports async subscribe (unsubscribe still runs after return())', async () => {
54
+ let unsubscribed = false;
55
+ const it = conflate<number>(async () => {
56
+ await tick();
57
+ return () => {
58
+ unsubscribed = true;
59
+ };
60
+ })[Symbol.asyncIterator]();
61
+
62
+ await it.return!();
63
+ await tick();
64
+ await tick();
65
+ expect(unsubscribed).toBe(true);
66
+ });
67
+
68
+ it('values pushed after return() are dropped', async () => {
69
+ let cb: ((v: number) => void) | undefined;
70
+ const it = conflate<number>((c) => {
71
+ cb = c;
72
+ return () => {};
73
+ })[Symbol.asyncIterator]();
74
+ await it.return!();
75
+ cb!(42);
76
+ expect(await it.next()).toEqual({ value: undefined, done: true });
77
+ });
78
+ });
79
+
80
+ describe('fromSubscription', () => {
81
+ it('serves initial synchronously, then live values; unsubscribes on dispose', async () => {
82
+ let cb: ((v: number) => void) | undefined;
83
+ let unsubscribed = false;
84
+ const subscribe = (c: (v: number) => void) => {
85
+ cb = c;
86
+ // spooky-style: fire immediately with current value
87
+ c(10);
88
+ return () => {
89
+ unsubscribed = true;
90
+ cb = undefined;
91
+ };
92
+ };
93
+
94
+ await createRoot(async (dispose) => {
95
+ const v = fromSubscription(subscribe, -1);
96
+ const seen: number[] = [];
97
+ createEffect(
98
+ () => v(),
99
+ (x) => {
100
+ seen.push(x);
101
+ }
102
+ );
103
+ flush();
104
+ expect(v()).toBe(-1); // loadingValue readable synchronously
105
+ await tick();
106
+ flush();
107
+ expect(v()).toBe(10); // immediate emission landed
108
+
109
+ cb!(20);
110
+ await tick();
111
+ flush();
112
+ expect(v()).toBe(20);
113
+ expect(seen).toContain(20);
114
+
115
+ dispose();
116
+ await tick();
117
+ expect(unsubscribed).toBe(true);
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,397 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createEffect, createRoot, createSignal, flush } from 'solid-js';
3
+ import { createQuery } from '../create-query';
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
+ type Emission = Record<string, any>[];
14
+
15
+ function mockEngine() {
16
+ const subs = new Map<string, (e: Emission) => void>();
17
+ const statusSubs = new Map<string, (s: string) => void>();
18
+ const authoritySubs = new Map<string, (known: boolean) => void>();
19
+ const unsubscribed: string[] = [];
20
+ const deregistered: string[] = [];
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
+ ),
29
+ subscribe: vi.fn(async (hash: string, cb: (e: Emission) => void, _o?: unknown) => {
30
+ subs.set(hash, cb);
31
+ return () => {
32
+ subs.delete(hash);
33
+ unsubscribed.push(hash);
34
+ };
35
+ }),
36
+ subscribeQueryStatus: vi.fn(
37
+ (hash: string, cb: (s: string) => void, o?: { immediate?: boolean }) => {
38
+ statusSubs.set(hash, cb);
39
+ if (o?.immediate) cb('idle');
40
+ return () => statusSubs.delete(hash);
41
+ }
42
+ ),
43
+ deregisterQuery: vi.fn((hash: string) => deregistered.push(hash)),
44
+ reportFrontendTiming: vi.fn(),
45
+ };
46
+ const db = Object.create(SyncedDb.prototype) as SyncedDb<any>;
47
+ (db as any).getSp00ky = () => sp00ky;
48
+ return {
49
+ db,
50
+ sp00ky,
51
+ emit: (hash: string, e: Emission) => subs.get(hash)?.(e),
52
+ setStatus: (hash: string, s: string) => statusSubs.get(hash)?.(s),
53
+ setAuthority: (hash: string, known: boolean) => authoritySubs.get(hash)?.(known),
54
+ unsubscribed,
55
+ deregistered,
56
+ hasSub: (hash: string) => subs.has(hash),
57
+ };
58
+ }
59
+
60
+ function mockQuery(hash: string, isOne = false) {
61
+ return {
62
+ hash,
63
+ isOne,
64
+ run: async () => ({ hash }),
65
+ } as any;
66
+ }
67
+
68
+ describe('createQuery', () => {
69
+ it('serves empty data immediately, then live emissions with row identity kept', async () => {
70
+ const eng = mockEngine();
71
+ await createRoot(async (dispose) => {
72
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
73
+ createEffect(
74
+ () => q.data(),
75
+ () => {}
76
+ );
77
+ flush();
78
+
79
+ expect(q.data()).toEqual([]); // committed empty, no suspension
80
+ expect(q.isLoading()).toBe(true);
81
+
82
+ await settle();
83
+ eng.emit('h1', [
84
+ { id: 'a', n: 1 },
85
+ { id: 'b', n: 2 },
86
+ ]);
87
+ await settle();
88
+
89
+ expect(q.data().map((r: any) => r.id)).toEqual(['a', 'b']);
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();
95
+ expect(q.isSettled()).toBe(true);
96
+
97
+ const rowA = q.data()[0];
98
+ eng.emit('h1', [
99
+ { id: 'a', n: 99 },
100
+ { id: 'b', n: 2 },
101
+ ]);
102
+ await settle();
103
+ expect(q.data()[0]).toBe(rowA); // identity preserved via keyed reconcile
104
+ expect(q.data()[0].n).toBe(99);
105
+
106
+ dispose();
107
+ await settle();
108
+ expect(eng.unsubscribed).toContain('h1');
109
+ });
110
+ });
111
+
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.
161
+ const eng = mockEngine();
162
+ await createRoot(async (dispose) => {
163
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
164
+ createEffect(
165
+ () => q.data(),
166
+ () => {}
167
+ );
168
+ flush();
169
+ await settle();
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
+
197
+ eng.emit('h1', []);
198
+ eng.setAuthority('h1', true);
199
+ await settle();
200
+ expect(q.isLoading()).toBe(false);
201
+ expect(q.isEmpty()).toBe(true);
202
+ expect(q.data()).toBeNull();
203
+ dispose();
204
+ });
205
+ });
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.
224
+ eng.emit('h1', []);
225
+ eng.setAuthority('h1', false);
226
+ await settle();
227
+ expect(q.isLoading()).toBe(true);
228
+ expect(q.isEmpty()).toBe(false);
229
+ dispose();
230
+ });
231
+ });
232
+
233
+ it('one() queries yield the row or null', async () => {
234
+ const eng = mockEngine();
235
+ await createRoot(async (dispose) => {
236
+ const q = createQuery<any, any, any, any, true, any>(eng.db, mockQuery('h1', true));
237
+ createEffect(
238
+ () => q.data(),
239
+ () => {}
240
+ );
241
+ flush();
242
+ await settle();
243
+
244
+ expect(q.data()).toBe(null);
245
+ eng.emit('h1', [{ id: 'a', n: 1 }]);
246
+ await settle();
247
+ expect(q.data()?.n).toBe(1);
248
+ expect(q.isLoading()).toBe(false);
249
+ dispose();
250
+ });
251
+ });
252
+
253
+ it('isFetching mirrors query status; isSettled composes both', async () => {
254
+ const eng = mockEngine();
255
+ await createRoot(async (dispose) => {
256
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
257
+ createEffect(
258
+ () => q.data(),
259
+ () => {}
260
+ );
261
+ flush();
262
+ await settle();
263
+
264
+ eng.setStatus('h1', 'fetching');
265
+ flush();
266
+ expect(q.isFetching()).toBe(true);
267
+
268
+ eng.emit('h1', [{ id: 'a' }]);
269
+ eng.setAuthority('h1', true);
270
+ await settle();
271
+ expect(q.isSettled()).toBe(false); // authoritative but still fetching
272
+
273
+ eng.setStatus('h1', 'idle');
274
+ flush();
275
+ expect(q.isSettled()).toBe(true);
276
+ dispose();
277
+ });
278
+ });
279
+
280
+ it('registration failure surfaces via error(), resolving isLoading', async () => {
281
+ const eng = mockEngine();
282
+ const failing = {
283
+ hash: 'hx',
284
+ isOne: false,
285
+ run: async () => {
286
+ throw new Error('SSP NOT_READY');
287
+ },
288
+ } as any;
289
+ await createRoot(async (dispose) => {
290
+ const q = createQuery<any, any, any, any, false, any>(eng.db, failing);
291
+ createEffect(
292
+ () => q.data(),
293
+ () => {}
294
+ );
295
+ flush();
296
+ await settle();
297
+
298
+ expect(q.error()?.message).toBe('SSP NOT_READY');
299
+ expect(q.isLoading()).toBe(false); // spinner must resolve
300
+ expect(q.data()).toEqual([]);
301
+ dispose();
302
+ });
303
+ });
304
+
305
+ it('reactive query thunk: identity change re-subscribes and clears state', async () => {
306
+ const eng = mockEngine();
307
+ await createRoot(async (dispose) => {
308
+ const [id, setId] = createSignal('h1');
309
+ const q = createQuery<any, any, any, any, false, any>(eng.db, () => mockQuery(id()));
310
+ createEffect(
311
+ () => q.data(),
312
+ () => {}
313
+ );
314
+ flush();
315
+ await settle();
316
+
317
+ eng.emit('h1', [{ id: 'a' }]);
318
+ await settle();
319
+ expect(q.data().map((r: any) => r.id)).toEqual(['a']);
320
+
321
+ setId('h2');
322
+ flush();
323
+ await settle();
324
+ await settle();
325
+
326
+ expect(eng.unsubscribed).toContain('h1'); // superseded subscription torn down
327
+ expect(eng.hasSub('h2')).toBe(true);
328
+ expect(q.isLoading()).toBe(true); // reset for the new identity
329
+
330
+ eng.emit('h2', [{ id: 'z' }]);
331
+ await settle();
332
+ expect(q.data().map((r: any) => r.id)).toEqual(['z']);
333
+ dispose();
334
+ });
335
+ });
336
+
337
+ it('enabled=false runs no query; flipping true starts it', async () => {
338
+ const eng = mockEngine();
339
+ await createRoot(async (dispose) => {
340
+ const [enabled, setEnabled] = createSignal(false);
341
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'), {
342
+ enabled,
343
+ });
344
+ createEffect(
345
+ () => q.data(),
346
+ () => {}
347
+ );
348
+ flush();
349
+ await settle();
350
+ expect(eng.sp00ky.subscribe).not.toHaveBeenCalled();
351
+
352
+ setEnabled(true);
353
+ flush();
354
+ await settle();
355
+ expect(eng.hasSub('h1')).toBe(true);
356
+ dispose();
357
+ });
358
+ });
359
+
360
+ it('deregisterOnCleanup deregisters the active hash on dispose', async () => {
361
+ const eng = mockEngine();
362
+ await createRoot(async (dispose) => {
363
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'), {
364
+ deregisterOnCleanup: true,
365
+ });
366
+ createEffect(
367
+ () => q.data(),
368
+ () => {}
369
+ );
370
+ flush();
371
+ await settle();
372
+ expect(eng.hasSub('h1')).toBe(true);
373
+
374
+ dispose();
375
+ await settle();
376
+ expect(eng.unsubscribed).toContain('h1');
377
+ expect(eng.deregistered).toEqual(['h1']);
378
+ });
379
+ });
380
+
381
+ it('reports frontend timing per emission', async () => {
382
+ const eng = mockEngine();
383
+ await createRoot(async (dispose) => {
384
+ const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
385
+ createEffect(
386
+ () => q.data(),
387
+ () => {}
388
+ );
389
+ flush();
390
+ await settle();
391
+ eng.emit('h1', [{ id: 'a' }]);
392
+ await settle();
393
+ expect(eng.sp00ky.reportFrontendTiming).toHaveBeenCalledWith('h1', expect.any(Number));
394
+ dispose();
395
+ });
396
+ });
397
+ });