@spooky-sync/client-solid 0.0.1-canary.17 → 0.0.1-canary.170

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, fileToUint8Array, semverGt } from "@spooky-sync/core";
2
2
  import { RecordId, Uuid } from "surrealdb";
3
3
  import { createComponent, createContext, createEffect, createMemo, createSignal, mergeProps, 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,56 @@ 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
+ const initQuery = async (query, myRun) => {
37
50
  const { hash } = await query.run();
51
+ if (myRun !== runId) return;
52
+ activeHash = hash;
38
53
  setError(void 0);
39
54
  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;
55
+ const unsub = await sp00ky.subscribe(hash, (e) => {
56
+ const queryData = query.isOne ? e[0] : e;
57
+ const reconcileStart = performance.now();
58
+ setState("value", reconcile(queryData, { key: "id" }));
59
+ setVersion((v) => v + 1);
60
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
61
+ const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
44
62
  if (!isFirstCall || hasData) setIsFetched(true);
45
63
  isFirstCall = false;
46
64
  }, { immediate: true });
47
- setUnsubscribe(() => unsub);
65
+ const unsubStatus = sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true });
66
+ const teardown = () => {
67
+ unsub();
68
+ unsubStatus();
69
+ };
70
+ if (myRun !== runId) {
71
+ teardown();
72
+ return;
73
+ }
74
+ activeUnsub = teardown;
48
75
  };
49
76
  createEffect(() => {
50
77
  if (!(options?.enabled?.() ?? true)) {
@@ -53,22 +80,236 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
53
80
  }
54
81
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
55
82
  if (!query) return;
56
- const queryString = JSON.stringify(query);
83
+ const queryString = String(query.hash);
57
84
  if (queryString === prevQueryString) return;
58
85
  prevQueryString = queryString;
86
+ const myRun = ++runId;
87
+ teardownActive();
59
88
  setIsFetched(false);
60
- initQuery(query);
61
- onCleanup(() => {
62
- unsubscribe()?.();
63
- });
89
+ initQuery(query, myRun);
90
+ });
91
+ onCleanup(() => {
92
+ runId++;
93
+ teardownActive();
94
+ if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
64
95
  });
65
96
  const isLoading = () => {
66
97
  return !isFetched() && error() === void 0;
67
98
  };
99
+ const isSettled = () => isFetched() && !isFetching();
68
100
  return {
69
101
  data,
70
102
  error,
71
- isLoading
103
+ isLoading,
104
+ isFetching,
105
+ isSettled
106
+ };
107
+ }
108
+
109
+ //#endregion
110
+ //#region src/lib/create-preload.ts
111
+ /**
112
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
113
+ * function so it tracks reactive deps), dedupes on the query's stable identity
114
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
115
+ * no cleanup: preload registers nothing that needs tearing down.
116
+ *
117
+ * Typical use: inside a list row, preload the detail query the user is likely
118
+ * to open next, so navigation paints from cache instead of the network.
119
+ */
120
+ function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
121
+ let db;
122
+ let finalQuery;
123
+ let options;
124
+ if (dbOrQuery instanceof SyncedDb) {
125
+ db = dbOrQuery;
126
+ finalQuery = queryOrOptions;
127
+ options = maybeOptions;
128
+ } else {
129
+ const contextDb = useContext(Sp00kyContext);
130
+ 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>.");
131
+ db = contextDb;
132
+ finalQuery = dbOrQuery;
133
+ options = queryOrOptions;
134
+ }
135
+ let prevHash;
136
+ createEffect(() => {
137
+ if (!(options?.enabled?.() ?? true)) return;
138
+ const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
139
+ if (!query) return;
140
+ if (query.hash === prevHash) return;
141
+ prevHash = query.hash;
142
+ db.getSp00ky().preload(query, {
143
+ refresh: options?.refresh,
144
+ staleTime: options?.staleTime
145
+ });
146
+ });
147
+ }
148
+
149
+ //#endregion
150
+ //#region src/lib/use-sync-status.ts
151
+ /**
152
+ * Observe sync health for a "can't reach the server" banner / indicator.
153
+ *
154
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
155
+ * remote 500 on query registration, a dropped socket) are absorbed by the
156
+ * retry and never flip this; `isDegraded()` only goes true once failures
157
+ * persist for the configured number of consecutive rounds (sp00ky core config
158
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
159
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
160
+ */
161
+ function useSyncStatus() {
162
+ const db = useDb();
163
+ const [health, setHealth] = createSignal(db.syncHealth);
164
+ onCleanup(db.subscribeToSyncHealth(setHealth));
165
+ return {
166
+ health,
167
+ status: () => health().status,
168
+ isHealthy: () => health().status === "healthy",
169
+ isDegraded: () => health().status === "degraded",
170
+ everConnected: () => health().everConnected,
171
+ isOffline: () => health().status === "degraded" && health().everConnected,
172
+ connection: () => health().connection,
173
+ isReconnecting: () => health().connection === "reconnecting"
174
+ };
175
+ }
176
+
177
+ //#endregion
178
+ //#region src/lib/use-storage-status.ts
179
+ /**
180
+ * Observe how durable the LOCAL cache is, for a "no local storage" warning.
181
+ *
182
+ * Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
183
+ * the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
184
+ * second tab of the same app cannot get it and runs in memory instead (the
185
+ * engine retries first, so a closing tab's lock is usually waited out). Must be
186
+ * used within a `<Sp00kyProvider>`.
187
+ */
188
+ function useStorageStatus() {
189
+ const db = useDb();
190
+ const [health, setHealth] = createSignal(db.storageHealth);
191
+ onCleanup(db.subscribeToStorageHealth(setHealth));
192
+ return {
193
+ health,
194
+ status: () => health().status,
195
+ isPersistent: () => health().status === "persistent",
196
+ isMemoryFallback: () => health().fallback
197
+ };
198
+ }
199
+
200
+ //#endregion
201
+ //#region src/lib/use-crdt-field.ts
202
+ function useCrdtField(table, recordId, field, fallbackText) {
203
+ const db = useContext(Sp00kyContext);
204
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
205
+ const [crdtField, setCrdtField] = createSignal(null);
206
+ let currentId;
207
+ let initialized = false;
208
+ createEffect(() => {
209
+ const id = recordId();
210
+ if (initialized && id === currentId) return;
211
+ if (currentId && crdtField()) {
212
+ db.getSp00ky().closeCrdtField(table, currentId, field);
213
+ setCrdtField(null);
214
+ }
215
+ currentId = id;
216
+ initialized = true;
217
+ if (!id) return;
218
+ const sp00ky = db.getSp00ky();
219
+ const text = fallbackText?.();
220
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
221
+ if (currentId === id) setCrdtField(cf);
222
+ }).catch((err) => {
223
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
224
+ });
225
+ });
226
+ onCleanup(() => {
227
+ if (currentId && crdtField()) {
228
+ db.getSp00ky().closeCrdtField(table, currentId, field);
229
+ setCrdtField(null);
230
+ }
231
+ });
232
+ return crdtField;
233
+ }
234
+
235
+ //#endregion
236
+ //#region src/lib/use-feature-flag.ts
237
+ /**
238
+ * Subscribe to a feature flag for the currently authenticated user.
239
+ *
240
+ * Returns three Solid accessors that update reactively whenever the
241
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
242
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
243
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
244
+ *
245
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
246
+ * For multi-variant flags, prefer `variant()` directly.
247
+ */
248
+ function useFeatureFlag(key, options) {
249
+ const handle = useDb().getSp00ky().feature(key, options);
250
+ const [variant, setVariant] = createSignal(handle.variant());
251
+ const [payload, setPayload] = createSignal(handle.payload());
252
+ const unsub = handle.subscribe((s) => {
253
+ setVariant(s.variant ?? options?.fallback);
254
+ setPayload(s.payload);
255
+ });
256
+ onCleanup(() => {
257
+ unsub();
258
+ handle.close();
259
+ });
260
+ return {
261
+ variant,
262
+ payload,
263
+ enabled: () => {
264
+ const v = variant();
265
+ return v !== void 0 && v !== "off";
266
+ }
267
+ };
268
+ }
269
+
270
+ //#endregion
271
+ //#region src/lib/use-app-release.ts
272
+ async function reloadForSnapshot(snapshot) {
273
+ if (typeof window === "undefined") return;
274
+ if (snapshot.cacheBust) try {
275
+ if (window.caches) {
276
+ const keys = await window.caches.keys();
277
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
278
+ }
279
+ if (navigator.serviceWorker) {
280
+ const regs = await navigator.serviceWorker.getRegistrations();
281
+ for (const r of regs) r.update().catch(() => {});
282
+ }
283
+ window.location.href = window.location.pathname + "?cb=" + Date.now();
284
+ return;
285
+ } catch {}
286
+ window.location.reload();
287
+ }
288
+ /**
289
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
290
+ * `spky deploy` / `spky release`) and compare it against the running build.
291
+ *
292
+ * Typical use: mount a small "new version available — Reload" notification
293
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
294
+ * (guard the auto path against reload loops with a per-version marker, since
295
+ * a client can reload while the deploy is still rolling out and land on the
296
+ * old bundle again).
297
+ */
298
+ function useAppRelease(options) {
299
+ const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
300
+ const [snapshot, setSnapshot] = createSignal(handle.snapshot());
301
+ const unsub = handle.subscribe(setSnapshot);
302
+ onCleanup(() => {
303
+ unsub();
304
+ handle.close();
305
+ });
306
+ const updateAvailable = () => semverGt(snapshot().version, options.currentVersion);
307
+ return {
308
+ latestVersion: () => snapshot().version,
309
+ updateAvailable,
310
+ mandatory: () => updateAvailable() && snapshot().mandatory,
311
+ cacheBust: () => snapshot().cacheBust,
312
+ reload: () => reloadForSnapshot(snapshot())
72
313
  };
73
314
  }
74
315
 
@@ -94,7 +335,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
94
335
  const validate = (file) => {
95
336
  const config = db.getBucketConfig(bucketName);
96
337
  if (!config) return;
97
- if (config.maxSize != null && file.size > config.maxSize) {
338
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
98
339
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
99
340
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
100
341
  }
@@ -167,20 +408,6 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
167
408
 
168
409
  //#endregion
169
410
  //#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
411
  function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
185
412
  let db;
186
413
  let bucketName;
@@ -201,63 +428,15 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
201
428
  const [url, setUrl] = createSignal(null);
202
429
  const [isLoading, setIsLoading] = createSignal(false);
203
430
  const [error, setError] = createSignal(null);
204
- let currentKey = null;
431
+ let lease = null;
205
432
  let privateUrl = null;
206
- let refetchTrigger;
207
433
  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) {
434
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
435
+ let reloadOnce = false;
436
+ function releaseCurrent() {
437
+ lease?.release();
438
+ lease = null;
439
+ if (privateUrl) {
261
440
  URL.revokeObjectURL(privateUrl);
262
441
  privateUrl = null;
263
442
  }
@@ -265,29 +444,40 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
265
444
  createEffect(() => {
266
445
  const filePath = path();
267
446
  refetchSignal();
268
- releaseCurrentEntry();
447
+ releaseCurrent();
269
448
  if (!filePath) {
270
449
  setUrl(null);
271
450
  setIsLoading(false);
272
451
  setError(null);
273
452
  return;
274
453
  }
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
- }
454
+ const reload = reloadOnce;
455
+ reloadOnce = false;
287
456
  let cancelled = false;
288
457
  setIsLoading(true);
289
458
  setError(null);
290
- doDownload(key, filePath).then((result) => {
459
+ const bucket = db.bucket(bucketName);
460
+ (useCache ? bucket.url(filePath, {
461
+ persist: options.persist !== false,
462
+ pin: options.pin,
463
+ revalidate: options.revalidate,
464
+ reload
465
+ }).then((acquired) => {
466
+ if (!acquired) return null;
467
+ if (cancelled) {
468
+ acquired.release();
469
+ return null;
470
+ }
471
+ lease = acquired;
472
+ return acquired.url;
473
+ }) : bucket.read(filePath, {
474
+ persist: false,
475
+ reload: true
476
+ }).then((blob) => {
477
+ if (!blob || cancelled) return null;
478
+ privateUrl = URL.createObjectURL(blob);
479
+ return privateUrl;
480
+ })).then((result) => {
291
481
  if (!cancelled) {
292
482
  setUrl(result);
293
483
  setIsLoading(false);
@@ -303,18 +493,11 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
303
493
  });
304
494
  });
305
495
  onCleanup(() => {
306
- releaseCurrentEntry();
496
+ releaseCurrent();
307
497
  });
308
498
  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();
499
+ reloadOnce = true;
500
+ setRefetchSignal((n) => n + 1);
318
501
  };
319
502
  return {
320
503
  url,
@@ -325,26 +508,39 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
325
508
  }
326
509
 
327
510
  //#endregion
328
- //#region src/lib/SpookyProvider.ts
329
- function SpookyProvider(props) {
511
+ //#region src/lib/Sp00kyProvider.ts
512
+ function Sp00kyProvider(props) {
330
513
  const merged = mergeProps({ fallback: void 0 }, props);
331
514
  const [db, setDb] = createSignal(void 0);
515
+ let disposed = false;
516
+ onCleanup(() => {
517
+ disposed = true;
518
+ });
332
519
  onMount(async () => {
333
520
  try {
334
521
  const instance = new SyncedDb(merged.config);
335
522
  await instance.init();
523
+ if (disposed) {
524
+ await instance.close();
525
+ return;
526
+ }
527
+ if (merged.preload) try {
528
+ await merged.preload(instance);
529
+ } catch (e) {
530
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
531
+ }
336
532
  setDb(() => instance);
337
533
  merged.onReady?.(instance);
338
534
  } catch (e) {
339
535
  const error = e instanceof Error ? e : new Error(String(e));
340
536
  if (merged.onError) merged.onError(error);
341
- else console.error("SpookyProvider: Failed to initialize database", error);
537
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
342
538
  }
343
539
  });
344
540
  return createMemo(() => {
345
541
  const instance = db();
346
542
  if (!instance) return merged.fallback;
347
- return createComponent(SpookyContext.Provider, {
543
+ return createComponent(Sp00kyContext.Provider, {
348
544
  value: instance,
349
545
  get children() {
350
546
  return merged.children;
@@ -356,69 +552,94 @@ function SpookyProvider(props) {
356
552
  //#endregion
357
553
  //#region src/index.ts
358
554
  /**
359
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
360
- * Delegates all logic to the underlying spooky-ts instance
555
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
556
+ * Delegates all logic to the underlying sp00ky-ts instance
361
557
  */
362
558
  var SyncedDb = class {
363
559
  constructor(config) {
364
- this.spooky = null;
560
+ this.sp00ky = null;
365
561
  this._initialized = false;
366
562
  this.config = config;
367
563
  }
368
- getSpooky() {
369
- if (!this.spooky) throw new Error("SyncedDb not initialized");
370
- return this.spooky;
564
+ getSp00ky() {
565
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
566
+ return this.sp00ky;
371
567
  }
372
568
  /**
373
- * Initialize the spooky-ts instance
569
+ * Initialize the sp00ky-ts instance
374
570
  */
375
571
  async init() {
376
572
  if (this._initialized) return;
377
- this.spooky = new SpookyClient(this.config);
378
- await this.spooky.init();
573
+ this.sp00ky = new Sp00kyClient(this.config);
574
+ await this.sp00ky.init();
379
575
  this._initialized = true;
380
576
  }
381
577
  /**
578
+ * Tear down the client: leaves the tabs broker, closes the local store and
579
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
580
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
581
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
582
+ */
583
+ async close() {
584
+ const instance = this.sp00ky;
585
+ this.sp00ky = null;
586
+ this._initialized = false;
587
+ if (instance) await instance.close();
588
+ }
589
+ /**
382
590
  * Create a new record in the database
383
591
  */
384
592
  async create(id, payload) {
385
- if (!this.spooky) throw new Error("SyncedDb not initialized");
386
- await this.spooky.create(id, payload);
593
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
594
+ await this.sp00ky.create(id, payload);
387
595
  }
388
596
  /**
389
597
  * Update an existing record in the database
390
598
  */
391
599
  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);
600
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
601
+ await this.sp00ky.update(tableName, recordId, payload, options);
394
602
  }
395
603
  /**
396
604
  * Delete an existing record in the database
397
605
  */
398
606
  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);
607
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
608
+ const isRecordId = selector instanceof RecordId || selector?.constructor?.name === "RecordId";
609
+ let id;
610
+ if (typeof selector === "string") id = selector;
611
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
612
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
613
+ await this.sp00ky.delete(tableName, id);
614
+ }
615
+ /**
616
+ * Preload/prewarm a built query into the local cache without registering a
617
+ * live view. Fetches once and stores the rows (+ embedded related children)
618
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
619
+ */
620
+ async preload(finalQuery, options) {
621
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
622
+ await this.sp00ky.preload(finalQuery, options);
402
623
  }
403
624
  /**
404
625
  * Query data from the database
405
626
  */
406
627
  query(table) {
407
- if (!this.spooky) throw new Error("SyncedDb not initialized");
408
- return this.spooky.query(table, {});
628
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
629
+ return this.sp00ky.query(table, {});
409
630
  }
410
631
  /**
411
632
  * Run a backend operation
412
633
  */
413
634
  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);
635
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
636
+ await this.sp00ky.run(backend, path, payload, options);
416
637
  }
417
638
  /**
418
639
  * Authenticate with the database
419
640
  */
420
641
  async authenticate(token) {
421
- await this.spooky?.authenticate(token);
642
+ await this.sp00ky?.authenticate(token);
422
643
  return new RecordId("user", "me");
423
644
  }
424
645
  /**
@@ -432,48 +653,81 @@ var SyncedDb = class {
432
653
  * Sign out, clear session and local storage
433
654
  */
434
655
  async signOut() {
435
- if (!this.spooky) throw new Error("SyncedDb not initialized");
436
- await this.spooky.auth.signOut();
656
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
657
+ await this.sp00ky.auth.signOut();
437
658
  }
438
659
  /**
439
660
  * Execute a function with direct access to the remote database connection
440
661
  */
441
662
  async useRemote(fn) {
442
- if (!this.spooky) throw new Error("SyncedDb not initialized");
443
- return await this.spooky.useRemote(fn);
663
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
664
+ return await this.sp00ky.useRemote(fn);
444
665
  }
445
666
  /**
446
667
  * Access the remote database service directly
447
668
  */
448
669
  get remote() {
449
- if (!this.spooky) throw new Error("SyncedDb not initialized");
450
- return this.spooky.remoteClient;
670
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
671
+ return this.sp00ky.remoteClient;
451
672
  }
452
673
  /**
453
674
  * Access the local database service directly
454
675
  */
455
676
  get local() {
456
- if (!this.spooky) throw new Error("SyncedDb not initialized");
457
- return this.spooky.localClient;
677
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
678
+ return this.sp00ky.localClient;
458
679
  }
459
680
  /**
460
681
  * Access the auth service
461
682
  */
462
683
  get auth() {
463
- if (!this.spooky) throw new Error("SyncedDb not initialized");
464
- return this.spooky.auth;
684
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
685
+ return this.sp00ky.auth;
465
686
  }
466
687
  get pendingMutationCount() {
467
- if (!this.spooky) throw new Error("SyncedDb not initialized");
468
- return this.spooky.pendingMutationCount;
688
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
689
+ return this.sp00ky.pendingMutationCount;
690
+ }
691
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
692
+ get liveRetryCount() {
693
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
694
+ return this.sp00ky.liveRetryCount;
469
695
  }
470
696
  subscribeToPendingMutations(cb) {
471
- if (!this.spooky) throw new Error("SyncedDb not initialized");
472
- return this.spooky.subscribeToPendingMutations(cb);
697
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
698
+ return this.sp00ky.subscribeToPendingMutations(cb);
699
+ }
700
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
701
+ get syncHealth() {
702
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
703
+ return this.sp00ky.syncHealth;
704
+ }
705
+ /**
706
+ * Observe sync health. Fires immediately with the current status and again
707
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
708
+ * components; this is the imperative escape hatch.
709
+ */
710
+ subscribeToSyncHealth(cb) {
711
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
712
+ return this.sp00ky.subscribeToSyncHealth(cb);
713
+ }
714
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
715
+ get storageHealth() {
716
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
717
+ return this.sp00ky.storageHealth;
718
+ }
719
+ /**
720
+ * Observe local-store durability. Fires immediately with the current snapshot
721
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
722
+ * is the imperative escape hatch.
723
+ */
724
+ subscribeToStorageHealth(cb) {
725
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
726
+ return this.sp00ky.subscribeToStorageHealth(cb);
473
727
  }
474
728
  bucket(name) {
475
- if (!this.spooky) throw new Error("SyncedDb not initialized");
476
- return this.spooky.bucket(name);
729
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
730
+ return this.sp00ky.bucket(name);
477
731
  }
478
732
  getBucketConfig(name) {
479
733
  return this.config.schema.buckets?.find((b) => b.name === name);
@@ -481,5 +735,5 @@ var SyncedDb = class {
481
735
  };
482
736
 
483
737
  //#endregion
484
- export { RecordId, SpookyProvider, SyncedDb, Uuid, useDb, useDownloadFile, useFileUpload, useQuery };
738
+ export { RecordId, Sp00kyProvider, SyncedDb, Uuid, createPreload, useAppRelease, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, useQuery, useStorageStatus, useSyncStatus };
485
739
  //# sourceMappingURL=index.js.map