@spooky-sync/client-solid 0.0.1-canary.26 → 0.0.1-canary.261

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