@spooky-sync/client-solid 0.0.1-canary.16 → 0.0.1-canary.160

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.
@@ -0,0 +1,111 @@
1
+ import type {
2
+ ColumnSchema,
3
+ FinalQuery,
4
+ SchemaStructure,
5
+ TableNames,
6
+ } from '@spooky-sync/query-builder';
7
+ import { createEffect, useContext } from 'solid-js';
8
+ import { SyncedDb } from '..';
9
+ import type { Sp00kyQueryResultPromise, PreloadOptions as CorePreloadOptions } from '@spooky-sync/core';
10
+ import { Sp00kyContext } from './context';
11
+
12
+ type PreloadArg<
13
+ S extends SchemaStructure,
14
+ TableName extends TableNames<S>,
15
+ T extends { columns: Record<string, ColumnSchema> },
16
+ RelatedFields extends Record<string, any>,
17
+ IsOne extends boolean,
18
+ > =
19
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
20
+ | (() =>
21
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
22
+ | null
23
+ | undefined);
24
+
25
+ type PreloadOptions = CorePreloadOptions & {
26
+ /** Only preload while this returns true (defaults to always). */
27
+ enabled?: () => boolean;
28
+ };
29
+
30
+ // Overload: context-based (no explicit db)
31
+ export function createPreload<
32
+ S extends SchemaStructure,
33
+ TableName extends TableNames<S>,
34
+ T extends { columns: Record<string, ColumnSchema> },
35
+ RelatedFields extends Record<string, any>,
36
+ IsOne extends boolean,
37
+ >(
38
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
39
+ options?: PreloadOptions,
40
+ ): void;
41
+
42
+ // Overload: explicit db
43
+ export function createPreload<
44
+ S extends SchemaStructure,
45
+ TableName extends TableNames<S>,
46
+ T extends { columns: Record<string, ColumnSchema> },
47
+ RelatedFields extends Record<string, any>,
48
+ IsOne extends boolean,
49
+ >(
50
+ db: SyncedDb<S>,
51
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
52
+ options?: PreloadOptions,
53
+ ): void;
54
+
55
+ /**
56
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
57
+ * function so it tracks reactive deps), dedupes on the query's stable identity
58
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
59
+ * no cleanup: preload registers nothing that needs tearing down.
60
+ *
61
+ * Typical use: inside a list row, preload the detail query the user is likely
62
+ * to open next, so navigation paints from cache instead of the network.
63
+ */
64
+ export function createPreload<
65
+ S extends SchemaStructure,
66
+ TableName extends TableNames<S>,
67
+ T extends { columns: Record<string, ColumnSchema> },
68
+ RelatedFields extends Record<string, any>,
69
+ IsOne extends boolean,
70
+ >(
71
+ dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
72
+ queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
73
+ maybeOptions?: PreloadOptions,
74
+ ): void {
75
+ let db: SyncedDb<S>;
76
+ let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
77
+ let options: PreloadOptions | undefined;
78
+
79
+ if (dbOrQuery instanceof SyncedDb) {
80
+ db = dbOrQuery;
81
+ finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
82
+ options = maybeOptions;
83
+ } else {
84
+ const contextDb = useContext(Sp00kyContext);
85
+ if (!contextDb) {
86
+ throw new Error(
87
+ 'createPreload: No db argument provided and no Sp00kyContext found. ' +
88
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.',
89
+ );
90
+ }
91
+ db = contextDb as SyncedDb<S>;
92
+ finalQuery = dbOrQuery;
93
+ options = queryOrOptions as PreloadOptions | undefined;
94
+ }
95
+
96
+ let prevHash: number | undefined;
97
+
98
+ createEffect(() => {
99
+ if (!(options?.enabled?.() ?? true)) return;
100
+
101
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
102
+ if (!query) return;
103
+
104
+ // Dedupe on the query's stable identity hash so a reactive re-run with an
105
+ // unchanged query doesn't refetch (the core also dedupes per session).
106
+ if (query.hash === prevHash) return;
107
+ prevHash = query.hash;
108
+
109
+ void db.getSp00ky().preload(query, { refresh: options?.refresh, staleTime: options?.staleTime });
110
+ });
111
+ }
package/src/lib/models.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RecordId } from 'surrealdb';
1
+ import type { RecordId } from 'surrealdb';
2
2
 
3
3
  // Re-export types from query-builder for backward compatibility
4
4
  export type { GenericModel, GenericSchema } from '@spooky/query-builder';
@@ -0,0 +1,89 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import { semverGt, type AppReleaseOptions, type AppReleaseSnapshot } from '@spooky-sync/core';
4
+
5
+ export interface UseAppReleaseOptions extends AppReleaseOptions {
6
+ /** App name from sp00ky.yml, e.g. `web`. */
7
+ app: string;
8
+ /**
9
+ * The running build's version (X.Y.Z), typically baked in at build time
10
+ * (e.g. a vite `define` from package.json). `updateAvailable()` is true when
11
+ * the announced release is semver-newer than this.
12
+ */
13
+ currentVersion: string;
14
+ }
15
+
16
+ export interface UseAppRelease {
17
+ /** Latest announced version for the app, or undefined when no row exists. */
18
+ latestVersion: Accessor<string | undefined>;
19
+ /** Announced version is semver-newer than the running build. */
20
+ updateAvailable: Accessor<boolean>;
21
+ /** The newer release asks clients to update/reload without prompting. */
22
+ mandatory: Accessor<boolean>;
23
+ /** The newer release asks reloads to clear service-worker caches first. */
24
+ cacheBust: Accessor<boolean>;
25
+ /**
26
+ * Reload onto the announced release. Plain `location.reload()` normally;
27
+ * when the release is flagged cache-bust, CacheStorage is cleared, the
28
+ * service-worker registration is nudged to update, and navigation carries a
29
+ * `?cb=` token to punch through intermediary caches. The service worker is
30
+ * deliberately NOT unregistered: navigating while still controlled by a
31
+ * just-unregistered worker strands subresource fetches on the dead worker
32
+ * and the page hangs until a manual reload.
33
+ */
34
+ reload: () => Promise<void>;
35
+ }
36
+
37
+ async function reloadForSnapshot(snapshot: AppReleaseSnapshot): Promise<void> {
38
+ if (typeof window === 'undefined') return;
39
+ if (snapshot.cacheBust) {
40
+ try {
41
+ if (window.caches) {
42
+ const keys = await window.caches.keys();
43
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
44
+ }
45
+ if (navigator.serviceWorker) {
46
+ const regs = await navigator.serviceWorker.getRegistrations();
47
+ for (const r of regs) r.update().catch(() => {});
48
+ }
49
+ window.location.href = window.location.pathname + '?cb=' + Date.now();
50
+ return;
51
+ } catch {
52
+ /* fall through to a plain reload */
53
+ }
54
+ }
55
+ window.location.reload();
56
+ }
57
+
58
+ /**
59
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
60
+ * `spky deploy` / `spky release`) and compare it against the running build.
61
+ *
62
+ * Typical use: mount a small "new version available — Reload" notification
63
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
64
+ * (guard the auto path against reload loops with a per-version marker, since
65
+ * a client can reload while the deploy is still rolling out and land on the
66
+ * old bundle again).
67
+ */
68
+ export function useAppRelease(options: UseAppReleaseOptions): UseAppRelease {
69
+ const db = useDb();
70
+ const handle = db.getSp00ky().appRelease(options.app, { ttl: options.ttl });
71
+
72
+ const [snapshot, setSnapshot] = createSignal<AppReleaseSnapshot>(handle.snapshot());
73
+ const unsub = handle.subscribe(setSnapshot);
74
+
75
+ onCleanup(() => {
76
+ unsub();
77
+ handle.close();
78
+ });
79
+
80
+ const updateAvailable = () => semverGt(snapshot().version, options.currentVersion);
81
+
82
+ return {
83
+ latestVersion: () => snapshot().version,
84
+ updateAvailable,
85
+ mandatory: () => updateAvailable() && snapshot().mandatory,
86
+ cacheBust: () => snapshot().cacheBust,
87
+ reload: () => reloadForSnapshot(snapshot()),
88
+ };
89
+ }
@@ -0,0 +1,68 @@
1
+ import { createEffect, createSignal, onCleanup, useContext, type Accessor } from 'solid-js';
2
+ import { Sp00kyContext } from './context';
3
+ import type { CrdtField } from '@spooky-sync/core';
4
+
5
+ export function useCrdtField(
6
+ table: string,
7
+ recordId: () => string | undefined,
8
+ field: string,
9
+ fallbackText?: () => string | undefined,
10
+ ): Accessor<CrdtField | null> {
11
+ const db = useContext(Sp00kyContext);
12
+ if (!db) {
13
+ throw new Error('useCrdtField must be used within a <Sp00kyProvider>');
14
+ }
15
+
16
+ const [crdtField, setCrdtField] = createSignal<CrdtField | null>(null);
17
+ let currentId: string | undefined;
18
+ let initialized = false;
19
+
20
+ createEffect(() => {
21
+ const id = recordId();
22
+
23
+ // Skip if the ID hasn't changed (but allow the first non-undefined value through)
24
+ if (initialized && id === currentId) return;
25
+
26
+ // Close previous field
27
+ if (currentId && crdtField()) {
28
+ db.getSp00ky().closeCrdtField(table, currentId, field);
29
+ setCrdtField(null);
30
+ }
31
+
32
+ currentId = id;
33
+ initialized = true;
34
+
35
+ if (!id) return;
36
+
37
+ const sp00ky = db.getSp00ky();
38
+ const text = fallbackText?.();
39
+ sp00ky
40
+ .openCrdtField(table, id, field, text)
41
+ .then((cf) => {
42
+ if (currentId === id) {
43
+ setCrdtField(cf);
44
+ }
45
+ })
46
+ .catch((err) => {
47
+ // Silent rejections here leave the consumer's `Show when={field()}`
48
+ // permanently stuck on its fallback (typically a static `<p>` with
49
+ // no editing UI), with no error trail. Surface the failure so the
50
+ // root cause (missing `@crdt` annotation, schema codegen drift,
51
+ // local DB query failure, etc.) is visible in the console instead
52
+ // of silently breaking collaborative fields.
53
+ console.error(
54
+ `[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`,
55
+ err,
56
+ );
57
+ });
58
+ });
59
+
60
+ onCleanup(() => {
61
+ if (currentId && crdtField()) {
62
+ db.getSp00ky().closeCrdtField(table, currentId, field);
63
+ setCrdtField(null);
64
+ }
65
+ });
66
+
67
+ return crdtField;
68
+ }
@@ -78,9 +78,8 @@ export function useDownloadFile<S extends SchemaStructure>(
78
78
 
79
79
  let currentKey: string | null = null;
80
80
  let privateUrl: string | null = null;
81
- let refetchTrigger: () => void;
82
81
  const [refetchSignal, setRefetchSignal] = createSignal(0);
83
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
82
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
84
83
 
85
84
  async function doDownload(key: string, filePath: string): Promise<string | null> {
86
85
  if (useCache) {
@@ -184,6 +183,7 @@ export function useDownloadFile<S extends SchemaStructure>(
184
183
  setUrl(result);
185
184
  setIsLoading(false);
186
185
  }
186
+ return undefined;
187
187
  },
188
188
  (err) => {
189
189
  if (!cancelled) {
@@ -0,0 +1,50 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { FeatureFlagOptions } from '@spooky-sync/core';
4
+
5
+ export interface UseFeatureFlag {
6
+ variant: Accessor<string | undefined>;
7
+ payload: Accessor<unknown | undefined>;
8
+ enabled: Accessor<boolean>;
9
+ }
10
+
11
+ /**
12
+ * Subscribe to a feature flag for the currently authenticated user.
13
+ *
14
+ * Returns three Solid accessors that update reactively whenever the
15
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
16
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
17
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
18
+ *
19
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
20
+ * For multi-variant flags, prefer `variant()` directly.
21
+ */
22
+ export function useFeatureFlag(
23
+ key: string,
24
+ options?: FeatureFlagOptions,
25
+ ): UseFeatureFlag {
26
+ const db = useDb();
27
+ const handle = db.getSp00ky().feature(key, options);
28
+
29
+ const [variant, setVariant] = createSignal<string | undefined>(handle.variant());
30
+ const [payload, setPayload] = createSignal<unknown | undefined>(handle.payload());
31
+
32
+ const unsub = handle.subscribe((s) => {
33
+ setVariant(s.variant ?? options?.fallback);
34
+ setPayload(s.payload);
35
+ });
36
+
37
+ onCleanup(() => {
38
+ unsub();
39
+ handle.close();
40
+ });
41
+
42
+ return {
43
+ variant,
44
+ payload,
45
+ enabled: () => {
46
+ const v = variant();
47
+ return v !== undefined && v !== 'off';
48
+ },
49
+ };
50
+ }
@@ -33,6 +33,7 @@ export function useFileUpload<S extends SchemaStructure>(
33
33
  bucketName = dbOrBucketName as BucketNames<S>;
34
34
  } else {
35
35
  db = dbOrBucketName as SyncedDb<S>;
36
+ // oxlint-disable-next-line no-non-null-assertion
36
37
  bucketName = maybeBucketName!;
37
38
  }
38
39
 
@@ -52,7 +53,7 @@ export function useFileUpload<S extends SchemaStructure>(
52
53
  const config = db.getBucketConfig(bucketName as string);
53
54
  if (!config) return;
54
55
 
55
- if (config.maxSize != null && file.size > config.maxSize) {
56
+ if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
56
57
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
57
58
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
58
59
  }
@@ -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,13 @@ 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
+ isSettled: () => boolean;
56
+ };
40
57
 
41
58
  // Overload: explicit db (backward-compatible)
42
59
  export function useQuery<
@@ -50,7 +67,13 @@ export function useQuery<
50
67
  db: SyncedDb<S>,
51
68
  finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
52
69
  options?: QueryOptions,
53
- ): { data: () => TData | undefined; error: () => Error | undefined; isLoading: () => boolean };
70
+ ): {
71
+ data: () => TData | undefined;
72
+ error: () => Error | undefined;
73
+ isLoading: () => boolean;
74
+ isFetching: () => boolean;
75
+ isSettled: () => boolean;
76
+ };
54
77
 
55
78
  // Implementation
56
79
  export function useQuery<
@@ -82,11 +105,11 @@ export function useQuery<
82
105
  options = maybeOptions;
83
106
  } else {
84
107
  // Context-based overload: useQuery(query, options?)
85
- const contextDb = useContext(SpookyContext);
108
+ const contextDb = useContext(Sp00kyContext);
86
109
  if (!contextDb) {
87
110
  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>.'
111
+ 'useQuery: No db argument provided and no Sp00kyContext found. ' +
112
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.'
90
113
  );
91
114
  }
92
115
  db = contextDb as SyncedDb<S>;
@@ -94,29 +117,76 @@ export function useQuery<
94
117
  options = queryOrOptions as QueryOptions | undefined;
95
118
  }
96
119
 
97
- const [data, setData] = createSignal<TData | undefined>(undefined);
98
120
  const [error, setError] = createSignal<Error | undefined>(undefined);
99
121
  const [isFetched, setIsFetched] = createSignal(false);
100
- const [unsubscribe, setUnsubscribe] = createSignal<(() => void) | undefined>(undefined);
122
+ const [isFetching, setIsFetching] = createSignal(false);
123
+ // Results live in a store (not a signal) so consecutive live-query emissions
124
+ // are merged with `reconcile`: unchanged rows keep their object identity and
125
+ // changed rows are mutated in place. That keeps Solid's reference-keyed `<For>`
126
+ // rows — and any `useQuery` subscriptions mounted inside them — alive across
127
+ // updates, instead of tearing every row down and re-registering its queries.
128
+ const [state, setState] = createStore<{ value: TData | undefined }>({ value: undefined });
129
+ // `reconcile` (below) merges each emission into `state.value` IN PLACE, keeping
130
+ // the array reference stable. That's ideal for granular per-row reactivity, but
131
+ // it means a *coarse* reader of `data()` — `<For each={data()}>`, or an effect
132
+ // that copies the whole array elsewhere (e.g. GameList's windowed store) — is
133
+ // NOT re-run when rows are added/removed/reordered within a same-length result
134
+ // (the classic case: deleting a row in a windowed list shifts the next one in,
135
+ // so length stays 50 and the array ref never changes). Bump a version on every
136
+ // emission and read it in `data()` so every consumer re-runs on any change while
137
+ // reconcile still preserves row identity underneath.
138
+ const [version, setVersion] = createSignal(0);
139
+ const data = () => {
140
+ version();
141
+ return state.value;
142
+ };
143
+
101
144
  let prevQueryString: string | undefined;
145
+ // Monotonic token for each subscription generation. Bumped whenever the query
146
+ // identity changes or the hook is disposed, so a slow async `initQuery`
147
+ // continuation can detect it was superseded and avoid installing a stale (and
148
+ // leaked) subscription.
149
+ let runId = 0;
150
+ let activeUnsub: (() => void) | undefined;
151
+ // The hash of the currently-installed subscription, for opt-in deregister on
152
+ // dispose (see `deregisterOnCleanup`).
153
+ let activeHash: string | undefined;
154
+
155
+ const teardownActive = () => {
156
+ activeUnsub?.();
157
+ activeUnsub = undefined;
158
+ };
102
159
 
103
- const spooky = db.getSpooky();
160
+ const sp00ky = db.getSp00ky();
104
161
 
105
162
  const initQuery = async (
106
- query: FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
163
+ query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
164
+ myRun: number
107
165
  ) => {
108
166
  const { hash } = await query.run();
167
+ // A newer query identity (or disposal) won the race while we awaited run().
168
+ if (myRun !== runId) return;
169
+ activeHash = hash;
109
170
  setError(undefined);
110
171
 
111
172
  let isFirstCall = true;
112
- const unsub = await spooky.subscribe(
173
+ const unsub = await sp00ky.subscribe(
113
174
  hash,
114
175
  (e) => {
115
- const data = (query.isOne ? e[0] : e) as TData;
116
- setData(() => data);
176
+ const queryData = (query.isOne ? e[0] : e) as TData;
177
+ // Merge into the store by record id: unchanged rows keep their identity,
178
+ // changed rows update in place. Replaces wholesale for `one()`/null.
179
+ // Time the reconcile → report as the "frontend" phase for DevTools/MCP.
180
+ const reconcileStart = performance.now();
181
+ setState('value', reconcile(queryData as any, { key: 'id' }));
182
+ // Notify coarse `data()` readers (see the `version` note above): reconcile
183
+ // keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
184
+ // on add/remove/reorder.
185
+ setVersion((v) => v + 1);
186
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
117
187
  // The first (immediate) callback with no data likely means the local DB
118
188
  // 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;
189
+ const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
120
190
  if (!isFirstCall || hasData) {
121
191
  setIsFetched(true);
122
192
  }
@@ -125,7 +195,25 @@ export function useQuery<
125
195
  { immediate: true }
126
196
  );
127
197
 
128
- setUnsubscribe(() => unsub);
198
+ // Mirror the query's fetch status so the UI can show a "loading more"
199
+ // state while the sync engine pulls missing records in the background.
200
+ const unsubStatus = sp00ky.subscribeQueryStatus(
201
+ hash,
202
+ (status) => setIsFetching(status === 'fetching'),
203
+ { immediate: true }
204
+ );
205
+
206
+ const teardown = () => {
207
+ unsub();
208
+ unsubStatus();
209
+ };
210
+
211
+ // Superseded while awaiting subscribe()? Don't leak — tear down immediately.
212
+ if (myRun !== runId) {
213
+ teardown();
214
+ return;
215
+ }
216
+ activeUnsub = teardown;
129
217
  };
130
218
 
131
219
  createEffect(() => {
@@ -143,30 +231,57 @@ export function useQuery<
143
231
  return;
144
232
  }
145
233
 
146
- // Prevent re-running if query hasn't changed
147
- const queryString = JSON.stringify(query);
234
+ // Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
235
+ // full `JSON.stringify` of the FinalQuery (which walks the whole schema +
236
+ // inner query on every reactive tick and isn't guaranteed stable). When the
237
+ // identity is unchanged we keep the existing subscription alive.
238
+ const queryString = String(query.hash);
148
239
  if (queryString === prevQueryString) {
149
240
  return;
150
241
  }
151
242
  prevQueryString = queryString;
152
243
 
153
- // Reset fetched state when query changes
244
+ // New query identity supersede the previous subscription and start fresh.
245
+ const myRun = ++runId;
246
+ teardownActive();
154
247
  setIsFetched(false);
155
- initQuery(query);
248
+ initQuery(query, myRun);
249
+ });
156
250
 
157
- // Cleanup
158
- onCleanup(() => {
159
- unsubscribe()?.();
160
- });
251
+ // Tear down the live subscription when the hook's owner is disposed. Registered
252
+ // on the hook (component) scope rather than inside the effect, so an effect
253
+ // re-run that early-returns (unchanged query) doesn't clean up the still-valid
254
+ // subscription. Bumping runId also invalidates any in-flight initQuery.
255
+ onCleanup(() => {
256
+ runId++;
257
+ teardownActive();
258
+ // Opt-in: cancel the query once this hook (its last subscriber) is gone.
259
+ // teardownActive() above already removed this hook's callback, so
260
+ // deregisterQuery's refcount guard sees the true remaining-subscriber count.
261
+ if (options?.deregisterOnCleanup && activeHash) {
262
+ sp00ky.deregisterQuery(activeHash);
263
+ }
161
264
  });
162
265
 
163
266
  const isLoading = () => {
164
267
  return !isFetched() && error() === undefined;
165
268
  };
166
269
 
270
+ // True once the query has delivered a result AND no fetch cycle is in flight
271
+ // (registration + initial sync included — the core holds `fetching` across
272
+ // the whole registration and flushes debounced results before flipping back
273
+ // to idle). While settled, the results are authoritative: a windowed query
274
+ // returning fewer rows than its LIMIT really is the end of the list, so
275
+ // virtualized lists may size themselves to it without the scrollbar jumping
276
+ // when a still-syncing window transiently reports short. Resets to false
277
+ // whenever the query identity changes.
278
+ const isSettled = () => isFetched() && !isFetching();
279
+
167
280
  return {
168
281
  data,
169
282
  error,
170
283
  isLoading,
284
+ isFetching,
285
+ isSettled,
171
286
  };
172
287
  }
@@ -0,0 +1,44 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
4
+
5
+ export interface UseStorageStatus {
6
+ /** Full durability snapshot; updates reactively. */
7
+ health: Accessor<StorageHealth>;
8
+ /** `'unknown'` | `'persistent'` | `'memory'`. */
9
+ status: Accessor<StorageHealthStatus>;
10
+ /** `true` when the local store survives a reload. */
11
+ isPersistent: Accessor<boolean>;
12
+ /**
13
+ * `true` only when durable storage was requested and could NOT be opened, so
14
+ * the dataset is sitting in RAM and local writes die on reload. Drive a
15
+ * warning off this, not off `status`: a store configured as in-memory reports
16
+ * `'memory'` too, and that is a choice rather than a problem.
17
+ */
18
+ isMemoryFallback: Accessor<boolean>;
19
+ }
20
+
21
+ /**
22
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
23
+ *
24
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
25
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
26
+ * second tab of the same app cannot get it and runs in memory instead (the
27
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
28
+ * used within a `<Sp00kyProvider>`.
29
+ */
30
+ export function useStorageStatus(): UseStorageStatus {
31
+ const db = useDb();
32
+ // subscribeToStorageHealth fires synchronously with the current snapshot, so
33
+ // the signal is correct from the first read; the initial value avoids a flash.
34
+ const [health, setHealth] = createSignal<StorageHealth>(db.storageHealth);
35
+ const unsub = db.subscribeToStorageHealth(setHealth);
36
+ onCleanup(unsub);
37
+
38
+ return {
39
+ health,
40
+ status: () => health().status,
41
+ isPersistent: () => health().status === 'persistent',
42
+ isMemoryFallback: () => health().fallback,
43
+ };
44
+ }