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