@spooky-sync/client-solid 0.0.1-canary.32 → 0.0.1-canary.321

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,6 +2,7 @@ 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
8
  const Sp00kyContext = (0, solid_js.createContext)();
@@ -28,24 +29,67 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
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;
42
+ let runId = 0;
43
+ let activeUnsub;
44
+ let activeHash;
45
+ const teardownActive = () => {
46
+ activeUnsub?.();
47
+ activeUnsub = void 0;
48
+ };
36
49
  const sp00ky = db.getSp00ky();
37
- const initQuery = async (query) => {
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
73
  const unsub = await sp00ky.subscribe(hash, (e) => {
42
- const data = query.isOne ? e[0] : e;
43
- setData(() => data);
44
- const hasData = query.isOne ? data != null : e.length > 0;
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,257 @@ 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 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-impersonation.ts
196
+ /**
197
+ * Observe admin impersonation (started from the DevTools). The client already
198
+ * shows its own warning bar with a Stop button; use this for anything extra,
199
+ * such as hiding actions an admin should not take on someone's behalf.
200
+ * Must be used within a `<Sp00kyProvider>`.
201
+ */
202
+ function useImpersonation(options = {}) {
203
+ const db = useDb();
204
+ const [impersonation, setImpersonation] = (0, solid_js.createSignal)(db.auth.impersonation);
205
+ (0, solid_js.onCleanup)(db.auth.subscribeImpersonation(setImpersonation));
206
+ (0, solid_js.createEffect)(() => {
207
+ if (options.rendersBanner && impersonation()) db.acknowledgeImpersonationBanner();
208
+ });
209
+ return {
210
+ impersonation,
211
+ isImpersonating: () => impersonation() !== null,
212
+ stop: () => db.auth.stopImpersonating()
213
+ };
214
+ }
215
+
216
+ //#endregion
217
+ //#region src/lib/use-storage-status.ts
218
+ /**
219
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
220
+ *
221
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
222
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
223
+ * second tab of the same app cannot get it and runs in memory instead (the
224
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
225
+ * used within a `<Sp00kyProvider>`.
226
+ */
227
+ function useStorageStatus() {
228
+ const db = useDb();
229
+ const [health, setHealth] = (0, solid_js.createSignal)(db.storageHealth);
230
+ (0, solid_js.onCleanup)(db.subscribeToStorageHealth(setHealth));
231
+ return {
232
+ health,
233
+ status: () => health().status,
234
+ isPersistent: () => health().status === "persistent",
235
+ isMemoryFallback: () => health().fallback
236
+ };
237
+ }
238
+
239
+ //#endregion
240
+ //#region src/lib/use-crdt-field.ts
241
+ function useCrdtField(table, recordId, field, fallbackText) {
242
+ const db = (0, solid_js.useContext)(Sp00kyContext);
243
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
244
+ const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
245
+ let currentId;
246
+ let initialized = false;
247
+ (0, solid_js.createEffect)(() => {
248
+ const id = recordId();
249
+ if (initialized && id === currentId) return;
250
+ if (currentId && crdtField()) {
251
+ db.getSp00ky().closeCrdtField(table, currentId, field);
252
+ setCrdtField(null);
253
+ }
254
+ currentId = id;
255
+ initialized = true;
256
+ if (!id) return;
257
+ const sp00ky = db.getSp00ky();
258
+ const text = fallbackText?.();
259
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
260
+ if (currentId === id) setCrdtField(cf);
261
+ }).catch((err) => {
262
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
263
+ });
264
+ });
265
+ (0, solid_js.onCleanup)(() => {
266
+ if (currentId && crdtField()) {
267
+ db.getSp00ky().closeCrdtField(table, currentId, field);
268
+ setCrdtField(null);
269
+ }
270
+ });
271
+ return crdtField;
272
+ }
273
+
274
+ //#endregion
275
+ //#region src/lib/use-feature-flag.ts
276
+ /**
277
+ * Subscribe to a feature flag for the currently authenticated user.
278
+ *
279
+ * Returns three Solid accessors that update reactively whenever the
280
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
281
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
282
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
283
+ *
284
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
285
+ * For multi-variant flags, prefer `variant()` directly.
286
+ */
287
+ function useFeatureFlag(key, options) {
288
+ const handle = useDb().getSp00ky().feature(key, options);
289
+ const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
290
+ const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
291
+ const unsub = handle.subscribe((s) => {
292
+ setVariant(s.variant ?? options?.fallback);
293
+ setPayload(s.payload);
294
+ });
295
+ (0, solid_js.onCleanup)(() => {
296
+ unsub();
297
+ handle.close();
298
+ });
299
+ return {
300
+ variant,
301
+ payload,
302
+ enabled: () => {
303
+ const v = variant();
304
+ return v !== void 0 && v !== "off";
305
+ }
306
+ };
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/lib/use-app-release.ts
311
+ async function reloadForSnapshot(snapshot) {
312
+ if (typeof window === "undefined") return;
313
+ if (snapshot.cacheBust) try {
314
+ if (window.caches) {
315
+ const keys = await window.caches.keys();
316
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
317
+ }
318
+ if (navigator.serviceWorker) {
319
+ const regs = await navigator.serviceWorker.getRegistrations();
320
+ for (const r of regs) r.update().catch(() => {});
321
+ }
322
+ window.location.href = window.location.pathname + "?cb=" + Date.now();
323
+ return;
324
+ } catch {}
325
+ window.location.reload();
326
+ }
327
+ /**
328
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
329
+ * `spky deploy` / `spky release`) and compare it against the running build.
330
+ *
331
+ * Typical use: mount a small "new version available — Reload" notification
332
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
333
+ * (guard the auto path against reload loops with a per-version marker, since
334
+ * a client can reload while the deploy is still rolling out and land on the
335
+ * old bundle again).
336
+ */
337
+ function useAppRelease(options) {
338
+ const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
339
+ const [snapshot, setSnapshot] = (0, solid_js.createSignal)(handle.snapshot());
340
+ const unsub = handle.subscribe(setSnapshot);
341
+ (0, solid_js.onCleanup)(() => {
342
+ unsub();
343
+ handle.close();
344
+ });
345
+ const updateAvailable = () => (0, _spooky_sync_core.semverGt)(snapshot().version, options.currentVersion);
346
+ return {
347
+ latestVersion: () => snapshot().version,
348
+ updateAvailable,
349
+ mandatory: () => updateAvailable() && snapshot().mandatory,
350
+ cacheBust: () => snapshot().cacheBust,
351
+ reload: () => reloadForSnapshot(snapshot())
73
352
  };
74
353
  }
75
354
 
@@ -95,7 +374,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
95
374
  const validate = (file) => {
96
375
  const config = db.getBucketConfig(bucketName);
97
376
  if (!config) return;
98
- if (config.maxSize != null && file.size > config.maxSize) {
377
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
99
378
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
100
379
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
101
380
  }
@@ -107,7 +386,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
107
386
  }
108
387
  }
109
388
  };
110
- const upload = async (path, file) => {
389
+ const upload = async (path, file, options) => {
111
390
  setError(null);
112
391
  try {
113
392
  validate(file);
@@ -118,7 +397,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
118
397
  setIsUploading(true);
119
398
  try {
120
399
  const bytes = await (0, _spooky_sync_core.fileToUint8Array)(file);
121
- await db.bucket(bucketName).put(path, bytes);
400
+ return await db.bucket(bucketName).put(path, bytes, options);
122
401
  } catch (e) {
123
402
  setError(e instanceof Error ? e : new Error(String(e)));
124
403
  } finally {
@@ -168,20 +447,6 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
168
447
 
169
448
  //#endregion
170
449
  //#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
450
  function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
186
451
  let db;
187
452
  let bucketName;
@@ -202,63 +467,15 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
202
467
  const [url, setUrl] = (0, solid_js.createSignal)(null);
203
468
  const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
204
469
  const [error, setError] = (0, solid_js.createSignal)(null);
205
- let currentKey = null;
470
+ let lease = null;
206
471
  let privateUrl = null;
207
- let refetchTrigger;
208
472
  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) {
473
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
474
+ let reloadOnce = false;
475
+ function releaseCurrent() {
476
+ lease?.release();
477
+ lease = null;
478
+ if (privateUrl) {
262
479
  URL.revokeObjectURL(privateUrl);
263
480
  privateUrl = null;
264
481
  }
@@ -266,29 +483,40 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
266
483
  (0, solid_js.createEffect)(() => {
267
484
  const filePath = path();
268
485
  refetchSignal();
269
- releaseCurrentEntry();
486
+ releaseCurrent();
270
487
  if (!filePath) {
271
488
  setUrl(null);
272
489
  setIsLoading(false);
273
490
  setError(null);
274
491
  return;
275
492
  }
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
- }
493
+ const reload = reloadOnce;
494
+ reloadOnce = false;
288
495
  let cancelled = false;
289
496
  setIsLoading(true);
290
497
  setError(null);
291
- doDownload(key, filePath).then((result) => {
498
+ const bucket = db.bucket(bucketName);
499
+ (useCache ? bucket.url(filePath, {
500
+ persist: options.persist !== false,
501
+ pin: options.pin,
502
+ revalidate: options.revalidate,
503
+ reload
504
+ }).then((acquired) => {
505
+ if (!acquired) return null;
506
+ if (cancelled) {
507
+ acquired.release();
508
+ return null;
509
+ }
510
+ lease = acquired;
511
+ return acquired.url;
512
+ }) : bucket.read(filePath, {
513
+ persist: false,
514
+ reload: true
515
+ }).then((blob) => {
516
+ if (!blob || cancelled) return null;
517
+ privateUrl = URL.createObjectURL(blob);
518
+ return privateUrl;
519
+ })).then((result) => {
292
520
  if (!cancelled) {
293
521
  setUrl(result);
294
522
  setIsLoading(false);
@@ -304,18 +532,11 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
304
532
  });
305
533
  });
306
534
  (0, solid_js.onCleanup)(() => {
307
- releaseCurrentEntry();
535
+ releaseCurrent();
308
536
  });
309
537
  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();
538
+ reloadOnce = true;
539
+ setRefetchSignal((n) => n + 1);
319
540
  };
320
541
  return {
321
542
  url,
@@ -325,15 +546,240 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
325
546
  };
326
547
  }
327
548
 
549
+ //#endregion
550
+ //#region src/lib/use-blurhash.ts
551
+ function useBlurhash(dbOrBucketName, bucketNameOrPath, maybePath) {
552
+ let db;
553
+ let bucketName;
554
+ let path;
555
+ if (typeof dbOrBucketName === "string") {
556
+ db = useDb();
557
+ bucketName = dbOrBucketName;
558
+ path = bucketNameOrPath;
559
+ } else {
560
+ db = dbOrBucketName;
561
+ bucketName = bucketNameOrPath;
562
+ path = maybePath;
563
+ }
564
+ const [hash, setHash] = (0, solid_js.createSignal)(null);
565
+ const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
566
+ (0, solid_js.createEffect)(() => {
567
+ const filePath = path();
568
+ if (!filePath) {
569
+ setHash(null);
570
+ setIsLoading(false);
571
+ return;
572
+ }
573
+ let cancelled = false;
574
+ setIsLoading(true);
575
+ db.bucket(bucketName).blurhash(filePath).then((result) => {
576
+ if (cancelled) return;
577
+ setHash(result);
578
+ setIsLoading(false);
579
+ }).catch(() => {
580
+ if (cancelled) return;
581
+ setHash(null);
582
+ setIsLoading(false);
583
+ });
584
+ (0, solid_js.onCleanup)(() => {
585
+ cancelled = true;
586
+ });
587
+ });
588
+ return {
589
+ hash,
590
+ isLoading
591
+ };
592
+ }
593
+
594
+ //#endregion
595
+ //#region src/lib/use-bucket-image.ts
596
+ function useBucketImage(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
597
+ let db;
598
+ let bucketName;
599
+ let path;
600
+ let options;
601
+ if (typeof dbOrBucketName === "string") {
602
+ db = useDb();
603
+ bucketName = dbOrBucketName;
604
+ path = bucketNameOrPath;
605
+ options = pathOrOptions ?? {};
606
+ } else {
607
+ db = dbOrBucketName;
608
+ bucketName = bucketNameOrPath;
609
+ path = pathOrOptions;
610
+ options = maybeOptions ?? {};
611
+ }
612
+ const wantHash = options.blurhash !== false;
613
+ const { hash } = useBlurhash(db, bucketName, () => wantHash ? path() : null);
614
+ const file = useDownloadFile(db, bucketName, path, options);
615
+ const [ready, setReady] = (0, solid_js.createSignal)(false);
616
+ (0, solid_js.createEffect)((0, solid_js.on)(file.url, () => setReady(false), { defer: true }));
617
+ const gate = (img) => {
618
+ const done = () => setReady(true);
619
+ if (typeof img.decode === "function") img.decode().then(done, done);
620
+ else if (img.complete) done();
621
+ else {
622
+ img.onload = done;
623
+ img.onerror = done;
624
+ }
625
+ };
626
+ return {
627
+ ...file,
628
+ blurhash: hash,
629
+ ready,
630
+ gate
631
+ };
632
+ }
633
+
634
+ //#endregion
635
+ //#region src/lib/Blurhash.ts
636
+ /**
637
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
638
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
639
+ * resolution stays tiny regardless of the displayed size.
640
+ */
641
+ function Blurhash(props) {
642
+ if (typeof document === "undefined") return null;
643
+ const canvas = document.createElement("canvas");
644
+ (0, solid_js.createEffect)(() => {
645
+ canvas.className = props.class ?? "";
646
+ });
647
+ (0, solid_js.createEffect)(() => {
648
+ canvas.style.cssText = props.style ?? "";
649
+ });
650
+ (0, solid_js.createEffect)(() => {
651
+ const width = props.width ?? 32;
652
+ const height = props.height ?? 32;
653
+ canvas.width = width;
654
+ canvas.height = height;
655
+ const hash = props.hash;
656
+ if (!hash) return;
657
+ try {
658
+ const pixels = (0, _spooky_sync_core.decodeBlurhash)(hash, width, height, props.punch ?? 1);
659
+ const ctx = canvas.getContext("2d");
660
+ if (!ctx) return;
661
+ const imageData = ctx.createImageData(width, height);
662
+ imageData.data.set(pixels);
663
+ ctx.putImageData(imageData, 0, 0);
664
+ } catch {}
665
+ });
666
+ return canvas;
667
+ }
668
+
669
+ //#endregion
670
+ //#region src/lib/BucketImage.ts
671
+ const LAYER_STYLE = "position:absolute;inset:0;width:100%;height:100%;";
672
+ /**
673
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
674
+ * plate, the automatically stored blurhash, and the real image, which stays
675
+ * transparent until the bitmap is DECODED and then crossfades over the
676
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
677
+ * prefers-reduced-motion (instant swap). The container is made
678
+ * `position: relative` unless your `class` positions it already.
679
+ *
680
+ * ```tsx
681
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
682
+ * fallback={<MyPlate />} alt="" />
683
+ * ```
684
+ */
685
+ function BucketImage(props) {
686
+ if (typeof document === "undefined") return null;
687
+ const image = useBucketImage(props.bucket, () => props.path, {
688
+ ...props.options,
689
+ blurhash: props.blurhash !== false
690
+ });
691
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
692
+ const root = document.createElement("div");
693
+ (0, solid_js.createEffect)(() => {
694
+ root.className = props.class ?? "";
695
+ });
696
+ (0, solid_js.onMount)(() => {
697
+ if (getComputedStyle(root).position === "static") root.style.position = "relative";
698
+ });
699
+ const placeholder = document.createElement("div");
700
+ placeholder.style.cssText = LAYER_STYLE;
701
+ const fallbackHolder = document.createElement("div");
702
+ fallbackHolder.style.cssText = LAYER_STYLE;
703
+ placeholder.append(fallbackHolder);
704
+ const resolvedFallback = (0, solid_js.children)(() => props.fallback);
705
+ (0, solid_js.createEffect)(() => {
706
+ const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node);
707
+ fallbackHolder.replaceChildren(...nodes);
708
+ });
709
+ const hashCanvas = Blurhash({
710
+ get hash() {
711
+ return image.blurhash();
712
+ },
713
+ style: LAYER_STYLE
714
+ });
715
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
716
+ const img = document.createElement("img");
717
+ img.decoding = "async";
718
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
719
+ (0, solid_js.createEffect)(() => {
720
+ img.className = props.imgClass ?? "";
721
+ });
722
+ (0, solid_js.createEffect)(() => {
723
+ img.style.objectFit = props.fit ?? "cover";
724
+ });
725
+ (0, solid_js.createEffect)(() => {
726
+ img.alt = props.alt ?? "";
727
+ });
728
+ (0, solid_js.createEffect)(() => {
729
+ img.style.transition = reducedMotion ? "none" : `opacity ${props.transition ?? 300}ms ${props.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)"}`;
730
+ });
731
+ (0, solid_js.createEffect)(() => {
732
+ const url = image.url();
733
+ if (!url) {
734
+ img.removeAttribute("src");
735
+ return;
736
+ }
737
+ img.src = url;
738
+ image.gate(img);
739
+ });
740
+ (0, solid_js.createEffect)(() => {
741
+ img.style.opacity = image.ready() ? "1" : "0";
742
+ });
743
+ const [settled, setSettled] = (0, solid_js.createSignal)(false);
744
+ (0, solid_js.createEffect)(() => {
745
+ if (!image.ready()) {
746
+ setSettled(false);
747
+ return;
748
+ }
749
+ const wait = (reducedMotion ? 0 : props.transition ?? 300) + 120;
750
+ const timer = setTimeout(() => setSettled(true), wait);
751
+ (0, solid_js.onCleanup)(() => clearTimeout(timer));
752
+ });
753
+ (0, solid_js.createEffect)(() => {
754
+ if (settled()) placeholder.remove();
755
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
756
+ });
757
+ root.append(placeholder, img);
758
+ return root;
759
+ }
760
+
328
761
  //#endregion
329
762
  //#region src/lib/Sp00kyProvider.ts
330
763
  function Sp00kyProvider(props) {
331
764
  const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
332
765
  const [db, setDb] = (0, solid_js.createSignal)(void 0);
766
+ let disposed = false;
767
+ (0, solid_js.onCleanup)(() => {
768
+ disposed = true;
769
+ });
333
770
  (0, solid_js.onMount)(async () => {
334
771
  try {
335
772
  const instance = new SyncedDb(merged.config);
336
773
  await instance.init();
774
+ if (disposed) {
775
+ await instance.close();
776
+ return;
777
+ }
778
+ if (merged.preload) try {
779
+ await merged.preload(instance);
780
+ } catch (e) {
781
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
782
+ }
337
783
  setDb(() => instance);
338
784
  merged.onReady?.(instance);
339
785
  } catch (e) {
@@ -380,6 +826,18 @@ var SyncedDb = class {
380
826
  this._initialized = true;
381
827
  }
382
828
  /**
829
+ * Tear down the client: leaves the tabs broker, closes the local store and
830
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
831
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
832
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
833
+ */
834
+ async close() {
835
+ const instance = this.sp00ky;
836
+ this.sp00ky = null;
837
+ this._initialized = false;
838
+ if (instance) await instance.close();
839
+ }
840
+ /**
383
841
  * Create a new record in the database
384
842
  */
385
843
  async create(id, payload) {
@@ -398,8 +856,21 @@ var SyncedDb = class {
398
856
  */
399
857
  async delete(tableName, selector) {
400
858
  if (!this.sp00ky) 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.sp00ky.delete(tableName, selector);
859
+ const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
860
+ let id;
861
+ if (typeof selector === "string") id = selector;
862
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
863
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
864
+ await this.sp00ky.delete(tableName, id);
865
+ }
866
+ /**
867
+ * Preload/prewarm a built query into the local cache without registering a
868
+ * live view. Fetches once and stores the rows (+ embedded related children)
869
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
870
+ */
871
+ async preload(finalQuery, options) {
872
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
873
+ await this.sp00ky.preload(finalQuery, options);
403
874
  }
404
875
  /**
405
876
  * Query data from the database
@@ -430,6 +901,15 @@ var SyncedDb = class {
430
901
  await this.signOut();
431
902
  }
432
903
  /**
904
+ * Tell the client that this app's own impersonation banner is on screen.
905
+ * Only needed with `impersonationBanner: { mode: 'custom' }`; the
906
+ * `useImpersonation({ rendersBanner: true })` hook calls it for you.
907
+ */
908
+ acknowledgeImpersonationBanner() {
909
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
910
+ this.sp00ky.acknowledgeImpersonationBanner();
911
+ }
912
+ /**
433
913
  * Sign out, clear session and local storage
434
914
  */
435
915
  async signOut() {
@@ -468,10 +948,52 @@ var SyncedDb = class {
468
948
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
469
949
  return this.sp00ky.pendingMutationCount;
470
950
  }
951
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
952
+ get liveRetryCount() {
953
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
954
+ return this.sp00ky.liveRetryCount;
955
+ }
471
956
  subscribeToPendingMutations(cb) {
472
957
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
473
958
  return this.sp00ky.subscribeToPendingMutations(cb);
474
959
  }
960
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
961
+ get syncHealth() {
962
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
963
+ return this.sp00ky.syncHealth;
964
+ }
965
+ /**
966
+ * Observe sync health. Fires immediately with the current status and again
967
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
968
+ * components; this is the imperative escape hatch.
969
+ */
970
+ subscribeToSyncHealth(cb) {
971
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
972
+ return this.sp00ky.subscribeToSyncHealth(cb);
973
+ }
974
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
975
+ get storageHealth() {
976
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
977
+ return this.sp00ky.storageHealth;
978
+ }
979
+ /**
980
+ * Observe local-store durability. Fires immediately with the current snapshot
981
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
982
+ * is the imperative escape hatch.
983
+ */
984
+ subscribeToStorageHealth(cb) {
985
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
986
+ return this.sp00ky.subscribeToStorageHealth(cb);
987
+ }
988
+ /**
989
+ * Web Push for this browser: `support()`, `subscribe()` (from a click),
990
+ * `devices()`, `notify()` / `test()` (push to yourself), `bridge()` for the
991
+ * service worker. See the push-notifications docs page.
992
+ */
993
+ get webPush() {
994
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
995
+ return this.sp00ky.webPush;
996
+ }
475
997
  bucket(name) {
476
998
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
477
999
  return this.sp00ky.bucket(name);
@@ -482,12 +1004,29 @@ var SyncedDb = class {
482
1004
  };
483
1005
 
484
1006
  //#endregion
1007
+ exports.Blurhash = Blurhash;
1008
+ exports.BucketImage = BucketImage;
485
1009
  exports.RecordId = surrealdb.RecordId;
486
1010
  exports.Sp00kyProvider = Sp00kyProvider;
487
1011
  exports.SyncedDb = SyncedDb;
488
1012
  exports.Uuid = surrealdb.Uuid;
1013
+ Object.defineProperty(exports, 'WebPushError', {
1014
+ enumerable: true,
1015
+ get: function () {
1016
+ return _spooky_sync_core.WebPushError;
1017
+ }
1018
+ });
1019
+ exports.createPreload = createPreload;
1020
+ exports.useAppRelease = useAppRelease;
1021
+ exports.useBlurhash = useBlurhash;
1022
+ exports.useBucketImage = useBucketImage;
1023
+ exports.useCrdtField = useCrdtField;
489
1024
  exports.useDb = useDb;
490
1025
  exports.useDownloadFile = useDownloadFile;
1026
+ exports.useFeatureFlag = useFeatureFlag;
491
1027
  exports.useFileUpload = useFileUpload;
1028
+ exports.useImpersonation = useImpersonation;
492
1029
  exports.useQuery = useQuery;
1030
+ exports.useStorageStatus = useStorageStatus;
1031
+ exports.useSyncStatus = useSyncStatus;
493
1032
  //# sourceMappingURL=index.cjs.map