@spooky-sync/client-solid 0.0.1-canary.18 → 0.0.1-canary.180

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/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { SpookyClient, fileToUint8Array } from "@spooky-sync/core";
1
+ import { Sp00kyClient, fileToUint8Array, semverGt } from "@spooky-sync/core";
2
2
  import { RecordId, Uuid } from "surrealdb";
3
3
  import { createComponent, createContext, createEffect, createMemo, createSignal, mergeProps, onCleanup, onMount, useContext } from "solid-js";
4
+ import { createStore, reconcile } from "solid-js/store";
4
5
 
5
6
  //#region src/lib/context.ts
6
- const SpookyContext = createContext();
7
+ const Sp00kyContext = createContext();
7
8
  function useDb() {
8
- const db = useContext(SpookyContext);
9
- if (!db) throw new Error("useDb must be used within a <SpookyProvider>. Wrap your app in <SpookyProvider config={...}>.");
9
+ const db = useContext(Sp00kyContext);
10
+ if (!db) throw new Error("useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.");
10
11
  return db;
11
12
  }
12
13
 
@@ -21,30 +22,73 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
21
22
  finalQuery = queryOrOptions;
22
23
  options = maybeOptions;
23
24
  } else {
24
- const contextDb = useContext(SpookyContext);
25
- if (!contextDb) throw new Error("useQuery: No db argument provided and no SpookyContext found. Either pass a SyncedDb instance or wrap your app in <SpookyProvider>.");
25
+ const contextDb = useContext(Sp00kyContext);
26
+ if (!contextDb) throw new Error("useQuery: No db argument provided and no Sp00kyContext found. Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.");
26
27
  db = contextDb;
27
28
  finalQuery = dbOrQuery;
28
29
  options = queryOrOptions;
29
30
  }
30
- const [data, setData] = createSignal(void 0);
31
31
  const [error, setError] = createSignal(void 0);
32
32
  const [isFetched, setIsFetched] = createSignal(false);
33
- const [unsubscribe, setUnsubscribe] = createSignal(void 0);
33
+ const [isFetching, setIsFetching] = createSignal(false);
34
+ const [state, setState] = createStore({ value: void 0 });
35
+ const [version, setVersion] = createSignal(0);
36
+ const data = () => {
37
+ version();
38
+ return state.value;
39
+ };
34
40
  let prevQueryString;
35
- const spooky = db.getSpooky();
36
- const initQuery = async (query) => {
41
+ let runId = 0;
42
+ let activeUnsub;
43
+ let activeHash;
44
+ const teardownActive = () => {
45
+ activeUnsub?.();
46
+ activeUnsub = void 0;
47
+ };
48
+ const sp00ky = db.getSp00ky();
49
+ /**
50
+ * Registration can fail — the canonical case is the SSP answering 503
51
+ * NOT_READY while it bootstraps. Nothing here used to catch that: the
52
+ * rejection escaped as an unhandled promise, `isFetched` stayed false, and
53
+ * `isLoading()` therefore stayed true FOREVER, which is what a spinner that
54
+ * never resolves actually was. Surface it as `error()` instead; the sync
55
+ * scheduler retries the registration underneath, so a transient failure
56
+ * still recovers on its own.
57
+ */
58
+ const initQuery = async (query, myRun) => {
59
+ try {
60
+ await subscribeQuery(query, myRun);
61
+ } catch (err) {
62
+ if (myRun !== runId) return;
63
+ setError(err instanceof Error ? err : new Error(String(err)));
64
+ }
65
+ };
66
+ const subscribeQuery = async (query, myRun) => {
37
67
  const { hash } = await query.run();
68
+ if (myRun !== runId) return;
69
+ activeHash = hash;
38
70
  setError(void 0);
39
71
  let isFirstCall = true;
40
- const unsub = await spooky.subscribe(hash, (e) => {
41
- const data = query.isOne ? e[0] : e;
42
- setData(() => data);
43
- const hasData = query.isOne ? data != null : e.length > 0;
72
+ const unsub = await sp00ky.subscribe(hash, (e) => {
73
+ const queryData = query.isOne ? e[0] : e;
74
+ const reconcileStart = performance.now();
75
+ setState("value", reconcile(queryData, { key: "id" }));
76
+ setVersion((v) => v + 1);
77
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
78
+ const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
44
79
  if (!isFirstCall || hasData) setIsFetched(true);
45
80
  isFirstCall = false;
46
81
  }, { immediate: true });
47
- setUnsubscribe(() => unsub);
82
+ const unsubStatus = sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true });
83
+ const teardown = () => {
84
+ unsub();
85
+ unsubStatus();
86
+ };
87
+ if (myRun !== runId) {
88
+ teardown();
89
+ return;
90
+ }
91
+ activeUnsub = teardown;
48
92
  };
49
93
  createEffect(() => {
50
94
  if (!(options?.enabled?.() ?? true)) {
@@ -53,22 +97,237 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
53
97
  }
54
98
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
55
99
  if (!query) return;
56
- const queryString = JSON.stringify(query);
100
+ const queryString = String(query.hash);
57
101
  if (queryString === prevQueryString) return;
58
102
  prevQueryString = queryString;
103
+ const myRun = ++runId;
104
+ teardownActive();
59
105
  setIsFetched(false);
60
- initQuery(query);
61
- onCleanup(() => {
62
- unsubscribe()?.();
63
- });
106
+ setError(void 0);
107
+ initQuery(query, myRun);
108
+ });
109
+ onCleanup(() => {
110
+ runId++;
111
+ teardownActive();
112
+ if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
64
113
  });
65
114
  const isLoading = () => {
66
115
  return !isFetched() && error() === void 0;
67
116
  };
117
+ const isSettled = () => isFetched() && !isFetching();
68
118
  return {
69
119
  data,
70
120
  error,
71
- isLoading
121
+ isLoading,
122
+ isFetching,
123
+ isSettled
124
+ };
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/lib/create-preload.ts
129
+ /**
130
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
131
+ * function so it tracks reactive deps), dedupes on the query's stable identity
132
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
133
+ * no cleanup: preload registers nothing that needs tearing down.
134
+ *
135
+ * Typical use: inside a list row, preload the detail query the user is likely
136
+ * to open next, so navigation paints from cache instead of the network.
137
+ */
138
+ function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
139
+ let db;
140
+ let finalQuery;
141
+ let options;
142
+ if (dbOrQuery instanceof SyncedDb) {
143
+ db = dbOrQuery;
144
+ finalQuery = queryOrOptions;
145
+ options = maybeOptions;
146
+ } else {
147
+ const contextDb = useContext(Sp00kyContext);
148
+ if (!contextDb) throw new Error("createPreload: No db argument provided and no Sp00kyContext found. Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.");
149
+ db = contextDb;
150
+ finalQuery = dbOrQuery;
151
+ options = queryOrOptions;
152
+ }
153
+ let prevHash;
154
+ createEffect(() => {
155
+ if (!(options?.enabled?.() ?? true)) return;
156
+ const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
157
+ if (!query) return;
158
+ if (query.hash === prevHash) return;
159
+ prevHash = query.hash;
160
+ db.getSp00ky().preload(query, {
161
+ refresh: options?.refresh,
162
+ staleTime: options?.staleTime
163
+ });
164
+ });
165
+ }
166
+
167
+ //#endregion
168
+ //#region src/lib/use-sync-status.ts
169
+ /**
170
+ * Observe sync health for a "can't reach the server" banner / indicator.
171
+ *
172
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
173
+ * remote 500 on query registration, a dropped socket) are absorbed by the
174
+ * retry and never flip this; `isDegraded()` only goes true once failures
175
+ * persist for the configured number of consecutive rounds (sp00ky core config
176
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
177
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
178
+ */
179
+ function useSyncStatus() {
180
+ const db = useDb();
181
+ const [health, setHealth] = createSignal(db.syncHealth);
182
+ onCleanup(db.subscribeToSyncHealth(setHealth));
183
+ return {
184
+ health,
185
+ status: () => health().status,
186
+ isHealthy: () => health().status === "healthy",
187
+ isDegraded: () => health().status === "degraded",
188
+ everConnected: () => health().everConnected,
189
+ isOffline: () => health().status === "degraded" && health().everConnected,
190
+ connection: () => health().connection,
191
+ isReconnecting: () => health().connection === "reconnecting"
192
+ };
193
+ }
194
+
195
+ //#endregion
196
+ //#region src/lib/use-storage-status.ts
197
+ /**
198
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
199
+ *
200
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
201
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
202
+ * second tab of the same app cannot get it and runs in memory instead (the
203
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
204
+ * used within a `<Sp00kyProvider>`.
205
+ */
206
+ function useStorageStatus() {
207
+ const db = useDb();
208
+ const [health, setHealth] = createSignal(db.storageHealth);
209
+ onCleanup(db.subscribeToStorageHealth(setHealth));
210
+ return {
211
+ health,
212
+ status: () => health().status,
213
+ isPersistent: () => health().status === "persistent",
214
+ isMemoryFallback: () => health().fallback
215
+ };
216
+ }
217
+
218
+ //#endregion
219
+ //#region src/lib/use-crdt-field.ts
220
+ function useCrdtField(table, recordId, field, fallbackText) {
221
+ const db = useContext(Sp00kyContext);
222
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
223
+ const [crdtField, setCrdtField] = createSignal(null);
224
+ let currentId;
225
+ let initialized = false;
226
+ createEffect(() => {
227
+ const id = recordId();
228
+ if (initialized && id === currentId) return;
229
+ if (currentId && crdtField()) {
230
+ db.getSp00ky().closeCrdtField(table, currentId, field);
231
+ setCrdtField(null);
232
+ }
233
+ currentId = id;
234
+ initialized = true;
235
+ if (!id) return;
236
+ const sp00ky = db.getSp00ky();
237
+ const text = fallbackText?.();
238
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
239
+ if (currentId === id) setCrdtField(cf);
240
+ }).catch((err) => {
241
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
242
+ });
243
+ });
244
+ onCleanup(() => {
245
+ if (currentId && crdtField()) {
246
+ db.getSp00ky().closeCrdtField(table, currentId, field);
247
+ setCrdtField(null);
248
+ }
249
+ });
250
+ return crdtField;
251
+ }
252
+
253
+ //#endregion
254
+ //#region src/lib/use-feature-flag.ts
255
+ /**
256
+ * Subscribe to a feature flag for the currently authenticated user.
257
+ *
258
+ * Returns three Solid accessors that update reactively whenever the
259
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
260
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
261
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
262
+ *
263
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
264
+ * For multi-variant flags, prefer `variant()` directly.
265
+ */
266
+ function useFeatureFlag(key, options) {
267
+ const handle = useDb().getSp00ky().feature(key, options);
268
+ const [variant, setVariant] = createSignal(handle.variant());
269
+ const [payload, setPayload] = createSignal(handle.payload());
270
+ const unsub = handle.subscribe((s) => {
271
+ setVariant(s.variant ?? options?.fallback);
272
+ setPayload(s.payload);
273
+ });
274
+ onCleanup(() => {
275
+ unsub();
276
+ handle.close();
277
+ });
278
+ return {
279
+ variant,
280
+ payload,
281
+ enabled: () => {
282
+ const v = variant();
283
+ return v !== void 0 && v !== "off";
284
+ }
285
+ };
286
+ }
287
+
288
+ //#endregion
289
+ //#region src/lib/use-app-release.ts
290
+ async function reloadForSnapshot(snapshot) {
291
+ if (typeof window === "undefined") return;
292
+ if (snapshot.cacheBust) try {
293
+ if (window.caches) {
294
+ const keys = await window.caches.keys();
295
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
296
+ }
297
+ if (navigator.serviceWorker) {
298
+ const regs = await navigator.serviceWorker.getRegistrations();
299
+ for (const r of regs) r.update().catch(() => {});
300
+ }
301
+ window.location.href = window.location.pathname + "?cb=" + Date.now();
302
+ return;
303
+ } catch {}
304
+ window.location.reload();
305
+ }
306
+ /**
307
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
308
+ * `spky deploy` / `spky release`) and compare it against the running build.
309
+ *
310
+ * Typical use: mount a small "new version available — Reload" notification
311
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
312
+ * (guard the auto path against reload loops with a per-version marker, since
313
+ * a client can reload while the deploy is still rolling out and land on the
314
+ * old bundle again).
315
+ */
316
+ function useAppRelease(options) {
317
+ const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
318
+ const [snapshot, setSnapshot] = createSignal(handle.snapshot());
319
+ const unsub = handle.subscribe(setSnapshot);
320
+ onCleanup(() => {
321
+ unsub();
322
+ handle.close();
323
+ });
324
+ const updateAvailable = () => semverGt(snapshot().version, options.currentVersion);
325
+ return {
326
+ latestVersion: () => snapshot().version,
327
+ updateAvailable,
328
+ mandatory: () => updateAvailable() && snapshot().mandatory,
329
+ cacheBust: () => snapshot().cacheBust,
330
+ reload: () => reloadForSnapshot(snapshot())
72
331
  };
73
332
  }
74
333
 
@@ -94,7 +353,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
94
353
  const validate = (file) => {
95
354
  const config = db.getBucketConfig(bucketName);
96
355
  if (!config) return;
97
- if (config.maxSize != null && file.size > config.maxSize) {
356
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
98
357
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
99
358
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
100
359
  }
@@ -167,20 +426,6 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
167
426
 
168
427
  //#endregion
169
428
  //#region src/lib/use-download-file.ts
170
- const downloadCache = /* @__PURE__ */ new Map();
171
- const inflightRequests = /* @__PURE__ */ new Map();
172
- function cacheKey(bucket, path) {
173
- return `${bucket}:${path}`;
174
- }
175
- function releaseEntry(key) {
176
- const entry = downloadCache.get(key);
177
- if (!entry) return;
178
- entry.refCount--;
179
- if (entry.refCount <= 0) {
180
- URL.revokeObjectURL(entry.url);
181
- downloadCache.delete(key);
182
- }
183
- }
184
429
  function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
185
430
  let db;
186
431
  let bucketName;
@@ -201,63 +446,15 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
201
446
  const [url, setUrl] = createSignal(null);
202
447
  const [isLoading, setIsLoading] = createSignal(false);
203
448
  const [error, setError] = createSignal(null);
204
- let currentKey = null;
449
+ let lease = null;
205
450
  let privateUrl = null;
206
- let refetchTrigger;
207
451
  const [refetchSignal, setRefetchSignal] = createSignal(0);
208
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
209
- async function doDownload(key, filePath) {
210
- if (useCache) {
211
- const cached = downloadCache.get(key);
212
- if (cached) {
213
- cached.refCount++;
214
- currentKey = key;
215
- return cached.url;
216
- }
217
- const inflight = inflightRequests.get(key);
218
- if (inflight) {
219
- const result = await inflight;
220
- if (result) {
221
- const entry = downloadCache.get(key);
222
- if (entry) {
223
- entry.refCount++;
224
- currentKey = key;
225
- }
226
- }
227
- return result;
228
- }
229
- const promise = (async () => {
230
- const content = await db.bucket(bucketName).get(filePath);
231
- if (!content) return null;
232
- const objectUrl = URL.createObjectURL(new Blob([content]));
233
- downloadCache.set(key, {
234
- url: objectUrl,
235
- refCount: 1
236
- });
237
- return objectUrl;
238
- })();
239
- inflightRequests.set(key, promise);
240
- try {
241
- const result = await promise;
242
- currentKey = key;
243
- return result;
244
- } finally {
245
- inflightRequests.delete(key);
246
- }
247
- } else {
248
- const content = await db.bucket(bucketName).get(filePath);
249
- if (!content) return null;
250
- const objectUrl = URL.createObjectURL(new Blob([content]));
251
- privateUrl = objectUrl;
252
- return objectUrl;
253
- }
254
- }
255
- function releaseCurrentEntry() {
256
- if (useCache && currentKey) {
257
- releaseEntry(currentKey);
258
- currentKey = null;
259
- }
260
- if (!useCache && privateUrl) {
452
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
453
+ let reloadOnce = false;
454
+ function releaseCurrent() {
455
+ lease?.release();
456
+ lease = null;
457
+ if (privateUrl) {
261
458
  URL.revokeObjectURL(privateUrl);
262
459
  privateUrl = null;
263
460
  }
@@ -265,29 +462,40 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
265
462
  createEffect(() => {
266
463
  const filePath = path();
267
464
  refetchSignal();
268
- releaseCurrentEntry();
465
+ releaseCurrent();
269
466
  if (!filePath) {
270
467
  setUrl(null);
271
468
  setIsLoading(false);
272
469
  setError(null);
273
470
  return;
274
471
  }
275
- const key = cacheKey(bucketName, filePath);
276
- if (useCache) {
277
- const cached = downloadCache.get(key);
278
- if (cached) {
279
- cached.refCount++;
280
- currentKey = key;
281
- setUrl(cached.url);
282
- setIsLoading(false);
283
- setError(null);
284
- return;
285
- }
286
- }
472
+ const reload = reloadOnce;
473
+ reloadOnce = false;
287
474
  let cancelled = false;
288
475
  setIsLoading(true);
289
476
  setError(null);
290
- doDownload(key, filePath).then((result) => {
477
+ const bucket = db.bucket(bucketName);
478
+ (useCache ? bucket.url(filePath, {
479
+ persist: options.persist !== false,
480
+ pin: options.pin,
481
+ revalidate: options.revalidate,
482
+ reload
483
+ }).then((acquired) => {
484
+ if (!acquired) return null;
485
+ if (cancelled) {
486
+ acquired.release();
487
+ return null;
488
+ }
489
+ lease = acquired;
490
+ return acquired.url;
491
+ }) : bucket.read(filePath, {
492
+ persist: false,
493
+ reload: true
494
+ }).then((blob) => {
495
+ if (!blob || cancelled) return null;
496
+ privateUrl = URL.createObjectURL(blob);
497
+ return privateUrl;
498
+ })).then((result) => {
291
499
  if (!cancelled) {
292
500
  setUrl(result);
293
501
  setIsLoading(false);
@@ -303,18 +511,11 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
303
511
  });
304
512
  });
305
513
  onCleanup(() => {
306
- releaseCurrentEntry();
514
+ releaseCurrent();
307
515
  });
308
516
  const refetch = () => {
309
- if (useCache && currentKey) {
310
- const entry = downloadCache.get(currentKey);
311
- if (entry) {
312
- URL.revokeObjectURL(entry.url);
313
- downloadCache.delete(currentKey);
314
- }
315
- currentKey = null;
316
- }
317
- refetchTrigger();
517
+ reloadOnce = true;
518
+ setRefetchSignal((n) => n + 1);
318
519
  };
319
520
  return {
320
521
  url,
@@ -325,26 +526,39 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
325
526
  }
326
527
 
327
528
  //#endregion
328
- //#region src/lib/SpookyProvider.ts
329
- function SpookyProvider(props) {
529
+ //#region src/lib/Sp00kyProvider.ts
530
+ function Sp00kyProvider(props) {
330
531
  const merged = mergeProps({ fallback: void 0 }, props);
331
532
  const [db, setDb] = createSignal(void 0);
533
+ let disposed = false;
534
+ onCleanup(() => {
535
+ disposed = true;
536
+ });
332
537
  onMount(async () => {
333
538
  try {
334
539
  const instance = new SyncedDb(merged.config);
335
540
  await instance.init();
541
+ if (disposed) {
542
+ await instance.close();
543
+ return;
544
+ }
545
+ if (merged.preload) try {
546
+ await merged.preload(instance);
547
+ } catch (e) {
548
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
549
+ }
336
550
  setDb(() => instance);
337
551
  merged.onReady?.(instance);
338
552
  } catch (e) {
339
553
  const error = e instanceof Error ? e : new Error(String(e));
340
554
  if (merged.onError) merged.onError(error);
341
- else console.error("SpookyProvider: Failed to initialize database", error);
555
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
342
556
  }
343
557
  });
344
558
  return createMemo(() => {
345
559
  const instance = db();
346
560
  if (!instance) return merged.fallback;
347
- return createComponent(SpookyContext.Provider, {
561
+ return createComponent(Sp00kyContext.Provider, {
348
562
  value: instance,
349
563
  get children() {
350
564
  return merged.children;
@@ -356,69 +570,94 @@ function SpookyProvider(props) {
356
570
  //#endregion
357
571
  //#region src/index.ts
358
572
  /**
359
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
360
- * Delegates all logic to the underlying spooky-ts instance
573
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
574
+ * Delegates all logic to the underlying sp00ky-ts instance
361
575
  */
362
576
  var SyncedDb = class {
363
577
  constructor(config) {
364
- this.spooky = null;
578
+ this.sp00ky = null;
365
579
  this._initialized = false;
366
580
  this.config = config;
367
581
  }
368
- getSpooky() {
369
- if (!this.spooky) throw new Error("SyncedDb not initialized");
370
- return this.spooky;
582
+ getSp00ky() {
583
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
584
+ return this.sp00ky;
371
585
  }
372
586
  /**
373
- * Initialize the spooky-ts instance
587
+ * Initialize the sp00ky-ts instance
374
588
  */
375
589
  async init() {
376
590
  if (this._initialized) return;
377
- this.spooky = new SpookyClient(this.config);
378
- await this.spooky.init();
591
+ this.sp00ky = new Sp00kyClient(this.config);
592
+ await this.sp00ky.init();
379
593
  this._initialized = true;
380
594
  }
381
595
  /**
596
+ * Tear down the client: leaves the tabs broker, closes the local store and
597
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
598
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
599
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
600
+ */
601
+ async close() {
602
+ const instance = this.sp00ky;
603
+ this.sp00ky = null;
604
+ this._initialized = false;
605
+ if (instance) await instance.close();
606
+ }
607
+ /**
382
608
  * Create a new record in the database
383
609
  */
384
610
  async create(id, payload) {
385
- if (!this.spooky) throw new Error("SyncedDb not initialized");
386
- await this.spooky.create(id, payload);
611
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
612
+ await this.sp00ky.create(id, payload);
387
613
  }
388
614
  /**
389
615
  * Update an existing record in the database
390
616
  */
391
617
  async update(tableName, recordId, payload, options) {
392
- if (!this.spooky) throw new Error("SyncedDb not initialized");
393
- await this.spooky.update(tableName, recordId, payload, options);
618
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
619
+ await this.sp00ky.update(tableName, recordId, payload, options);
394
620
  }
395
621
  /**
396
622
  * Delete an existing record in the database
397
623
  */
398
624
  async delete(tableName, selector) {
399
- if (!this.spooky) throw new Error("SyncedDb not initialized");
400
- if (typeof selector !== "string") throw new Error("Only string ID selectors are supported currently with core");
401
- await this.spooky.delete(tableName, selector);
625
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
626
+ const isRecordId = selector instanceof RecordId || selector?.constructor?.name === "RecordId";
627
+ let id;
628
+ if (typeof selector === "string") id = selector;
629
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
630
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
631
+ await this.sp00ky.delete(tableName, id);
632
+ }
633
+ /**
634
+ * Preload/prewarm a built query into the local cache without registering a
635
+ * live view. Fetches once and stores the rows (+ embedded related children)
636
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
637
+ */
638
+ async preload(finalQuery, options) {
639
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
640
+ await this.sp00ky.preload(finalQuery, options);
402
641
  }
403
642
  /**
404
643
  * Query data from the database
405
644
  */
406
645
  query(table) {
407
- if (!this.spooky) throw new Error("SyncedDb not initialized");
408
- return this.spooky.query(table, {});
646
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
647
+ return this.sp00ky.query(table, {});
409
648
  }
410
649
  /**
411
650
  * Run a backend operation
412
651
  */
413
652
  async run(backend, path, payload, options) {
414
- if (!this.spooky) throw new Error("SyncedDb not initialized");
415
- await this.spooky.run(backend, path, payload, options);
653
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
654
+ await this.sp00ky.run(backend, path, payload, options);
416
655
  }
417
656
  /**
418
657
  * Authenticate with the database
419
658
  */
420
659
  async authenticate(token) {
421
- await this.spooky?.authenticate(token);
660
+ await this.sp00ky?.authenticate(token);
422
661
  return new RecordId("user", "me");
423
662
  }
424
663
  /**
@@ -432,48 +671,81 @@ var SyncedDb = class {
432
671
  * Sign out, clear session and local storage
433
672
  */
434
673
  async signOut() {
435
- if (!this.spooky) throw new Error("SyncedDb not initialized");
436
- await this.spooky.auth.signOut();
674
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
675
+ await this.sp00ky.auth.signOut();
437
676
  }
438
677
  /**
439
678
  * Execute a function with direct access to the remote database connection
440
679
  */
441
680
  async useRemote(fn) {
442
- if (!this.spooky) throw new Error("SyncedDb not initialized");
443
- return await this.spooky.useRemote(fn);
681
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
682
+ return await this.sp00ky.useRemote(fn);
444
683
  }
445
684
  /**
446
685
  * Access the remote database service directly
447
686
  */
448
687
  get remote() {
449
- if (!this.spooky) throw new Error("SyncedDb not initialized");
450
- return this.spooky.remoteClient;
688
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
689
+ return this.sp00ky.remoteClient;
451
690
  }
452
691
  /**
453
692
  * Access the local database service directly
454
693
  */
455
694
  get local() {
456
- if (!this.spooky) throw new Error("SyncedDb not initialized");
457
- return this.spooky.localClient;
695
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
696
+ return this.sp00ky.localClient;
458
697
  }
459
698
  /**
460
699
  * Access the auth service
461
700
  */
462
701
  get auth() {
463
- if (!this.spooky) throw new Error("SyncedDb not initialized");
464
- return this.spooky.auth;
702
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
703
+ return this.sp00ky.auth;
465
704
  }
466
705
  get pendingMutationCount() {
467
- if (!this.spooky) throw new Error("SyncedDb not initialized");
468
- return this.spooky.pendingMutationCount;
706
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
707
+ return this.sp00ky.pendingMutationCount;
708
+ }
709
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
710
+ get liveRetryCount() {
711
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
712
+ return this.sp00ky.liveRetryCount;
469
713
  }
470
714
  subscribeToPendingMutations(cb) {
471
- if (!this.spooky) throw new Error("SyncedDb not initialized");
472
- return this.spooky.subscribeToPendingMutations(cb);
715
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
716
+ return this.sp00ky.subscribeToPendingMutations(cb);
717
+ }
718
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
719
+ get syncHealth() {
720
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
721
+ return this.sp00ky.syncHealth;
722
+ }
723
+ /**
724
+ * Observe sync health. Fires immediately with the current status and again
725
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
726
+ * components; this is the imperative escape hatch.
727
+ */
728
+ subscribeToSyncHealth(cb) {
729
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
730
+ return this.sp00ky.subscribeToSyncHealth(cb);
731
+ }
732
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
733
+ get storageHealth() {
734
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
735
+ return this.sp00ky.storageHealth;
736
+ }
737
+ /**
738
+ * Observe local-store durability. Fires immediately with the current snapshot
739
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
740
+ * is the imperative escape hatch.
741
+ */
742
+ subscribeToStorageHealth(cb) {
743
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
744
+ return this.sp00ky.subscribeToStorageHealth(cb);
473
745
  }
474
746
  bucket(name) {
475
- if (!this.spooky) throw new Error("SyncedDb not initialized");
476
- return this.spooky.bucket(name);
747
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
748
+ return this.sp00ky.bucket(name);
477
749
  }
478
750
  getBucketConfig(name) {
479
751
  return this.config.schema.buckets?.find((b) => b.name === name);
@@ -481,5 +753,5 @@ var SyncedDb = class {
481
753
  };
482
754
 
483
755
  //#endregion
484
- export { RecordId, SpookyProvider, SyncedDb, Uuid, useDb, useDownloadFile, useFileUpload, useQuery };
756
+ export { RecordId, Sp00kyProvider, SyncedDb, Uuid, createPreload, useAppRelease, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, useQuery, useStorageStatus, useSyncStatus };
485
757
  //# sourceMappingURL=index.js.map