@spooky-sync/client-solid 0.0.1-canary.33 → 0.0.1-canary.331

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,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
+ }
@@ -1,6 +1,7 @@
1
1
  import { createSignal, onCleanup } from 'solid-js';
2
2
  import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
3
  import { fileToUint8Array } from '@spooky-sync/core';
4
+ import type { BucketPutOptions, BucketPutResult } from '@spooky-sync/core';
4
5
  import type { SyncedDb } from '../index';
5
6
  import { useDb } from './context';
6
7
 
@@ -8,7 +9,7 @@ export interface FileUploadResult {
8
9
  isUploading: () => boolean;
9
10
  error: () => Error | null;
10
11
  clearError: () => void;
11
- upload: (path: string, file: File | Blob) => Promise<void>;
12
+ upload: (path: string, file: File | Blob, options?: BucketPutOptions) => Promise<BucketPutResult | void>;
12
13
  download: (path: string) => Promise<string | null>;
13
14
  remove: (path: string) => Promise<void>;
14
15
  exists: (path: string) => Promise<boolean>;
@@ -33,6 +34,7 @@ export function useFileUpload<S extends SchemaStructure>(
33
34
  bucketName = dbOrBucketName as BucketNames<S>;
34
35
  } else {
35
36
  db = dbOrBucketName as SyncedDb<S>;
37
+ // oxlint-disable-next-line no-non-null-assertion
36
38
  bucketName = maybeBucketName!;
37
39
  }
38
40
 
@@ -52,7 +54,7 @@ export function useFileUpload<S extends SchemaStructure>(
52
54
  const config = db.getBucketConfig(bucketName as string);
53
55
  if (!config) return;
54
56
 
55
- if (config.maxSize != null && file.size > config.maxSize) {
57
+ if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
56
58
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
57
59
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
58
60
  }
@@ -70,7 +72,11 @@ export function useFileUpload<S extends SchemaStructure>(
70
72
  }
71
73
  };
72
74
 
73
- const upload = async (path: string, file: File | Blob): Promise<void> => {
75
+ const upload = async (
76
+ path: string,
77
+ file: File | Blob,
78
+ options?: BucketPutOptions
79
+ ): Promise<BucketPutResult | void> => {
74
80
  setError(null);
75
81
  try {
76
82
  validate(file);
@@ -82,7 +88,7 @@ export function useFileUpload<S extends SchemaStructure>(
82
88
  setIsUploading(true);
83
89
  try {
84
90
  const bytes = await fileToUint8Array(file);
85
- await db.bucket(bucketName).put(path, bytes);
91
+ return await db.bucket(bucketName).put(path, bytes, options);
86
92
  } catch (e) {
87
93
  setError(e instanceof Error ? e : new Error(String(e)));
88
94
  } finally {
@@ -0,0 +1,44 @@
1
+ import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { ImpersonationInfo } from '@spooky-sync/core';
4
+
5
+ export interface UseImpersonationOptions {
6
+ /**
7
+ * This component renders the app's own impersonation banner. Required with
8
+ * `impersonationBanner: { mode: 'custom' }`: it acknowledges the banner, so
9
+ * the client does not fall back to its built-in one.
10
+ */
11
+ rendersBanner?: boolean;
12
+ }
13
+
14
+ export interface UseImpersonation {
15
+ /** The active impersonation, or `null`. */
16
+ impersonation: Accessor<ImpersonationInfo | null>;
17
+ isImpersonating: Accessor<boolean>;
18
+ /** Return to the admin session. */
19
+ stop: () => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Observe admin impersonation (started from the DevTools). The client already
24
+ * shows its own warning bar with a Stop button; use this for anything extra,
25
+ * such as hiding actions an admin should not take on someone's behalf.
26
+ * Must be used within a `<Sp00kyProvider>`.
27
+ */
28
+ export function useImpersonation(options: UseImpersonationOptions = {}): UseImpersonation {
29
+ const db = useDb();
30
+ const [impersonation, setImpersonation] = createSignal<ImpersonationInfo | null>(
31
+ db.auth.impersonation
32
+ );
33
+ onCleanup(db.auth.subscribeImpersonation(setImpersonation));
34
+ // Re-acknowledged per session: a new impersonation restarts the client's
35
+ // fallback timer, and this component is what cancels it.
36
+ createEffect(() => {
37
+ if (options.rendersBanner && impersonation()) db.acknowledgeImpersonationBanner();
38
+ });
39
+ return {
40
+ impersonation,
41
+ isImpersonating: () => impersonation() !== null,
42
+ stop: () => db.auth.stopImpersonating(),
43
+ };
44
+ }
@@ -1,4 +1,4 @@
1
- import {
1
+ import type {
2
2
  ColumnSchema,
3
3
  FinalQuery,
4
4
  SchemaStructure,
@@ -6,8 +6,9 @@ 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 { Sp00kyQueryResultPromise } from '@spooky-sync/core';
11
+ import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
11
12
  import { Sp00kyContext } from './context';
12
13
 
13
14
  type QueryArg<
@@ -23,7 +24,17 @@ type QueryArg<
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<
@@ -94,29 +117,98 @@ 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
160
  const sp00ky = db.getSp00ky();
104
161
 
162
+ /**
163
+ * Registration can fail — the canonical case is the SSP answering 503
164
+ * NOT_READY while it bootstraps. Nothing here used to catch that: the
165
+ * rejection escaped as an unhandled promise, `isFetched` stayed false, and
166
+ * `isLoading()` therefore stayed true FOREVER, which is what a spinner that
167
+ * never resolves actually was. Surface it as `error()` instead; the sync
168
+ * scheduler retries the registration underneath, so a transient failure
169
+ * still recovers on its own.
170
+ */
105
171
  const initQuery = async (
106
- query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
172
+ query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
173
+ myRun: number
174
+ ) => {
175
+ try {
176
+ await subscribeQuery(query, myRun);
177
+ } catch (err) {
178
+ // A superseded run's failure is not this subscription's problem.
179
+ if (myRun !== runId) return;
180
+ setError(err instanceof Error ? err : new Error(String(err)));
181
+ }
182
+ };
183
+
184
+ const subscribeQuery = async (
185
+ query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
186
+ myRun: number
107
187
  ) => {
108
188
  const { hash } = await query.run();
189
+ // A newer query identity (or disposal) won the race while we awaited run().
190
+ if (myRun !== runId) return;
191
+ activeHash = hash;
109
192
  setError(undefined);
110
193
 
111
194
  let isFirstCall = true;
112
195
  const unsub = await sp00ky.subscribe(
113
196
  hash,
114
197
  (e) => {
115
- const data = (query.isOne ? e[0] : e) as TData;
116
- setData(() => data);
198
+ const queryData = (query.isOne ? e[0] : e) as TData;
199
+ // Merge into the store by record id: unchanged rows keep their identity,
200
+ // changed rows update in place. Replaces wholesale for `one()`/null.
201
+ // Time the reconcile → report as the "frontend" phase for DevTools/MCP.
202
+ const reconcileStart = performance.now();
203
+ setState('value', reconcile(queryData as any, { key: 'id' }));
204
+ // Notify coarse `data()` readers (see the `version` note above): reconcile
205
+ // keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
206
+ // on add/remove/reorder.
207
+ setVersion((v) => v + 1);
208
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
117
209
  // The first (immediate) callback with no data likely means the local DB
118
210
  // 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;
211
+ const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
120
212
  if (!isFirstCall || hasData) {
121
213
  setIsFetched(true);
122
214
  }
@@ -125,7 +217,49 @@ export function useQuery<
125
217
  { immediate: true }
126
218
  );
127
219
 
128
- setUnsubscribe(() => unsub);
220
+ // A result that is empty and stays empty (a new account's list) produces
221
+ // no second callback above, so `isFetched` also follows the server: once
222
+ // its membership for the query is known and no fetch is in flight, the
223
+ // (possibly empty) result is the answer. Without this the hook sat in
224
+ // `isLoading()` until a reload.
225
+ let known = false;
226
+ let idle = false;
227
+ const settleFromServer = () => {
228
+ if (known && idle && myRun === runId) setIsFetched(true);
229
+ };
230
+
231
+ // Mirror the query's fetch status so the UI can show a "loading more"
232
+ // state while the sync engine pulls missing records in the background.
233
+ const unsubStatus = sp00ky.subscribeQueryStatus(
234
+ hash,
235
+ (status) => {
236
+ setIsFetching(status === 'fetching');
237
+ idle = status === 'idle';
238
+ settleFromServer();
239
+ },
240
+ { immediate: true }
241
+ );
242
+ const unsubAuthority = sp00ky.subscribeQueryAuthority(
243
+ hash,
244
+ (k) => {
245
+ known = k;
246
+ settleFromServer();
247
+ },
248
+ { immediate: true }
249
+ );
250
+
251
+ const teardown = () => {
252
+ unsub();
253
+ unsubStatus();
254
+ unsubAuthority();
255
+ };
256
+
257
+ // Superseded while awaiting subscribe()? Don't leak — tear down immediately.
258
+ if (myRun !== runId) {
259
+ teardown();
260
+ return;
261
+ }
262
+ activeUnsub = teardown;
129
263
  };
130
264
 
131
265
  createEffect(() => {
@@ -143,30 +277,60 @@ export function useQuery<
143
277
  return;
144
278
  }
145
279
 
146
- // Prevent re-running if query hasn't changed
147
- const queryString = JSON.stringify(query);
280
+ // Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
281
+ // full `JSON.stringify` of the FinalQuery (which walks the whole schema +
282
+ // inner query on every reactive tick and isn't guaranteed stable). When the
283
+ // identity is unchanged we keep the existing subscription alive.
284
+ const queryString = String(query.hash);
148
285
  if (queryString === prevQueryString) {
149
286
  return;
150
287
  }
151
288
  prevQueryString = queryString;
152
289
 
153
- // Reset fetched state when query changes
290
+ // New query identity → supersede the previous subscription and start fresh.
291
+ const myRun = ++runId;
292
+ teardownActive();
154
293
  setIsFetched(false);
155
- initQuery(query);
294
+ // A new identity starts clean: a previous identity's failure must not keep
295
+ // this one out of its loading state.
296
+ setError(undefined);
297
+ void initQuery(query, myRun);
298
+ });
156
299
 
157
- // Cleanup
158
- onCleanup(() => {
159
- unsubscribe()?.();
160
- });
300
+ // Tear down the live subscription when the hook's owner is disposed. Registered
301
+ // on the hook (component) scope rather than inside the effect, so an effect
302
+ // re-run that early-returns (unchanged query) doesn't clean up the still-valid
303
+ // subscription. Bumping runId also invalidates any in-flight initQuery.
304
+ onCleanup(() => {
305
+ runId++;
306
+ teardownActive();
307
+ // Opt-in: cancel the query once this hook (its last subscriber) is gone.
308
+ // teardownActive() above already removed this hook's callback, so
309
+ // deregisterQuery's refcount guard sees the true remaining-subscriber count.
310
+ if (options?.deregisterOnCleanup && activeHash) {
311
+ sp00ky.deregisterQuery(activeHash);
312
+ }
161
313
  });
162
314
 
163
315
  const isLoading = () => {
164
316
  return !isFetched() && error() === undefined;
165
317
  };
166
318
 
319
+ // True once the query has delivered a result AND no fetch cycle is in flight
320
+ // (registration + initial sync included — the core holds `fetching` across
321
+ // the whole registration and flushes debounced results before flipping back
322
+ // to idle). While settled, the results are authoritative: a windowed query
323
+ // returning fewer rows than its LIMIT really is the end of the list, so
324
+ // virtualized lists may size themselves to it without the scrollbar jumping
325
+ // when a still-syncing window transiently reports short. Resets to false
326
+ // whenever the query identity changes.
327
+ const isSettled = () => isFetched() && !isFetching();
328
+
167
329
  return {
168
330
  data,
169
331
  error,
170
332
  isLoading,
333
+ isFetching,
334
+ isSettled,
171
335
  };
172
336
  }
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { ConnectionState, 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
+ /** `true` once at least one sync round has succeeded this session. */
14
+ everConnected: Accessor<boolean>;
15
+ /**
16
+ * `true` only for a real lost connection: degraded AFTER a first successful
17
+ * sync. Stays `false` during the initial "connecting" phase (degraded but
18
+ * never reached the server yet), so an indicator can show nothing until the
19
+ * app has actually connected once.
20
+ */
21
+ isOffline: Accessor<boolean>;
22
+ /**
23
+ * Transport state of the remote WebSocket. Flips the instant the socket
24
+ * drops, unlike `status`, which only degrades after a sustained run of failed
25
+ * sync rounds — so this is what to drive a "reconnecting…" affordance off.
26
+ */
27
+ connection: Accessor<ConnectionState>;
28
+ /**
29
+ * `true` while the connection is being re-established. Usually still
30
+ * `isHealthy()`: a short reconnect is invisible to sync, and writes made
31
+ * during it are queued locally and pushed once the socket is back.
32
+ */
33
+ isReconnecting: Accessor<boolean>;
34
+ }
35
+
36
+ /**
37
+ * Observe sync health for a "can't reach the server" banner / indicator.
38
+ *
39
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
40
+ * remote 500 on query registration, a dropped socket) are absorbed by the
41
+ * retry and never flip this; `isDegraded()` only goes true once failures
42
+ * persist for the configured number of consecutive rounds (sp00ky core config
43
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
44
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
45
+ */
46
+ export function useSyncStatus(): UseSyncStatus {
47
+ const db = useDb();
48
+ // subscribeToSyncHealth fires synchronously with the current status, so the
49
+ // signal is correct from first read; the initial value just avoids a flash.
50
+ const [health, setHealth] = createSignal<SyncHealth>(db.syncHealth);
51
+ const unsub = db.subscribeToSyncHealth(setHealth);
52
+ onCleanup(unsub);
53
+
54
+ return {
55
+ health,
56
+ status: () => health().status,
57
+ isHealthy: () => health().status === 'healthy',
58
+ isDegraded: () => health().status === 'degraded',
59
+ everConnected: () => health().everConnected,
60
+ isOffline: () => health().status === 'degraded' && health().everConnected,
61
+ connection: () => health().connection,
62
+ isReconnecting: () => health().connection === 'reconnecting',
63
+ };
64
+ }
@@ -1,6 +1,5 @@
1
- import type { Surreal } from 'surrealdb';
2
1
  import type { SyncedDb } from '../index';
3
- import { GenericSchema } from '../lib/models';
2
+ import type { GenericSchema } from '../lib/models';
4
3
  import type { Sp00kyConfig } from '@spooky-sync/core';
5
4
  import type { SchemaStructure, TableNames, GetTable, TableModel } from '@spooky-sync/query-builder';
6
5