@spooky-sync/client-solid 0.0.1-canary.27 → 0.0.1-canary.271

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