@spooky-sync/client-solid 0.0.1-canary.33 → 0.0.1-canary.331

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,81 @@ 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
+ let known = false;
84
+ let idle = false;
85
+ const settleFromServer = () => {
86
+ if (known && idle && myRun === runId) setIsFetched(true);
87
+ };
88
+ const unsubStatus = sp00ky.subscribeQueryStatus(hash, (status) => {
89
+ setIsFetching(status === "fetching");
90
+ idle = status === "idle";
91
+ settleFromServer();
92
+ }, { immediate: true });
93
+ const unsubAuthority = sp00ky.subscribeQueryAuthority(hash, (k) => {
94
+ known = k;
95
+ settleFromServer();
96
+ }, { immediate: true });
97
+ const teardown = () => {
98
+ unsub();
99
+ unsubStatus();
100
+ unsubAuthority();
101
+ };
102
+ if (myRun !== runId) {
103
+ teardown();
104
+ return;
105
+ }
106
+ activeUnsub = teardown;
49
107
  };
50
108
  (0, solid_js.createEffect)(() => {
51
109
  if (!(options?.enabled?.() ?? true)) {
@@ -54,22 +112,265 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
54
112
  }
55
113
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
56
114
  if (!query) return;
57
- const queryString = JSON.stringify(query);
115
+ const queryString = String(query.hash);
58
116
  if (queryString === prevQueryString) return;
59
117
  prevQueryString = queryString;
118
+ const myRun = ++runId;
119
+ teardownActive();
60
120
  setIsFetched(false);
61
- initQuery(query);
62
- (0, solid_js.onCleanup)(() => {
63
- unsubscribe()?.();
64
- });
121
+ setError(void 0);
122
+ initQuery(query, myRun);
123
+ });
124
+ (0, solid_js.onCleanup)(() => {
125
+ runId++;
126
+ teardownActive();
127
+ if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
65
128
  });
66
129
  const isLoading = () => {
67
130
  return !isFetched() && error() === void 0;
68
131
  };
132
+ const isSettled = () => isFetched() && !isFetching();
69
133
  return {
70
134
  data,
71
135
  error,
72
- isLoading
136
+ isLoading,
137
+ isFetching,
138
+ isSettled
139
+ };
140
+ }
141
+
142
+ //#endregion
143
+ //#region src/lib/create-preload.ts
144
+ /**
145
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
146
+ * function so it tracks reactive deps), dedupes on the query's stable identity
147
+ * hash, and registers it via `db.preload`: a live query nobody subscribes to,
148
+ * evicted a ttl after it was registered unless a view mounts it first. Nothing
149
+ * to clean up here.
150
+ *
151
+ * Typical use: inside a list row, preload the detail query the user is likely
152
+ * to open next, so navigation paints from cache instead of the network.
153
+ */
154
+ function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
155
+ let db;
156
+ let finalQuery;
157
+ let options;
158
+ if (dbOrQuery instanceof SyncedDb) {
159
+ db = dbOrQuery;
160
+ finalQuery = queryOrOptions;
161
+ options = maybeOptions;
162
+ } else {
163
+ const contextDb = (0, solid_js.useContext)(Sp00kyContext);
164
+ 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>.");
165
+ db = contextDb;
166
+ finalQuery = dbOrQuery;
167
+ options = queryOrOptions;
168
+ }
169
+ let prevHash;
170
+ (0, solid_js.createEffect)(() => {
171
+ if (!(options?.enabled?.() ?? true)) return;
172
+ const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
173
+ if (!query) return;
174
+ if (query.hash === prevHash) return;
175
+ prevHash = query.hash;
176
+ db.getSp00ky().preload(query, { signal: options?.signal });
177
+ });
178
+ }
179
+
180
+ //#endregion
181
+ //#region src/lib/use-sync-status.ts
182
+ /**
183
+ * Observe sync health for a "can't reach the server" banner / indicator.
184
+ *
185
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
186
+ * remote 500 on query registration, a dropped socket) are absorbed by the
187
+ * retry and never flip this; `isDegraded()` only goes true once failures
188
+ * persist for the configured number of consecutive rounds (sp00ky core config
189
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
190
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
191
+ */
192
+ function useSyncStatus() {
193
+ const db = useDb();
194
+ const [health, setHealth] = (0, solid_js.createSignal)(db.syncHealth);
195
+ (0, solid_js.onCleanup)(db.subscribeToSyncHealth(setHealth));
196
+ return {
197
+ health,
198
+ status: () => health().status,
199
+ isHealthy: () => health().status === "healthy",
200
+ isDegraded: () => health().status === "degraded",
201
+ everConnected: () => health().everConnected,
202
+ isOffline: () => health().status === "degraded" && health().everConnected,
203
+ connection: () => health().connection,
204
+ isReconnecting: () => health().connection === "reconnecting"
205
+ };
206
+ }
207
+
208
+ //#endregion
209
+ //#region src/lib/use-impersonation.ts
210
+ /**
211
+ * Observe admin impersonation (started from the DevTools). The client already
212
+ * shows its own warning bar with a Stop button; use this for anything extra,
213
+ * such as hiding actions an admin should not take on someone's behalf.
214
+ * Must be used within a `<Sp00kyProvider>`.
215
+ */
216
+ function useImpersonation(options = {}) {
217
+ const db = useDb();
218
+ const [impersonation, setImpersonation] = (0, solid_js.createSignal)(db.auth.impersonation);
219
+ (0, solid_js.onCleanup)(db.auth.subscribeImpersonation(setImpersonation));
220
+ (0, solid_js.createEffect)(() => {
221
+ if (options.rendersBanner && impersonation()) db.acknowledgeImpersonationBanner();
222
+ });
223
+ return {
224
+ impersonation,
225
+ isImpersonating: () => impersonation() !== null,
226
+ stop: () => db.auth.stopImpersonating()
227
+ };
228
+ }
229
+
230
+ //#endregion
231
+ //#region src/lib/use-storage-status.ts
232
+ /**
233
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
234
+ *
235
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
236
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
237
+ * second tab of the same app cannot get it and runs in memory instead (the
238
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
239
+ * used within a `<Sp00kyProvider>`.
240
+ */
241
+ function useStorageStatus() {
242
+ const db = useDb();
243
+ const [health, setHealth] = (0, solid_js.createSignal)(db.storageHealth);
244
+ (0, solid_js.onCleanup)(db.subscribeToStorageHealth(setHealth));
245
+ return {
246
+ health,
247
+ status: () => health().status,
248
+ isPersistent: () => health().status === "persistent",
249
+ isMemoryFallback: () => health().fallback
250
+ };
251
+ }
252
+
253
+ //#endregion
254
+ //#region src/lib/use-crdt-field.ts
255
+ /**
256
+ * Open a `@crdt` field of the record `recordId()` names, for an editor to bind
257
+ * to (`field()?.getDoc()`). Re-opens when the id changes; closes on cleanup.
258
+ *
259
+ * The accessor stays `null` until the field is loaded (`CrdtField.isLoaded`):
260
+ * this device had the document, or the server answered. Until then the
261
+ * document may be empty only because nothing has arrived yet, so show the
262
+ * column's text instead of binding an editor to it.
263
+ */
264
+ function useCrdtField(table, recordId, field) {
265
+ const db = (0, solid_js.useContext)(Sp00kyContext);
266
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
267
+ const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
268
+ (0, solid_js.createEffect)((0, solid_js.on)((0, solid_js.createMemo)(recordId), (current) => {
269
+ if (!current) return;
270
+ const sp00ky = db.getSp00ky();
271
+ let active = true;
272
+ let opened = false;
273
+ let offLoaded = () => {};
274
+ sp00ky.openCrdtField(table, current, field).then((cf) => {
275
+ opened = true;
276
+ if (!active) {
277
+ sp00ky.closeCrdtField(table, current, field);
278
+ return;
279
+ }
280
+ offLoaded = cf.onLoaded(() => {
281
+ if (active) setCrdtField(cf);
282
+ });
283
+ }).catch((err) => {
284
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${current}:`, err);
285
+ });
286
+ (0, solid_js.onCleanup)(() => {
287
+ active = false;
288
+ offLoaded();
289
+ setCrdtField(null);
290
+ if (opened) sp00ky.closeCrdtField(table, current, field);
291
+ });
292
+ }));
293
+ return crdtField;
294
+ }
295
+
296
+ //#endregion
297
+ //#region src/lib/use-feature-flag.ts
298
+ /**
299
+ * Subscribe to a feature flag for the currently authenticated user.
300
+ *
301
+ * Returns three Solid accessors that update reactively whenever the
302
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
303
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
304
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
305
+ *
306
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
307
+ * For multi-variant flags, prefer `variant()` directly.
308
+ */
309
+ function useFeatureFlag(key, options) {
310
+ const handle = useDb().getSp00ky().feature(key, options);
311
+ const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
312
+ const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
313
+ const unsub = handle.subscribe((s) => {
314
+ setVariant(s.variant ?? options?.fallback);
315
+ setPayload(s.payload);
316
+ });
317
+ (0, solid_js.onCleanup)(() => {
318
+ unsub();
319
+ handle.close();
320
+ });
321
+ return {
322
+ variant,
323
+ payload,
324
+ enabled: () => {
325
+ const v = variant();
326
+ return v !== void 0 && v !== "off";
327
+ }
328
+ };
329
+ }
330
+
331
+ //#endregion
332
+ //#region src/lib/use-app-release.ts
333
+ async function reloadForSnapshot(snapshot) {
334
+ if (typeof window === "undefined") return;
335
+ if (snapshot.cacheBust) try {
336
+ if (window.caches) {
337
+ const keys = await window.caches.keys();
338
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
339
+ }
340
+ if (navigator.serviceWorker) {
341
+ const regs = await navigator.serviceWorker.getRegistrations();
342
+ for (const r of regs) r.update().catch(() => {});
343
+ }
344
+ window.location.href = window.location.pathname + "?cb=" + Date.now();
345
+ return;
346
+ } catch {}
347
+ window.location.reload();
348
+ }
349
+ /**
350
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
351
+ * `spky deploy` / `spky release`) and compare it against the running build.
352
+ *
353
+ * Typical use: mount a small "new version available — Reload" notification
354
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
355
+ * (guard the auto path against reload loops with a per-version marker, since
356
+ * a client can reload while the deploy is still rolling out and land on the
357
+ * old bundle again).
358
+ */
359
+ function useAppRelease(options) {
360
+ const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
361
+ const [snapshot, setSnapshot] = (0, solid_js.createSignal)(handle.snapshot());
362
+ const unsub = handle.subscribe(setSnapshot);
363
+ (0, solid_js.onCleanup)(() => {
364
+ unsub();
365
+ handle.close();
366
+ });
367
+ const updateAvailable = () => (0, _spooky_sync_core.semverGt)(snapshot().version, options.currentVersion);
368
+ return {
369
+ latestVersion: () => snapshot().version,
370
+ updateAvailable,
371
+ mandatory: () => updateAvailable() && snapshot().mandatory,
372
+ cacheBust: () => snapshot().cacheBust,
373
+ reload: () => reloadForSnapshot(snapshot())
73
374
  };
74
375
  }
75
376
 
@@ -95,7 +396,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
95
396
  const validate = (file) => {
96
397
  const config = db.getBucketConfig(bucketName);
97
398
  if (!config) return;
98
- if (config.maxSize != null && file.size > config.maxSize) {
399
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
99
400
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
100
401
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
101
402
  }
@@ -107,7 +408,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
107
408
  }
108
409
  }
109
410
  };
110
- const upload = async (path, file) => {
411
+ const upload = async (path, file, options) => {
111
412
  setError(null);
112
413
  try {
113
414
  validate(file);
@@ -118,7 +419,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
118
419
  setIsUploading(true);
119
420
  try {
120
421
  const bytes = await (0, _spooky_sync_core.fileToUint8Array)(file);
121
- await db.bucket(bucketName).put(path, bytes);
422
+ return await db.bucket(bucketName).put(path, bytes, options);
122
423
  } catch (e) {
123
424
  setError(e instanceof Error ? e : new Error(String(e)));
124
425
  } finally {
@@ -168,20 +469,6 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
168
469
 
169
470
  //#endregion
170
471
  //#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
472
  function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
186
473
  let db;
187
474
  let bucketName;
@@ -202,63 +489,15 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
202
489
  const [url, setUrl] = (0, solid_js.createSignal)(null);
203
490
  const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
204
491
  const [error, setError] = (0, solid_js.createSignal)(null);
205
- let currentKey = null;
492
+ let lease = null;
206
493
  let privateUrl = null;
207
- let refetchTrigger;
208
494
  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) {
495
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
496
+ let reloadOnce = false;
497
+ function releaseCurrent() {
498
+ lease?.release();
499
+ lease = null;
500
+ if (privateUrl) {
262
501
  URL.revokeObjectURL(privateUrl);
263
502
  privateUrl = null;
264
503
  }
@@ -266,29 +505,40 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
266
505
  (0, solid_js.createEffect)(() => {
267
506
  const filePath = path();
268
507
  refetchSignal();
269
- releaseCurrentEntry();
508
+ releaseCurrent();
270
509
  if (!filePath) {
271
510
  setUrl(null);
272
511
  setIsLoading(false);
273
512
  setError(null);
274
513
  return;
275
514
  }
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
- }
515
+ const reload = reloadOnce;
516
+ reloadOnce = false;
288
517
  let cancelled = false;
289
518
  setIsLoading(true);
290
519
  setError(null);
291
- doDownload(key, filePath).then((result) => {
520
+ const bucket = db.bucket(bucketName);
521
+ (useCache ? bucket.url(filePath, {
522
+ persist: options.persist !== false,
523
+ pin: options.pin,
524
+ revalidate: options.revalidate,
525
+ reload
526
+ }).then((acquired) => {
527
+ if (!acquired) return null;
528
+ if (cancelled) {
529
+ acquired.release();
530
+ return null;
531
+ }
532
+ lease = acquired;
533
+ return acquired.url;
534
+ }) : bucket.read(filePath, {
535
+ persist: false,
536
+ reload: true
537
+ }).then((blob) => {
538
+ if (!blob || cancelled) return null;
539
+ privateUrl = URL.createObjectURL(blob);
540
+ return privateUrl;
541
+ })).then((result) => {
292
542
  if (!cancelled) {
293
543
  setUrl(result);
294
544
  setIsLoading(false);
@@ -304,18 +554,11 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
304
554
  });
305
555
  });
306
556
  (0, solid_js.onCleanup)(() => {
307
- releaseCurrentEntry();
557
+ releaseCurrent();
308
558
  });
309
559
  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();
560
+ reloadOnce = true;
561
+ setRefetchSignal((n) => n + 1);
319
562
  };
320
563
  return {
321
564
  url,
@@ -325,15 +568,240 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
325
568
  };
326
569
  }
327
570
 
571
+ //#endregion
572
+ //#region src/lib/use-blurhash.ts
573
+ function useBlurhash(dbOrBucketName, bucketNameOrPath, maybePath) {
574
+ let db;
575
+ let bucketName;
576
+ let path;
577
+ if (typeof dbOrBucketName === "string") {
578
+ db = useDb();
579
+ bucketName = dbOrBucketName;
580
+ path = bucketNameOrPath;
581
+ } else {
582
+ db = dbOrBucketName;
583
+ bucketName = bucketNameOrPath;
584
+ path = maybePath;
585
+ }
586
+ const [hash, setHash] = (0, solid_js.createSignal)(null);
587
+ const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
588
+ (0, solid_js.createEffect)(() => {
589
+ const filePath = path();
590
+ if (!filePath) {
591
+ setHash(null);
592
+ setIsLoading(false);
593
+ return;
594
+ }
595
+ let cancelled = false;
596
+ setIsLoading(true);
597
+ db.bucket(bucketName).blurhash(filePath).then((result) => {
598
+ if (cancelled) return;
599
+ setHash(result);
600
+ setIsLoading(false);
601
+ }).catch(() => {
602
+ if (cancelled) return;
603
+ setHash(null);
604
+ setIsLoading(false);
605
+ });
606
+ (0, solid_js.onCleanup)(() => {
607
+ cancelled = true;
608
+ });
609
+ });
610
+ return {
611
+ hash,
612
+ isLoading
613
+ };
614
+ }
615
+
616
+ //#endregion
617
+ //#region src/lib/use-bucket-image.ts
618
+ function useBucketImage(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
619
+ let db;
620
+ let bucketName;
621
+ let path;
622
+ let options;
623
+ if (typeof dbOrBucketName === "string") {
624
+ db = useDb();
625
+ bucketName = dbOrBucketName;
626
+ path = bucketNameOrPath;
627
+ options = pathOrOptions ?? {};
628
+ } else {
629
+ db = dbOrBucketName;
630
+ bucketName = bucketNameOrPath;
631
+ path = pathOrOptions;
632
+ options = maybeOptions ?? {};
633
+ }
634
+ const wantHash = options.blurhash !== false;
635
+ const { hash } = useBlurhash(db, bucketName, () => wantHash ? path() : null);
636
+ const file = useDownloadFile(db, bucketName, path, options);
637
+ const [ready, setReady] = (0, solid_js.createSignal)(false);
638
+ (0, solid_js.createEffect)((0, solid_js.on)(file.url, () => setReady(false), { defer: true }));
639
+ const gate = (img) => {
640
+ const done = () => setReady(true);
641
+ if (typeof img.decode === "function") img.decode().then(done, done);
642
+ else if (img.complete) done();
643
+ else {
644
+ img.onload = done;
645
+ img.onerror = done;
646
+ }
647
+ };
648
+ return {
649
+ ...file,
650
+ blurhash: hash,
651
+ ready,
652
+ gate
653
+ };
654
+ }
655
+
656
+ //#endregion
657
+ //#region src/lib/Blurhash.ts
658
+ /**
659
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
660
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
661
+ * resolution stays tiny regardless of the displayed size.
662
+ */
663
+ function Blurhash(props) {
664
+ if (typeof document === "undefined") return null;
665
+ const canvas = document.createElement("canvas");
666
+ (0, solid_js.createEffect)(() => {
667
+ canvas.className = props.class ?? "";
668
+ });
669
+ (0, solid_js.createEffect)(() => {
670
+ canvas.style.cssText = props.style ?? "";
671
+ });
672
+ (0, solid_js.createEffect)(() => {
673
+ const width = props.width ?? 32;
674
+ const height = props.height ?? 32;
675
+ canvas.width = width;
676
+ canvas.height = height;
677
+ const hash = props.hash;
678
+ if (!hash) return;
679
+ try {
680
+ const pixels = (0, _spooky_sync_core.decodeBlurhash)(hash, width, height, props.punch ?? 1);
681
+ const ctx = canvas.getContext("2d");
682
+ if (!ctx) return;
683
+ const imageData = ctx.createImageData(width, height);
684
+ imageData.data.set(pixels);
685
+ ctx.putImageData(imageData, 0, 0);
686
+ } catch {}
687
+ });
688
+ return canvas;
689
+ }
690
+
691
+ //#endregion
692
+ //#region src/lib/BucketImage.ts
693
+ const LAYER_STYLE = "position:absolute;inset:0;width:100%;height:100%;";
694
+ /**
695
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
696
+ * plate, the automatically stored blurhash, and the real image, which stays
697
+ * transparent until the bitmap is DECODED and then crossfades over the
698
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
699
+ * prefers-reduced-motion (instant swap). The container is made
700
+ * `position: relative` unless your `class` positions it already.
701
+ *
702
+ * ```tsx
703
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
704
+ * fallback={<MyPlate />} alt="" />
705
+ * ```
706
+ */
707
+ function BucketImage(props) {
708
+ if (typeof document === "undefined") return null;
709
+ const image = useBucketImage(props.bucket, () => props.path, {
710
+ ...props.options,
711
+ blurhash: props.blurhash !== false
712
+ });
713
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
714
+ const root = document.createElement("div");
715
+ (0, solid_js.createEffect)(() => {
716
+ root.className = props.class ?? "";
717
+ });
718
+ (0, solid_js.onMount)(() => {
719
+ if (getComputedStyle(root).position === "static") root.style.position = "relative";
720
+ });
721
+ const placeholder = document.createElement("div");
722
+ placeholder.style.cssText = LAYER_STYLE;
723
+ const fallbackHolder = document.createElement("div");
724
+ fallbackHolder.style.cssText = LAYER_STYLE;
725
+ placeholder.append(fallbackHolder);
726
+ const resolvedFallback = (0, solid_js.children)(() => props.fallback);
727
+ (0, solid_js.createEffect)(() => {
728
+ const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node);
729
+ fallbackHolder.replaceChildren(...nodes);
730
+ });
731
+ const hashCanvas = Blurhash({
732
+ get hash() {
733
+ return image.blurhash();
734
+ },
735
+ style: LAYER_STYLE
736
+ });
737
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
738
+ const img = document.createElement("img");
739
+ img.decoding = "async";
740
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
741
+ (0, solid_js.createEffect)(() => {
742
+ img.className = props.imgClass ?? "";
743
+ });
744
+ (0, solid_js.createEffect)(() => {
745
+ img.style.objectFit = props.fit ?? "cover";
746
+ });
747
+ (0, solid_js.createEffect)(() => {
748
+ img.alt = props.alt ?? "";
749
+ });
750
+ (0, solid_js.createEffect)(() => {
751
+ img.style.transition = reducedMotion ? "none" : `opacity ${props.transition ?? 300}ms ${props.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)"}`;
752
+ });
753
+ (0, solid_js.createEffect)(() => {
754
+ const url = image.url();
755
+ if (!url) {
756
+ img.removeAttribute("src");
757
+ return;
758
+ }
759
+ img.src = url;
760
+ image.gate(img);
761
+ });
762
+ (0, solid_js.createEffect)(() => {
763
+ img.style.opacity = image.ready() ? "1" : "0";
764
+ });
765
+ const [settled, setSettled] = (0, solid_js.createSignal)(false);
766
+ (0, solid_js.createEffect)(() => {
767
+ if (!image.ready()) {
768
+ setSettled(false);
769
+ return;
770
+ }
771
+ const wait = (reducedMotion ? 0 : props.transition ?? 300) + 120;
772
+ const timer = setTimeout(() => setSettled(true), wait);
773
+ (0, solid_js.onCleanup)(() => clearTimeout(timer));
774
+ });
775
+ (0, solid_js.createEffect)(() => {
776
+ if (settled()) placeholder.remove();
777
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
778
+ });
779
+ root.append(placeholder, img);
780
+ return root;
781
+ }
782
+
328
783
  //#endregion
329
784
  //#region src/lib/Sp00kyProvider.ts
330
785
  function Sp00kyProvider(props) {
331
786
  const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
332
787
  const [db, setDb] = (0, solid_js.createSignal)(void 0);
788
+ let disposed = false;
789
+ (0, solid_js.onCleanup)(() => {
790
+ disposed = true;
791
+ });
333
792
  (0, solid_js.onMount)(async () => {
334
793
  try {
335
794
  const instance = new SyncedDb(merged.config);
336
795
  await instance.init();
796
+ if (disposed) {
797
+ await instance.close();
798
+ return;
799
+ }
800
+ if (merged.preload) try {
801
+ await merged.preload(instance);
802
+ } catch (e) {
803
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
804
+ }
337
805
  setDb(() => instance);
338
806
  merged.onReady?.(instance);
339
807
  } catch (e) {
@@ -380,6 +848,18 @@ var SyncedDb = class {
380
848
  this._initialized = true;
381
849
  }
382
850
  /**
851
+ * Tear down the client: leaves the tabs broker, closes the local store and
852
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
853
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
854
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
855
+ */
856
+ async close() {
857
+ const instance = this.sp00ky;
858
+ this.sp00ky = null;
859
+ this._initialized = false;
860
+ if (instance) await instance.close();
861
+ }
862
+ /**
383
863
  * Create a new record in the database
384
864
  */
385
865
  async create(id, payload) {
@@ -398,8 +878,21 @@ var SyncedDb = class {
398
878
  */
399
879
  async delete(tableName, selector) {
400
880
  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);
881
+ const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
882
+ let id;
883
+ if (typeof selector === "string") id = selector;
884
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
885
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
886
+ await this.sp00ky.delete(tableName, id);
887
+ }
888
+ /**
889
+ * Preload/prewarm a built query into the local cache without registering a
890
+ * live view. Fetches once and stores the rows (+ embedded related children)
891
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
892
+ */
893
+ async preload(finalQuery, options) {
894
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
895
+ await this.sp00ky.preload(finalQuery, options);
403
896
  }
404
897
  /**
405
898
  * Query data from the database
@@ -430,6 +923,15 @@ var SyncedDb = class {
430
923
  await this.signOut();
431
924
  }
432
925
  /**
926
+ * Tell the client that this app's own impersonation banner is on screen.
927
+ * Only needed with `impersonationBanner: { mode: 'custom' }`; the
928
+ * `useImpersonation({ rendersBanner: true })` hook calls it for you.
929
+ */
930
+ acknowledgeImpersonationBanner() {
931
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
932
+ this.sp00ky.acknowledgeImpersonationBanner();
933
+ }
934
+ /**
433
935
  * Sign out, clear session and local storage
434
936
  */
435
937
  async signOut() {
@@ -444,6 +946,19 @@ var SyncedDb = class {
444
946
  return await this.sp00ky.useRemote(fn);
445
947
  }
446
948
  /**
949
+ * Run one SurrealQL statement on the server and return its result array.
950
+ * Nothing is cached locally. Prefer this over `useRemote(s => s.query(...))`
951
+ * for a plain read or write: the bare SDK client that callback receives
952
+ * skips the connect gate, so a statement issued while the socket is being
953
+ * rebuilt fails with "Specify a namespace to use" instead of waiting for
954
+ * the session. This one is also bounded by the remote service's
955
+ * concurrency limit and query timeout.
956
+ */
957
+ async remoteQuery(sql, vars) {
958
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
959
+ return await this.sp00ky.remoteQuery(sql, vars);
960
+ }
961
+ /**
447
962
  * Access the remote database service directly
448
963
  */
449
964
  get remote() {
@@ -468,10 +983,52 @@ var SyncedDb = class {
468
983
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
469
984
  return this.sp00ky.pendingMutationCount;
470
985
  }
986
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
987
+ get liveRetryCount() {
988
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
989
+ return this.sp00ky.liveRetryCount;
990
+ }
471
991
  subscribeToPendingMutations(cb) {
472
992
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
473
993
  return this.sp00ky.subscribeToPendingMutations(cb);
474
994
  }
995
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
996
+ get syncHealth() {
997
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
998
+ return this.sp00ky.syncHealth;
999
+ }
1000
+ /**
1001
+ * Observe sync health. Fires immediately with the current status and again
1002
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
1003
+ * components; this is the imperative escape hatch.
1004
+ */
1005
+ subscribeToSyncHealth(cb) {
1006
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
1007
+ return this.sp00ky.subscribeToSyncHealth(cb);
1008
+ }
1009
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
1010
+ get storageHealth() {
1011
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
1012
+ return this.sp00ky.storageHealth;
1013
+ }
1014
+ /**
1015
+ * Observe local-store durability. Fires immediately with the current snapshot
1016
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
1017
+ * is the imperative escape hatch.
1018
+ */
1019
+ subscribeToStorageHealth(cb) {
1020
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
1021
+ return this.sp00ky.subscribeToStorageHealth(cb);
1022
+ }
1023
+ /**
1024
+ * Web Push for this browser: `support()`, `subscribe()` (from a click),
1025
+ * `devices()`, `notify()` / `test()` (push to yourself), `bridge()` for the
1026
+ * service worker. See the push-notifications docs page.
1027
+ */
1028
+ get webPush() {
1029
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
1030
+ return this.sp00ky.webPush;
1031
+ }
475
1032
  bucket(name) {
476
1033
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
477
1034
  return this.sp00ky.bucket(name);
@@ -482,12 +1039,29 @@ var SyncedDb = class {
482
1039
  };
483
1040
 
484
1041
  //#endregion
1042
+ exports.Blurhash = Blurhash;
1043
+ exports.BucketImage = BucketImage;
485
1044
  exports.RecordId = surrealdb.RecordId;
486
1045
  exports.Sp00kyProvider = Sp00kyProvider;
487
1046
  exports.SyncedDb = SyncedDb;
488
1047
  exports.Uuid = surrealdb.Uuid;
1048
+ Object.defineProperty(exports, 'WebPushError', {
1049
+ enumerable: true,
1050
+ get: function () {
1051
+ return _spooky_sync_core.WebPushError;
1052
+ }
1053
+ });
1054
+ exports.createPreload = createPreload;
1055
+ exports.useAppRelease = useAppRelease;
1056
+ exports.useBlurhash = useBlurhash;
1057
+ exports.useBucketImage = useBucketImage;
1058
+ exports.useCrdtField = useCrdtField;
489
1059
  exports.useDb = useDb;
490
1060
  exports.useDownloadFile = useDownloadFile;
1061
+ exports.useFeatureFlag = useFeatureFlag;
491
1062
  exports.useFileUpload = useFileUpload;
1063
+ exports.useImpersonation = useImpersonation;
492
1064
  exports.useQuery = useQuery;
1065
+ exports.useStorageStatus = useStorageStatus;
1066
+ exports.useSyncStatus = useSyncStatus;
493
1067
  //# sourceMappingURL=index.cjs.map