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