@spooky-sync/client-solid 0.0.1-canary.17 → 0.0.1-canary.170

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