@spooky-sync/client-solid 0.0.0-canary.1 → 0.0.1-alpha.1

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,27 +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
- const unsub = await spooky.subscribe(hash, (e) => {
41
- const data = query.isOne ? e[0] : e;
42
- setData(() => data);
43
- setIsFetched(true);
72
+ let isFirstCall = true;
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;
80
+ if (!isFirstCall || hasData) setIsFetched(true);
81
+ isFirstCall = false;
44
82
  }, { immediate: true });
45
- 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;
46
93
  };
47
94
  (0, solid_js.createEffect)(() => {
48
95
  if (!(options?.enabled?.() ?? true)) {
@@ -51,46 +98,678 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
51
98
  }
52
99
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
53
100
  if (!query) return;
54
- const queryString = JSON.stringify(query);
101
+ const queryString = String(query.hash);
55
102
  if (queryString === prevQueryString) return;
56
103
  prevQueryString = queryString;
104
+ const myRun = ++runId;
105
+ teardownActive();
57
106
  setIsFetched(false);
58
- initQuery(query);
59
- (0, solid_js.onCleanup)(() => {
60
- unsubscribe?.();
61
- });
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);
62
114
  });
63
115
  const isLoading = () => {
64
116
  return !isFetched() && error() === void 0;
65
117
  };
118
+ const isSettled = () => isFetched() && !isFetching();
66
119
  return {
67
120
  data,
68
121
  error,
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 registers it via `db.preload`: a live query nobody subscribes to,
134
+ * evicted a ttl after it was registered unless a view mounts it first. Nothing
135
+ * to clean up here.
136
+ *
137
+ * Typical use: inside a list row, preload the detail query the user is likely
138
+ * to open next, so navigation paints from cache instead of the network.
139
+ */
140
+ function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
141
+ let db;
142
+ let finalQuery;
143
+ let options;
144
+ if (dbOrQuery instanceof SyncedDb) {
145
+ db = dbOrQuery;
146
+ finalQuery = queryOrOptions;
147
+ options = maybeOptions;
148
+ } else {
149
+ const contextDb = (0, solid_js.useContext)(Sp00kyContext);
150
+ 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>.");
151
+ db = contextDb;
152
+ finalQuery = dbOrQuery;
153
+ options = queryOrOptions;
154
+ }
155
+ let prevHash;
156
+ (0, solid_js.createEffect)(() => {
157
+ if (!(options?.enabled?.() ?? true)) return;
158
+ const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
159
+ if (!query) return;
160
+ if (query.hash === prevHash) return;
161
+ prevHash = query.hash;
162
+ db.getSp00ky().preload(query, { signal: options?.signal });
163
+ });
164
+ }
165
+
166
+ //#endregion
167
+ //#region src/lib/use-sync-status.ts
168
+ /**
169
+ * Observe sync health for a "can't reach the server" banner / indicator.
170
+ *
171
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
172
+ * remote 500 on query registration, a dropped socket) are absorbed by the
173
+ * retry and never flip this; `isDegraded()` only goes true once failures
174
+ * persist for the configured number of consecutive rounds (sp00ky core config
175
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
176
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
177
+ */
178
+ function useSyncStatus() {
179
+ const db = useDb();
180
+ const [health, setHealth] = (0, solid_js.createSignal)(db.syncHealth);
181
+ (0, solid_js.onCleanup)(db.subscribeToSyncHealth(setHealth));
182
+ return {
183
+ health,
184
+ status: () => health().status,
185
+ isHealthy: () => health().status === "healthy",
186
+ isDegraded: () => health().status === "degraded",
187
+ everConnected: () => health().everConnected,
188
+ isOffline: () => health().status === "degraded" && health().everConnected,
189
+ connection: () => health().connection,
190
+ isReconnecting: () => health().connection === "reconnecting"
191
+ };
192
+ }
193
+
194
+ //#endregion
195
+ //#region src/lib/use-storage-status.ts
196
+ /**
197
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
198
+ *
199
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
200
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
201
+ * second tab of the same app cannot get it and runs in memory instead (the
202
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
203
+ * used within a `<Sp00kyProvider>`.
204
+ */
205
+ function useStorageStatus() {
206
+ const db = useDb();
207
+ const [health, setHealth] = (0, solid_js.createSignal)(db.storageHealth);
208
+ (0, solid_js.onCleanup)(db.subscribeToStorageHealth(setHealth));
209
+ return {
210
+ health,
211
+ status: () => health().status,
212
+ isPersistent: () => health().status === "persistent",
213
+ isMemoryFallback: () => health().fallback
214
+ };
215
+ }
216
+
217
+ //#endregion
218
+ //#region src/lib/use-crdt-field.ts
219
+ function useCrdtField(table, recordId, field, fallbackText) {
220
+ const db = (0, solid_js.useContext)(Sp00kyContext);
221
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
222
+ const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
223
+ let currentId;
224
+ let initialized = false;
225
+ (0, solid_js.createEffect)(() => {
226
+ const id = recordId();
227
+ if (initialized && id === currentId) return;
228
+ if (currentId && crdtField()) {
229
+ db.getSp00ky().closeCrdtField(table, currentId, field);
230
+ setCrdtField(null);
231
+ }
232
+ currentId = id;
233
+ initialized = true;
234
+ if (!id) return;
235
+ const sp00ky = db.getSp00ky();
236
+ const text = fallbackText?.();
237
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
238
+ if (currentId === id) setCrdtField(cf);
239
+ }).catch((err) => {
240
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
241
+ });
242
+ });
243
+ (0, solid_js.onCleanup)(() => {
244
+ if (currentId && crdtField()) {
245
+ db.getSp00ky().closeCrdtField(table, currentId, field);
246
+ setCrdtField(null);
247
+ }
248
+ });
249
+ return crdtField;
250
+ }
251
+
252
+ //#endregion
253
+ //#region src/lib/use-feature-flag.ts
254
+ /**
255
+ * Subscribe to a feature flag for the currently authenticated user.
256
+ *
257
+ * Returns three Solid accessors that update reactively whenever the
258
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
259
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
260
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
261
+ *
262
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
263
+ * For multi-variant flags, prefer `variant()` directly.
264
+ */
265
+ function useFeatureFlag(key, options) {
266
+ const handle = useDb().getSp00ky().feature(key, options);
267
+ const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
268
+ const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
269
+ const unsub = handle.subscribe((s) => {
270
+ setVariant(s.variant ?? options?.fallback);
271
+ setPayload(s.payload);
272
+ });
273
+ (0, solid_js.onCleanup)(() => {
274
+ unsub();
275
+ handle.close();
276
+ });
277
+ return {
278
+ variant,
279
+ payload,
280
+ enabled: () => {
281
+ const v = variant();
282
+ return v !== void 0 && v !== "off";
283
+ }
284
+ };
285
+ }
286
+
287
+ //#endregion
288
+ //#region src/lib/use-app-release.ts
289
+ async function reloadForSnapshot(snapshot) {
290
+ if (typeof window === "undefined") return;
291
+ if (snapshot.cacheBust) try {
292
+ if (window.caches) {
293
+ const keys = await window.caches.keys();
294
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
295
+ }
296
+ if (navigator.serviceWorker) {
297
+ const regs = await navigator.serviceWorker.getRegistrations();
298
+ for (const r of regs) r.update().catch(() => {});
299
+ }
300
+ window.location.href = window.location.pathname + "?cb=" + Date.now();
301
+ return;
302
+ } catch {}
303
+ window.location.reload();
304
+ }
305
+ /**
306
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
307
+ * `spky deploy` / `spky release`) and compare it against the running build.
308
+ *
309
+ * Typical use: mount a small "new version available — Reload" notification
310
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
311
+ * (guard the auto path against reload loops with a per-version marker, since
312
+ * a client can reload while the deploy is still rolling out and land on the
313
+ * old bundle again).
314
+ */
315
+ function useAppRelease(options) {
316
+ const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
317
+ const [snapshot, setSnapshot] = (0, solid_js.createSignal)(handle.snapshot());
318
+ const unsub = handle.subscribe(setSnapshot);
319
+ (0, solid_js.onCleanup)(() => {
320
+ unsub();
321
+ handle.close();
322
+ });
323
+ const updateAvailable = () => (0, _spooky_sync_core.semverGt)(snapshot().version, options.currentVersion);
324
+ return {
325
+ latestVersion: () => snapshot().version,
326
+ updateAvailable,
327
+ mandatory: () => updateAvailable() && snapshot().mandatory,
328
+ cacheBust: () => snapshot().cacheBust,
329
+ reload: () => reloadForSnapshot(snapshot())
330
+ };
331
+ }
332
+
333
+ //#endregion
334
+ //#region src/lib/use-file-upload.ts
335
+ function useFileUpload(dbOrBucketName, maybeBucketName) {
336
+ let db;
337
+ let bucketName;
338
+ if (typeof dbOrBucketName === "string") {
339
+ db = useDb();
340
+ bucketName = dbOrBucketName;
341
+ } else {
342
+ db = dbOrBucketName;
343
+ bucketName = maybeBucketName;
344
+ }
345
+ const [isUploading, setIsUploading] = (0, solid_js.createSignal)(false);
346
+ const [error, setError] = (0, solid_js.createSignal)(null);
347
+ const objectUrls = [];
348
+ (0, solid_js.onCleanup)(() => {
349
+ for (const url of objectUrls) URL.revokeObjectURL(url);
350
+ });
351
+ const clearError = () => setError(null);
352
+ const validate = (file) => {
353
+ const config = db.getBucketConfig(bucketName);
354
+ if (!config) return;
355
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
356
+ const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
357
+ throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
358
+ }
359
+ if (config.allowedExtensions && config.allowedExtensions.length > 0) {
360
+ const fileName = file.name;
361
+ if (fileName) {
362
+ const ext = fileName.split(".").pop()?.toLowerCase();
363
+ if (!ext || !config.allowedExtensions.includes(ext)) throw new Error(`File type not allowed. Accepted: ${config.allowedExtensions.join(", ")}.`);
364
+ }
365
+ }
366
+ };
367
+ const upload = async (path, file, options) => {
368
+ setError(null);
369
+ try {
370
+ validate(file);
371
+ } catch (e) {
372
+ setError(e instanceof Error ? e : new Error(String(e)));
373
+ return;
374
+ }
375
+ setIsUploading(true);
376
+ try {
377
+ const bytes = await (0, _spooky_sync_core.fileToUint8Array)(file);
378
+ return await db.bucket(bucketName).put(path, bytes, options);
379
+ } catch (e) {
380
+ setError(e instanceof Error ? e : new Error(String(e)));
381
+ } finally {
382
+ setIsUploading(false);
383
+ }
384
+ };
385
+ const download = async (path) => {
386
+ setError(null);
387
+ try {
388
+ const content = await db.bucket(bucketName).get(path);
389
+ if (!content) return null;
390
+ const objectUrl = URL.createObjectURL(new Blob([content]));
391
+ objectUrls.push(objectUrl);
392
+ return objectUrl;
393
+ } catch (e) {
394
+ setError(e instanceof Error ? e : new Error(String(e)));
395
+ return null;
396
+ }
397
+ };
398
+ const remove = async (path) => {
399
+ setError(null);
400
+ try {
401
+ await db.bucket(bucketName).delete(path);
402
+ } catch (e) {
403
+ setError(e instanceof Error ? e : new Error(String(e)));
404
+ }
405
+ };
406
+ const exists = async (path) => {
407
+ setError(null);
408
+ try {
409
+ return await db.bucket(bucketName).exists(path);
410
+ } catch (e) {
411
+ setError(e instanceof Error ? e : new Error(String(e)));
412
+ return false;
413
+ }
414
+ };
415
+ return {
416
+ isUploading,
417
+ error,
418
+ clearError,
419
+ upload,
420
+ download,
421
+ remove,
422
+ exists
423
+ };
424
+ }
425
+
426
+ //#endregion
427
+ //#region src/lib/use-download-file.ts
428
+ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
429
+ let db;
430
+ let bucketName;
431
+ let path;
432
+ let options;
433
+ if (typeof dbOrBucketName === "string") {
434
+ db = useDb();
435
+ bucketName = dbOrBucketName;
436
+ path = bucketNameOrPath;
437
+ options = pathOrOptions ?? {};
438
+ } else {
439
+ db = dbOrBucketName;
440
+ bucketName = bucketNameOrPath;
441
+ path = pathOrOptions;
442
+ options = maybeOptions ?? {};
443
+ }
444
+ const useCache = options.cache !== false;
445
+ const [url, setUrl] = (0, solid_js.createSignal)(null);
446
+ const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
447
+ const [error, setError] = (0, solid_js.createSignal)(null);
448
+ let lease = null;
449
+ let privateUrl = null;
450
+ const [refetchSignal, setRefetchSignal] = (0, solid_js.createSignal)(0);
451
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
452
+ let reloadOnce = false;
453
+ function releaseCurrent() {
454
+ lease?.release();
455
+ lease = null;
456
+ if (privateUrl) {
457
+ URL.revokeObjectURL(privateUrl);
458
+ privateUrl = null;
459
+ }
460
+ }
461
+ (0, solid_js.createEffect)(() => {
462
+ const filePath = path();
463
+ refetchSignal();
464
+ releaseCurrent();
465
+ if (!filePath) {
466
+ setUrl(null);
467
+ setIsLoading(false);
468
+ setError(null);
469
+ return;
470
+ }
471
+ const reload = reloadOnce;
472
+ reloadOnce = false;
473
+ let cancelled = false;
474
+ setIsLoading(true);
475
+ setError(null);
476
+ const bucket = db.bucket(bucketName);
477
+ (useCache ? bucket.url(filePath, {
478
+ persist: options.persist !== false,
479
+ pin: options.pin,
480
+ revalidate: options.revalidate,
481
+ reload
482
+ }).then((acquired) => {
483
+ if (!acquired) return null;
484
+ if (cancelled) {
485
+ acquired.release();
486
+ return null;
487
+ }
488
+ lease = acquired;
489
+ return acquired.url;
490
+ }) : bucket.read(filePath, {
491
+ persist: false,
492
+ reload: true
493
+ }).then((blob) => {
494
+ if (!blob || cancelled) return null;
495
+ privateUrl = URL.createObjectURL(blob);
496
+ return privateUrl;
497
+ })).then((result) => {
498
+ if (!cancelled) {
499
+ setUrl(result);
500
+ setIsLoading(false);
501
+ }
502
+ }, (err) => {
503
+ if (!cancelled) {
504
+ setError(err instanceof Error ? err : new Error(String(err)));
505
+ setIsLoading(false);
506
+ }
507
+ });
508
+ (0, solid_js.onCleanup)(() => {
509
+ cancelled = true;
510
+ });
511
+ });
512
+ (0, solid_js.onCleanup)(() => {
513
+ releaseCurrent();
514
+ });
515
+ const refetch = () => {
516
+ reloadOnce = true;
517
+ setRefetchSignal((n) => n + 1);
518
+ };
519
+ return {
520
+ url,
521
+ isLoading,
522
+ error,
523
+ refetch
524
+ };
525
+ }
526
+
527
+ //#endregion
528
+ //#region src/lib/use-blurhash.ts
529
+ function useBlurhash(dbOrBucketName, bucketNameOrPath, maybePath) {
530
+ let db;
531
+ let bucketName;
532
+ let path;
533
+ if (typeof dbOrBucketName === "string") {
534
+ db = useDb();
535
+ bucketName = dbOrBucketName;
536
+ path = bucketNameOrPath;
537
+ } else {
538
+ db = dbOrBucketName;
539
+ bucketName = bucketNameOrPath;
540
+ path = maybePath;
541
+ }
542
+ const [hash, setHash] = (0, solid_js.createSignal)(null);
543
+ const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
544
+ (0, solid_js.createEffect)(() => {
545
+ const filePath = path();
546
+ if (!filePath) {
547
+ setHash(null);
548
+ setIsLoading(false);
549
+ return;
550
+ }
551
+ let cancelled = false;
552
+ setIsLoading(true);
553
+ db.bucket(bucketName).blurhash(filePath).then((result) => {
554
+ if (cancelled) return;
555
+ setHash(result);
556
+ setIsLoading(false);
557
+ }).catch(() => {
558
+ if (cancelled) return;
559
+ setHash(null);
560
+ setIsLoading(false);
561
+ });
562
+ (0, solid_js.onCleanup)(() => {
563
+ cancelled = true;
564
+ });
565
+ });
566
+ return {
567
+ hash,
69
568
  isLoading
70
569
  };
71
570
  }
72
571
 
73
572
  //#endregion
74
- //#region src/lib/SpookyProvider.ts
75
- function SpookyProvider(props) {
573
+ //#region src/lib/use-bucket-image.ts
574
+ function useBucketImage(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
575
+ let db;
576
+ let bucketName;
577
+ let path;
578
+ let options;
579
+ if (typeof dbOrBucketName === "string") {
580
+ db = useDb();
581
+ bucketName = dbOrBucketName;
582
+ path = bucketNameOrPath;
583
+ options = pathOrOptions ?? {};
584
+ } else {
585
+ db = dbOrBucketName;
586
+ bucketName = bucketNameOrPath;
587
+ path = pathOrOptions;
588
+ options = maybeOptions ?? {};
589
+ }
590
+ const wantHash = options.blurhash !== false;
591
+ const { hash } = useBlurhash(db, bucketName, () => wantHash ? path() : null);
592
+ const file = useDownloadFile(db, bucketName, path, options);
593
+ const [ready, setReady] = (0, solid_js.createSignal)(false);
594
+ (0, solid_js.createEffect)((0, solid_js.on)(file.url, () => setReady(false), { defer: true }));
595
+ const gate = (img) => {
596
+ const done = () => setReady(true);
597
+ if (typeof img.decode === "function") img.decode().then(done, done);
598
+ else if (img.complete) done();
599
+ else {
600
+ img.onload = done;
601
+ img.onerror = done;
602
+ }
603
+ };
604
+ return {
605
+ ...file,
606
+ blurhash: hash,
607
+ ready,
608
+ gate
609
+ };
610
+ }
611
+
612
+ //#endregion
613
+ //#region src/lib/Blurhash.ts
614
+ /**
615
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
616
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
617
+ * resolution stays tiny regardless of the displayed size.
618
+ */
619
+ function Blurhash(props) {
620
+ if (typeof document === "undefined") return null;
621
+ const canvas = document.createElement("canvas");
622
+ (0, solid_js.createEffect)(() => {
623
+ canvas.className = props.class ?? "";
624
+ });
625
+ (0, solid_js.createEffect)(() => {
626
+ canvas.style.cssText = props.style ?? "";
627
+ });
628
+ (0, solid_js.createEffect)(() => {
629
+ const width = props.width ?? 32;
630
+ const height = props.height ?? 32;
631
+ canvas.width = width;
632
+ canvas.height = height;
633
+ const hash = props.hash;
634
+ if (!hash) return;
635
+ try {
636
+ const pixels = (0, _spooky_sync_core.decodeBlurhash)(hash, width, height, props.punch ?? 1);
637
+ const ctx = canvas.getContext("2d");
638
+ if (!ctx) return;
639
+ const imageData = ctx.createImageData(width, height);
640
+ imageData.data.set(pixels);
641
+ ctx.putImageData(imageData, 0, 0);
642
+ } catch {}
643
+ });
644
+ return canvas;
645
+ }
646
+
647
+ //#endregion
648
+ //#region src/lib/BucketImage.ts
649
+ const LAYER_STYLE = "position:absolute;inset:0;width:100%;height:100%;";
650
+ /**
651
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
652
+ * plate, the automatically stored blurhash, and the real image, which stays
653
+ * transparent until the bitmap is DECODED and then crossfades over the
654
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
655
+ * prefers-reduced-motion (instant swap). The container is made
656
+ * `position: relative` unless your `class` positions it already.
657
+ *
658
+ * ```tsx
659
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
660
+ * fallback={<MyPlate />} alt="" />
661
+ * ```
662
+ */
663
+ function BucketImage(props) {
664
+ if (typeof document === "undefined") return null;
665
+ const image = useBucketImage(props.bucket, () => props.path, {
666
+ ...props.options,
667
+ blurhash: props.blurhash !== false
668
+ });
669
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
670
+ const root = document.createElement("div");
671
+ (0, solid_js.createEffect)(() => {
672
+ root.className = props.class ?? "";
673
+ });
674
+ (0, solid_js.onMount)(() => {
675
+ if (getComputedStyle(root).position === "static") root.style.position = "relative";
676
+ });
677
+ const placeholder = document.createElement("div");
678
+ placeholder.style.cssText = LAYER_STYLE;
679
+ const fallbackHolder = document.createElement("div");
680
+ fallbackHolder.style.cssText = LAYER_STYLE;
681
+ placeholder.append(fallbackHolder);
682
+ const resolvedFallback = (0, solid_js.children)(() => props.fallback);
683
+ (0, solid_js.createEffect)(() => {
684
+ const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node);
685
+ fallbackHolder.replaceChildren(...nodes);
686
+ });
687
+ const hashCanvas = Blurhash({
688
+ get hash() {
689
+ return image.blurhash();
690
+ },
691
+ style: LAYER_STYLE
692
+ });
693
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
694
+ const img = document.createElement("img");
695
+ img.decoding = "async";
696
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
697
+ (0, solid_js.createEffect)(() => {
698
+ img.className = props.imgClass ?? "";
699
+ });
700
+ (0, solid_js.createEffect)(() => {
701
+ img.style.objectFit = props.fit ?? "cover";
702
+ });
703
+ (0, solid_js.createEffect)(() => {
704
+ img.alt = props.alt ?? "";
705
+ });
706
+ (0, solid_js.createEffect)(() => {
707
+ img.style.transition = reducedMotion ? "none" : `opacity ${props.transition ?? 300}ms ${props.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)"}`;
708
+ });
709
+ (0, solid_js.createEffect)(() => {
710
+ const url = image.url();
711
+ if (!url) {
712
+ img.removeAttribute("src");
713
+ return;
714
+ }
715
+ img.src = url;
716
+ image.gate(img);
717
+ });
718
+ (0, solid_js.createEffect)(() => {
719
+ img.style.opacity = image.ready() ? "1" : "0";
720
+ });
721
+ const [settled, setSettled] = (0, solid_js.createSignal)(false);
722
+ (0, solid_js.createEffect)(() => {
723
+ if (!image.ready()) {
724
+ setSettled(false);
725
+ return;
726
+ }
727
+ const wait = (reducedMotion ? 0 : props.transition ?? 300) + 120;
728
+ const timer = setTimeout(() => setSettled(true), wait);
729
+ (0, solid_js.onCleanup)(() => clearTimeout(timer));
730
+ });
731
+ (0, solid_js.createEffect)(() => {
732
+ if (settled()) placeholder.remove();
733
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
734
+ });
735
+ root.append(placeholder, img);
736
+ return root;
737
+ }
738
+
739
+ //#endregion
740
+ //#region src/lib/Sp00kyProvider.ts
741
+ function Sp00kyProvider(props) {
76
742
  const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
77
743
  const [db, setDb] = (0, solid_js.createSignal)(void 0);
744
+ let disposed = false;
745
+ (0, solid_js.onCleanup)(() => {
746
+ disposed = true;
747
+ });
78
748
  (0, solid_js.onMount)(async () => {
79
749
  try {
80
750
  const instance = new SyncedDb(merged.config);
81
751
  await instance.init();
752
+ if (disposed) {
753
+ await instance.close();
754
+ return;
755
+ }
756
+ if (merged.preload) try {
757
+ await merged.preload(instance);
758
+ } catch (e) {
759
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
760
+ }
82
761
  setDb(() => instance);
83
762
  merged.onReady?.(instance);
84
763
  } catch (e) {
85
764
  const error = e instanceof Error ? e : new Error(String(e));
86
765
  if (merged.onError) merged.onError(error);
87
- else console.error("SpookyProvider: Failed to initialize database", error);
766
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
88
767
  }
89
768
  });
90
769
  return (0, solid_js.createMemo)(() => {
91
770
  const instance = db();
92
771
  if (!instance) return merged.fallback;
93
- return (0, solid_js.createComponent)(SpookyContext.Provider, {
772
+ return (0, solid_js.createComponent)(Sp00kyContext.Provider, {
94
773
  value: instance,
95
774
  get children() {
96
775
  return merged.children;
@@ -102,69 +781,94 @@ function SpookyProvider(props) {
102
781
  //#endregion
103
782
  //#region src/index.ts
104
783
  /**
105
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
106
- * Delegates all logic to the underlying spooky-ts instance
784
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
785
+ * Delegates all logic to the underlying sp00ky-ts instance
107
786
  */
108
787
  var SyncedDb = class {
109
788
  constructor(config) {
110
- this.spooky = null;
789
+ this.sp00ky = null;
111
790
  this._initialized = false;
112
791
  this.config = config;
113
792
  }
114
- getSpooky() {
115
- if (!this.spooky) throw new Error("SyncedDb not initialized");
116
- return this.spooky;
793
+ getSp00ky() {
794
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
795
+ return this.sp00ky;
117
796
  }
118
797
  /**
119
- * Initialize the spooky-ts instance
798
+ * Initialize the sp00ky-ts instance
120
799
  */
121
800
  async init() {
122
801
  if (this._initialized) return;
123
- this.spooky = new _spooky_sync_core.SpookyClient(this.config);
124
- await this.spooky.init();
802
+ this.sp00ky = new _spooky_sync_core.Sp00kyClient(this.config);
803
+ await this.sp00ky.init();
125
804
  this._initialized = true;
126
805
  }
127
806
  /**
807
+ * Tear down the client: leaves the tabs broker, closes the local store and
808
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
809
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
810
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
811
+ */
812
+ async close() {
813
+ const instance = this.sp00ky;
814
+ this.sp00ky = null;
815
+ this._initialized = false;
816
+ if (instance) await instance.close();
817
+ }
818
+ /**
128
819
  * Create a new record in the database
129
820
  */
130
821
  async create(id, payload) {
131
- if (!this.spooky) throw new Error("SyncedDb not initialized");
132
- await this.spooky.create(id, payload);
822
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
823
+ await this.sp00ky.create(id, payload);
133
824
  }
134
825
  /**
135
826
  * Update an existing record in the database
136
827
  */
137
828
  async update(tableName, recordId, payload, options) {
138
- if (!this.spooky) throw new Error("SyncedDb not initialized");
139
- await this.spooky.update(tableName, recordId, payload, options);
829
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
830
+ await this.sp00ky.update(tableName, recordId, payload, options);
140
831
  }
141
832
  /**
142
833
  * Delete an existing record in the database
143
834
  */
144
835
  async delete(tableName, selector) {
145
- if (!this.spooky) throw new Error("SyncedDb not initialized");
146
- if (typeof selector !== "string") throw new Error("Only string ID selectors are supported currently with core");
147
- await this.spooky.delete(tableName, selector);
836
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
837
+ const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
838
+ let id;
839
+ if (typeof selector === "string") id = selector;
840
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
841
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
842
+ await this.sp00ky.delete(tableName, id);
843
+ }
844
+ /**
845
+ * Preload/prewarm a built query into the local cache without registering a
846
+ * live view. Fetches once and stores the rows (+ embedded related children)
847
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
848
+ */
849
+ async preload(finalQuery, options) {
850
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
851
+ await this.sp00ky.preload(finalQuery, options);
148
852
  }
149
853
  /**
150
854
  * Query data from the database
151
855
  */
152
856
  query(table) {
153
- if (!this.spooky) throw new Error("SyncedDb not initialized");
154
- return this.spooky.query(table, {});
857
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
858
+ return this.sp00ky.query(table, {});
155
859
  }
156
860
  /**
157
861
  * Run a backend operation
158
862
  */
159
863
  async run(backend, path, payload, options) {
160
- if (!this.spooky) throw new Error("SyncedDb not initialized");
161
- await this.spooky.run(backend, path, payload, options);
864
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
865
+ await this.sp00ky.run(backend, path, payload, options);
162
866
  }
163
867
  /**
164
868
  * Authenticate with the database
165
869
  */
166
870
  async authenticate(token) {
167
- await this.spooky?.authenticate(token);
871
+ await this.sp00ky?.authenticate(token);
168
872
  return new surrealdb.RecordId("user", "me");
169
873
  }
170
874
  /**
@@ -178,52 +882,104 @@ var SyncedDb = class {
178
882
  * Sign out, clear session and local storage
179
883
  */
180
884
  async signOut() {
181
- if (!this.spooky) throw new Error("SyncedDb not initialized");
182
- await this.spooky.auth.signOut();
885
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
886
+ await this.sp00ky.auth.signOut();
183
887
  }
184
888
  /**
185
889
  * Execute a function with direct access to the remote database connection
186
890
  */
187
891
  async useRemote(fn) {
188
- if (!this.spooky) throw new Error("SyncedDb not initialized");
189
- return await this.spooky.useRemote(fn);
892
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
893
+ return await this.sp00ky.useRemote(fn);
190
894
  }
191
895
  /**
192
896
  * Access the remote database service directly
193
897
  */
194
898
  get remote() {
195
- if (!this.spooky) throw new Error("SyncedDb not initialized");
196
- return this.spooky.remoteClient;
899
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
900
+ return this.sp00ky.remoteClient;
197
901
  }
198
902
  /**
199
903
  * Access the local database service directly
200
904
  */
201
905
  get local() {
202
- if (!this.spooky) throw new Error("SyncedDb not initialized");
203
- return this.spooky.localClient;
906
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
907
+ return this.sp00ky.localClient;
204
908
  }
205
909
  /**
206
910
  * Access the auth service
207
911
  */
208
912
  get auth() {
209
- if (!this.spooky) throw new Error("SyncedDb not initialized");
210
- return this.spooky.auth;
913
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
914
+ return this.sp00ky.auth;
211
915
  }
212
916
  get pendingMutationCount() {
213
- if (!this.spooky) throw new Error("SyncedDb not initialized");
214
- return this.spooky.pendingMutationCount;
917
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
918
+ return this.sp00ky.pendingMutationCount;
919
+ }
920
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
921
+ get liveRetryCount() {
922
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
923
+ return this.sp00ky.liveRetryCount;
215
924
  }
216
925
  subscribeToPendingMutations(cb) {
217
- if (!this.spooky) throw new Error("SyncedDb not initialized");
218
- return this.spooky.subscribeToPendingMutations(cb);
926
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
927
+ return this.sp00ky.subscribeToPendingMutations(cb);
928
+ }
929
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
930
+ get syncHealth() {
931
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
932
+ return this.sp00ky.syncHealth;
933
+ }
934
+ /**
935
+ * Observe sync health. Fires immediately with the current status and again
936
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
937
+ * components; this is the imperative escape hatch.
938
+ */
939
+ subscribeToSyncHealth(cb) {
940
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
941
+ return this.sp00ky.subscribeToSyncHealth(cb);
942
+ }
943
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
944
+ get storageHealth() {
945
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
946
+ return this.sp00ky.storageHealth;
947
+ }
948
+ /**
949
+ * Observe local-store durability. Fires immediately with the current snapshot
950
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
951
+ * is the imperative escape hatch.
952
+ */
953
+ subscribeToStorageHealth(cb) {
954
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
955
+ return this.sp00ky.subscribeToStorageHealth(cb);
956
+ }
957
+ bucket(name) {
958
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
959
+ return this.sp00ky.bucket(name);
960
+ }
961
+ getBucketConfig(name) {
962
+ return this.config.schema.buckets?.find((b) => b.name === name);
219
963
  }
220
964
  };
221
965
 
222
966
  //#endregion
967
+ exports.Blurhash = Blurhash;
968
+ exports.BucketImage = BucketImage;
223
969
  exports.RecordId = surrealdb.RecordId;
224
- exports.SpookyProvider = SpookyProvider;
970
+ exports.Sp00kyProvider = Sp00kyProvider;
225
971
  exports.SyncedDb = SyncedDb;
226
972
  exports.Uuid = surrealdb.Uuid;
973
+ exports.createPreload = createPreload;
974
+ exports.useAppRelease = useAppRelease;
975
+ exports.useBlurhash = useBlurhash;
976
+ exports.useBucketImage = useBucketImage;
977
+ exports.useCrdtField = useCrdtField;
227
978
  exports.useDb = useDb;
979
+ exports.useDownloadFile = useDownloadFile;
980
+ exports.useFeatureFlag = useFeatureFlag;
981
+ exports.useFileUpload = useFileUpload;
228
982
  exports.useQuery = useQuery;
983
+ exports.useStorageStatus = useStorageStatus;
984
+ exports.useSyncStatus = useSyncStatus;
229
985
  //# sourceMappingURL=index.cjs.map