@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/AGENTS.md +2 -2
- package/QUICK_START.md +2 -2
- package/dist/index.cjs +6 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +16 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-solid2/SKILL.md +2 -2
- package/src/lib/__tests__/create-query.rebind.test.ts +126 -0
- package/src/lib/create-query.ts +39 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/client-solid2",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
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.
|
|
47
|
-
"@spooky-sync/core": "0.0.1-canary.
|
|
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()`
|
|
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()
|
|
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
|
+
});
|
package/src/lib/create-query.ts
CHANGED
|
@@ -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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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;
|