@spooky-sync/client-solid 0.0.1-canary.9 → 0.0.1-canary.91

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.
@@ -1,4 +1,4 @@
1
- import {
1
+ import type {
2
2
  ColumnSchema,
3
3
  FinalQuery,
4
4
  SchemaStructure,
@@ -6,9 +6,10 @@ import {
6
6
  QueryResult,
7
7
  } from '@spooky-sync/query-builder';
8
8
  import { createEffect, createSignal, onCleanup, useContext } from 'solid-js';
9
+ import { createStore, reconcile } from 'solid-js/store';
9
10
  import { SyncedDb } from '..';
10
- import { SpookyQueryResultPromise } from '@spooky-sync/core';
11
- import { SpookyContext } from './context';
11
+ import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
12
+ import { Sp00kyContext } from './context';
12
13
 
13
14
  type QueryArg<
14
15
  S extends SchemaStructure,
@@ -17,13 +18,23 @@ type QueryArg<
17
18
  RelatedFields extends Record<string, any>,
18
19
  IsOne extends boolean,
19
20
  > =
20
- | FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
21
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
21
22
  | (() =>
22
- | FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
23
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
23
24
  | null
24
25
  | undefined);
25
26
 
26
- type QueryOptions = { enabled?: () => boolean };
27
+ type QueryOptions = {
28
+ enabled?: () => boolean;
29
+ /**
30
+ * Tear down the query (remote `_00_query` view + local WASM view) when this
31
+ * hook is disposed and no other subscriber remains, instead of keeping it
32
+ * resident for cheap re-subscription. Use for viewport-windowed lists that
33
+ * mount/unmount a query per scroll window and want off-screen windows
34
+ * cancelled. Trade-off: scrolling back to a torn-down window re-registers it.
35
+ */
36
+ deregisterOnCleanup?: boolean;
37
+ };
27
38
 
28
39
  // Overload: context-based (no explicit db)
29
40
  export function useQuery<
@@ -36,7 +47,12 @@ export function useQuery<
36
47
  >(
37
48
  finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
38
49
  options?: QueryOptions,
39
- ): { data: () => TData | undefined; error: () => Error | undefined; isLoading: () => boolean };
50
+ ): {
51
+ data: () => TData | undefined;
52
+ error: () => Error | undefined;
53
+ isLoading: () => boolean;
54
+ isFetching: () => boolean;
55
+ };
40
56
 
41
57
  // Overload: explicit db (backward-compatible)
42
58
  export function useQuery<
@@ -50,7 +66,12 @@ export function useQuery<
50
66
  db: SyncedDb<S>,
51
67
  finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
52
68
  options?: QueryOptions,
53
- ): { data: () => TData | undefined; error: () => Error | undefined; isLoading: () => boolean };
69
+ ): {
70
+ data: () => TData | undefined;
71
+ error: () => Error | undefined;
72
+ isLoading: () => boolean;
73
+ isFetching: () => boolean;
74
+ };
54
75
 
55
76
  // Implementation
56
77
  export function useQuery<
@@ -82,11 +103,11 @@ export function useQuery<
82
103
  options = maybeOptions;
83
104
  } else {
84
105
  // Context-based overload: useQuery(query, options?)
85
- const contextDb = useContext(SpookyContext);
106
+ const contextDb = useContext(Sp00kyContext);
86
107
  if (!contextDb) {
87
108
  throw new Error(
88
- 'useQuery: No db argument provided and no SpookyContext found. ' +
89
- 'Either pass a SyncedDb instance or wrap your app in <SpookyProvider>.'
109
+ 'useQuery: No db argument provided and no Sp00kyContext found. ' +
110
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.'
90
111
  );
91
112
  }
92
113
  db = contextDb as SyncedDb<S>;
@@ -94,29 +115,76 @@ export function useQuery<
94
115
  options = queryOrOptions as QueryOptions | undefined;
95
116
  }
96
117
 
97
- const [data, setData] = createSignal<TData | undefined>(undefined);
98
118
  const [error, setError] = createSignal<Error | undefined>(undefined);
99
119
  const [isFetched, setIsFetched] = createSignal(false);
100
- const [unsubscribe, setUnsubscribe] = createSignal<(() => void) | undefined>(undefined);
120
+ const [isFetching, setIsFetching] = createSignal(false);
121
+ // Results live in a store (not a signal) so consecutive live-query emissions
122
+ // are merged with `reconcile`: unchanged rows keep their object identity and
123
+ // changed rows are mutated in place. That keeps Solid's reference-keyed `<For>`
124
+ // rows — and any `useQuery` subscriptions mounted inside them — alive across
125
+ // updates, instead of tearing every row down and re-registering its queries.
126
+ const [state, setState] = createStore<{ value: TData | undefined }>({ value: undefined });
127
+ // `reconcile` (below) merges each emission into `state.value` IN PLACE, keeping
128
+ // the array reference stable. That's ideal for granular per-row reactivity, but
129
+ // it means a *coarse* reader of `data()` — `<For each={data()}>`, or an effect
130
+ // that copies the whole array elsewhere (e.g. GameList's windowed store) — is
131
+ // NOT re-run when rows are added/removed/reordered within a same-length result
132
+ // (the classic case: deleting a row in a windowed list shifts the next one in,
133
+ // so length stays 50 and the array ref never changes). Bump a version on every
134
+ // emission and read it in `data()` so every consumer re-runs on any change while
135
+ // reconcile still preserves row identity underneath.
136
+ const [version, setVersion] = createSignal(0);
137
+ const data = () => {
138
+ version();
139
+ return state.value;
140
+ };
141
+
101
142
  let prevQueryString: string | undefined;
143
+ // Monotonic token for each subscription generation. Bumped whenever the query
144
+ // identity changes or the hook is disposed, so a slow async `initQuery`
145
+ // continuation can detect it was superseded and avoid installing a stale (and
146
+ // leaked) subscription.
147
+ let runId = 0;
148
+ let activeUnsub: (() => void) | undefined;
149
+ // The hash of the currently-installed subscription, for opt-in deregister on
150
+ // dispose (see `deregisterOnCleanup`).
151
+ let activeHash: string | undefined;
152
+
153
+ const teardownActive = () => {
154
+ activeUnsub?.();
155
+ activeUnsub = undefined;
156
+ };
102
157
 
103
- const spooky = db.getSpooky();
158
+ const sp00ky = db.getSp00ky();
104
159
 
105
160
  const initQuery = async (
106
- query: FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
161
+ query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
162
+ myRun: number
107
163
  ) => {
108
164
  const { hash } = await query.run();
165
+ // A newer query identity (or disposal) won the race while we awaited run().
166
+ if (myRun !== runId) return;
167
+ activeHash = hash;
109
168
  setError(undefined);
110
169
 
111
170
  let isFirstCall = true;
112
- const unsub = await spooky.subscribe(
171
+ const unsub = await sp00ky.subscribe(
113
172
  hash,
114
173
  (e) => {
115
- const data = (query.isOne ? e[0] : e) as TData;
116
- setData(() => data);
174
+ const queryData = (query.isOne ? e[0] : e) as TData;
175
+ // Merge into the store by record id: unchanged rows keep their identity,
176
+ // changed rows update in place. Replaces wholesale for `one()`/null.
177
+ // Time the reconcile → report as the "frontend" phase for DevTools/MCP.
178
+ const reconcileStart = performance.now();
179
+ setState('value', reconcile(queryData as any, { key: 'id' }));
180
+ // Notify coarse `data()` readers (see the `version` note above): reconcile
181
+ // keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
182
+ // on add/remove/reorder.
183
+ setVersion((v) => v + 1);
184
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
117
185
  // The first (immediate) callback with no data likely means the local DB
118
186
  // hasn't synced yet — don't mark as fetched so UI shows loading state
119
- const hasData = query.isOne ? data != null : (e as any[]).length > 0;
187
+ const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
120
188
  if (!isFirstCall || hasData) {
121
189
  setIsFetched(true);
122
190
  }
@@ -125,7 +193,25 @@ export function useQuery<
125
193
  { immediate: true }
126
194
  );
127
195
 
128
- setUnsubscribe(() => unsub);
196
+ // Mirror the query's fetch status so the UI can show a "loading more"
197
+ // state while the sync engine pulls missing records in the background.
198
+ const unsubStatus = sp00ky.subscribeQueryStatus(
199
+ hash,
200
+ (status) => setIsFetching(status === 'fetching'),
201
+ { immediate: true }
202
+ );
203
+
204
+ const teardown = () => {
205
+ unsub();
206
+ unsubStatus();
207
+ };
208
+
209
+ // Superseded while awaiting subscribe()? Don't leak — tear down immediately.
210
+ if (myRun !== runId) {
211
+ teardown();
212
+ return;
213
+ }
214
+ activeUnsub = teardown;
129
215
  };
130
216
 
131
217
  createEffect(() => {
@@ -143,21 +229,36 @@ export function useQuery<
143
229
  return;
144
230
  }
145
231
 
146
- // Prevent re-running if query hasn't changed
147
- const queryString = JSON.stringify(query);
232
+ // Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
233
+ // full `JSON.stringify` of the FinalQuery (which walks the whole schema +
234
+ // inner query on every reactive tick and isn't guaranteed stable). When the
235
+ // identity is unchanged we keep the existing subscription alive.
236
+ const queryString = String(query.hash);
148
237
  if (queryString === prevQueryString) {
149
238
  return;
150
239
  }
151
240
  prevQueryString = queryString;
152
241
 
153
- // Reset fetched state when query changes
242
+ // New query identity supersede the previous subscription and start fresh.
243
+ const myRun = ++runId;
244
+ teardownActive();
154
245
  setIsFetched(false);
155
- initQuery(query);
246
+ initQuery(query, myRun);
247
+ });
156
248
 
157
- // Cleanup
158
- onCleanup(() => {
159
- unsubscribe()?.();
160
- });
249
+ // Tear down the live subscription when the hook's owner is disposed. Registered
250
+ // on the hook (component) scope rather than inside the effect, so an effect
251
+ // re-run that early-returns (unchanged query) doesn't clean up the still-valid
252
+ // subscription. Bumping runId also invalidates any in-flight initQuery.
253
+ onCleanup(() => {
254
+ runId++;
255
+ teardownActive();
256
+ // Opt-in: cancel the query once this hook (its last subscriber) is gone.
257
+ // teardownActive() above already removed this hook's callback, so
258
+ // deregisterQuery's refcount guard sees the true remaining-subscriber count.
259
+ if (options?.deregisterOnCleanup && activeHash) {
260
+ sp00ky.deregisterQuery(activeHash);
261
+ }
161
262
  });
162
263
 
163
264
  const isLoading = () => {
@@ -168,5 +269,6 @@ export function useQuery<
168
269
  data,
169
270
  error,
170
271
  isLoading,
272
+ isFetching,
171
273
  };
172
274
  }
@@ -0,0 +1,39 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { SyncHealth, SyncHealthStatus } from '@spooky-sync/core';
4
+
5
+ export interface UseSyncStatus {
6
+ /** Full health snapshot; updates reactively on every transition. */
7
+ health: Accessor<SyncHealth>;
8
+ /** `'healthy'` | `'degraded'`. */
9
+ status: Accessor<SyncHealthStatus>;
10
+ isHealthy: Accessor<boolean>;
11
+ /** `true` once sync has failed for a sustained run — drive a banner off this. */
12
+ isDegraded: Accessor<boolean>;
13
+ }
14
+
15
+ /**
16
+ * Observe sync health for a "can't reach the server" banner / indicator.
17
+ *
18
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
19
+ * remote 500 on query registration, a dropped socket) are absorbed by the
20
+ * retry and never flip this; `isDegraded()` only goes true once failures
21
+ * persist for the configured number of consecutive rounds (sp00ky core config
22
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
23
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
24
+ */
25
+ export function useSyncStatus(): UseSyncStatus {
26
+ const db = useDb();
27
+ // subscribeToSyncHealth fires synchronously with the current status, so the
28
+ // signal is correct from first read; the initial value just avoids a flash.
29
+ const [health, setHealth] = createSignal<SyncHealth>(db.syncHealth);
30
+ const unsub = db.subscribeToSyncHealth(setHealth);
31
+ onCleanup(unsub);
32
+
33
+ return {
34
+ health,
35
+ status: () => health().status,
36
+ isHealthy: () => health().status === 'healthy',
37
+ isDegraded: () => health().status === 'degraded',
38
+ };
39
+ }
@@ -1,7 +1,6 @@
1
- import type { Surreal } from 'surrealdb';
2
1
  import type { SyncedDb } from '../index';
3
- import { GenericSchema } from '../lib/models';
4
- import type { SpookyConfig } from '@spooky-sync/core';
2
+ import type { GenericSchema } from '../lib/models';
3
+ import type { Sp00kyConfig } from '@spooky-sync/core';
5
4
  import type { SchemaStructure, TableNames, GetTable, TableModel } from '@spooky-sync/query-builder';
6
5
 
7
6
  /**
@@ -44,7 +43,7 @@ export type InferRelationshipsFromConst<S extends SchemaStructure, Schema extend
44
43
  // Prettify helper expands types for better intellisense
45
44
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
46
45
 
47
- export type SyncedDbConfig<S extends SchemaStructure> = Prettify<SpookyConfig<S>>;
46
+ export type SyncedDbConfig<S extends SchemaStructure> = Prettify<Sp00kyConfig<S>>;
48
47
 
49
48
  // export interface LocalDbConfig {
50
49
  // name: string;