@spooky-sync/client-solid2 0.0.1-canary.333 → 0.0.1-canary.334

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/client-solid2",
3
- "version": "0.0.1-canary.333",
3
+ "version": "0.0.1-canary.334",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Solid 2 native bindings for the sp00ky sync client",
@@ -43,8 +43,8 @@
43
43
  },
44
44
  "packageManager": "pnpm@9.0.0",
45
45
  "dependencies": {
46
- "@spooky-sync/query-builder": "0.0.1-canary.333",
47
- "@spooky-sync/core": "0.0.1-canary.333",
46
+ "@spooky-sync/query-builder": "0.0.1-canary.334",
47
+ "@spooky-sync/core": "0.0.1-canary.334",
48
48
  "@surrealdb/wasm": "^3.1.0",
49
49
  "surrealdb": "2.1.0"
50
50
  },
@@ -42,10 +42,10 @@ Returns `{ data, ready, error, isLoading, isFetching, isAuthoritative, hasData,
42
42
  - `data()` — non-suspending; `[]`/`null` before first result, keyed-reconciled live rows after.
43
43
  - `ready()` — suspending read for `<Loading fallback={...}>` (Solid 2's renamed Suspense).
44
44
  - `error()` — registration failure (SSP 503 NOT_READY etc.); never thrown into the tree, sync retries underneath.
45
- - `isAuthoritative()` — server membership known for this identity (latched until it changes).
45
+ - `isAuthoritative()`: `data()` holds the rendered rows of a known membership (server answer, or a previous session's on this device); not before they are painted, e.g. right after sign-in. Latched until the identity changes.
46
46
  - `hasData()` — `data()` holds rows / the `.one()` row.
47
47
  - `isEmpty()` — authoritative AND no data: the server said "no rows". Empty states go on this, never on `!hasData()`.
48
- - `isSettled()` — authoritative AND not fetching; gate windowed-list end detection on this. Latch it for a sticky verdict.
48
+ - `isSettled()`: authoritative AND not fetching; gate windowed-list end detection and create-if-missing on this (re-confirming a cached answer counts as fetching). Latch it for a sticky verdict.
49
49
  - Options: `enabled?: () => boolean`, `deregisterOnCleanup?: boolean`.
50
50
  - `useQuery` is a deprecated alias of `createQuery`.
51
51
 
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createEffect, createRoot, flush } from 'solid-js';
3
+ import { createQuery } from '../create-query';
4
+ import { SyncedDb } from '../../index';
5
+ // The real engine underneath, not a mock: the window this guards is the order
6
+ // in which the core announces authority and rows, which a mock would script.
7
+ import { Runtime } from '../../../../core/src/client/runtime';
8
+ import { rebindQueries } from '../../../../core/src/boot/bucket-switch.saga';
9
+ import { defaultEnv } from '../../../../core/src/query/env';
10
+ import { fakeAdapters } from '../../../../core/src/testing/adapters';
11
+ import { buildEntry, buildState } from '../../../../core/src/testing/build';
12
+
13
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
14
+ const settle = async () => {
15
+ await tick();
16
+ flush();
17
+ await tick();
18
+ };
19
+
20
+ const noop = () => {};
21
+ const logger: any = {
22
+ debug: noop,
23
+ info: noop,
24
+ warn: noop,
25
+ trace: noop,
26
+ error: noop,
27
+ child: () => logger,
28
+ };
29
+
30
+ /** A core Runtime whose `_00_view` holds one row for query `q`, behind a SyncedDb. */
31
+ function engine() {
32
+ const a = fakeAdapters({
33
+ local: {
34
+ getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
35
+ select: async (plan: any) =>
36
+ (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}`, name: 'mine' })),
37
+ },
38
+ });
39
+ const rt = new Runtime({
40
+ env: defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 }),
41
+ adapters: a.adapters,
42
+ logger,
43
+ tabId: 'tab-a',
44
+ // Registered while still in the anon bucket: cold, nothing painted.
45
+ initialState: {
46
+ ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]),
47
+ sessionId: 's',
48
+ },
49
+ });
50
+ const sp00ky = {
51
+ subscribe: async (
52
+ hash: string,
53
+ cb: (rows: Record<string, any>[]) => void,
54
+ o?: { immediate?: boolean }
55
+ ) => rt.subscribe(hash, cb, o),
56
+ subscribeQueryAuthority: (
57
+ hash: string,
58
+ cb: (known: boolean) => void,
59
+ o?: { immediate?: boolean }
60
+ ) => rt.subscribeAuthority(hash, cb, o),
61
+ subscribeQueryStatus: (hash: string, cb: (s: any) => void, o?: { immediate?: boolean }) =>
62
+ rt.subscribeStatus(hash, cb, o),
63
+ deregisterQuery: noop,
64
+ reportFrontendTiming: noop,
65
+ };
66
+ const db = Object.create(SyncedDb.prototype) as SyncedDb<any>;
67
+ (db as any).getSp00ky = () => sp00ky;
68
+ return { a, rt, db };
69
+ }
70
+
71
+ describe('createQuery across a bucket switch (sign-in)', () => {
72
+ it('never reads settled, authoritative or empty while the re-seeded rows are unpainted', async () => {
73
+ const { a, rt, db } = engine();
74
+ await createRoot(async (dispose) => {
75
+ const q = createQuery<any, any, any, any, false, any>(db, {
76
+ hash: 'q',
77
+ isOne: false,
78
+ run: async () => ({ hash: 'q' }),
79
+ } as any);
80
+ // Every state an effect could act on, e.g. "settled and no row: create one".
81
+ const seen: string[] = [];
82
+ createEffect(
83
+ () => ({
84
+ settled: q.isSettled(),
85
+ known: q.isAuthoritative(),
86
+ empty: q.isEmpty(),
87
+ rows: q.data().length,
88
+ }),
89
+ (v) =>
90
+ void seen.push(
91
+ `${v.settled ? 'settled' : '-'} ${v.known ? 'known' : '-'} ${v.empty ? 'empty' : '-'} ${v.rows}`
92
+ )
93
+ );
94
+ flush();
95
+ await settle();
96
+ expect(q.isLoading()).toBe(true);
97
+
98
+ // The AuthFlip's switch: re-seeded as `cached` from the user's durable
99
+ // view, `records` not painted, no fetch cycle in flight.
100
+ await rt.run(rebindQueries());
101
+ await settle();
102
+ expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({
103
+ phase: 'cached',
104
+ fetchDepth: 0,
105
+ });
106
+ expect(q.isFetching()).toBe(false);
107
+ expect(q.data()).toEqual([]);
108
+ expect(q.isAuthoritative()).toBe(false);
109
+ expect(q.isSettled()).toBe(false);
110
+ expect(q.isEmpty()).toBe(false);
111
+ expect(q.isLoading()).toBe(true);
112
+
113
+ // The debounced render paints the durable rows; authority follows them.
114
+ a.timers.fire('mat:q');
115
+ await settle();
116
+ expect(q.data().map((r: any) => r.id)).toEqual(['thing:1']);
117
+ expect(q.isAuthoritative()).toBe(true);
118
+ expect(q.isSettled()).toBe(true);
119
+ expect(q.isEmpty()).toBe(false);
120
+
121
+ expect(seen.filter((s) => s.startsWith('settled') && s.endsWith(' 0'))).toEqual([]);
122
+ expect(seen.filter((s) => s.includes('empty'))).toEqual([]);
123
+ dispose();
124
+ });
125
+ });
126
+ });
@@ -70,24 +70,31 @@ export type CreateQueryResult<TData> = {
70
70
  /** True while a fetch cycle is in flight (registration, a sync round). */
71
71
  isFetching: Accessor<boolean>;
72
72
  /**
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).
73
+ * True once `data()` holds the rendered rows of a known membership for
74
+ * this identity: the server's answer (once its rows are local), or the
75
+ * membership an earlier session left on this device. Knowing the set is
76
+ * not enough: right after a sign-in re-seed, on boot, or while a server
77
+ * answer's rows still download, the set is known but `data()` is not
78
+ * painted yet, and this stays false. From then on `data()` is an answer, not
79
+ * a cache guess. Latched until the identity changes (or a bucket switch
80
+ * resets it).
77
81
  */
78
82
  isAuthoritative: Accessor<boolean>;
79
83
  /** Whether `data()` currently holds anything: rows for a list, a row for `one()`. */
80
84
  hasData: Accessor<boolean>;
81
85
  /**
82
- * The server answered and the answer is "no rows". The signal to render an
86
+ * `isAuthoritative()` and the answer is "no rows". The signal to render an
83
87
  * empty state on; a bare `!hasData()` cannot tell empty from not-yet-loaded.
84
88
  */
85
89
  isEmpty: Accessor<boolean>;
86
90
  /**
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.
91
+ * True once `isAuthoritative()` AND no fetch cycle is in flight. While
92
+ * settled, `data()` is the answer: a windowed query returning fewer rows
93
+ * than its LIMIT really is the end of the list, and a missing row is
94
+ * missing. The registration that re-confirms a cached answer after a reload
95
+ * or sign-in is a fetch cycle, so while it runs this waits for the server;
96
+ * with nothing in flight (offline) a cached answer settles. Not monotonic: a
97
+ * later sync round re-enters `isFetching`; latch it if a verdict must stick.
91
98
  */
92
99
  isSettled: Accessor<boolean>;
93
100
  };
@@ -252,25 +259,6 @@ export function createQuery<
252
259
  })
253
260
  );
254
261
 
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
-
274
262
  addCleanup(
275
263
  sp00ky.subscribe(
276
264
  hash,
@@ -303,6 +291,29 @@ export function createQuery<
303
291
  { immediate: true }
304
292
  )
305
293
  );
294
+
295
+ // Mirror whether the rows of a known membership are painted. This
296
+ // is the channel that ends a cold load for a query whose honest
297
+ // answer is zero rows: the core emits no second `[]` for it, but it
298
+ // does flip authority once the render of the known set ran, always
299
+ // after the rows that render emitted, so `data()` is current when
300
+ // this reads true (subscribed after the rows so the immediate
301
+ // deliveries keep that order too). A flip back to false is a bucket
302
+ // switch (a different principal): what is on screen is the previous
303
+ // one's until the new store's render replaces it, so the paint
304
+ // latch goes with it.
305
+ addCleanup(
306
+ sp00ky.subscribeQueryAuthority(
307
+ hash,
308
+ (known) => {
309
+ if (disposed || myRun !== runId) return;
310
+ setIsAuthoritative(known);
311
+ if (known) wakeReady();
312
+ else setHasPainted(false);
313
+ },
314
+ { immediate: true }
315
+ )
316
+ );
306
317
  })
307
318
  .catch((err: unknown) => {
308
319
  if (disposed || myRun !== runId) return;