@spooky-sync/client-solid 0.0.1-canary.1 → 0.0.1-canary.100

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 } from "@spooky-sync/core";
1
+ import { Sp00kyClient, fileToUint8Array } 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,27 +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
- const unsub = await spooky.subscribe(hash, (e) => {
40
- const data = query.isOne ? e[0] : e;
41
- setData(() => data);
42
- setIsFetched(true);
54
+ let isFirstCall = true;
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;
62
+ if (!isFirstCall || hasData) setIsFetched(true);
63
+ isFirstCall = false;
43
64
  }, { immediate: true });
44
- 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;
45
75
  };
46
76
  createEffect(() => {
47
77
  if (!(options?.enabled?.() ?? true)) {
@@ -50,14 +80,18 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
50
80
  }
51
81
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
52
82
  if (!query) return;
53
- const queryString = JSON.stringify(query);
83
+ const queryString = String(query.hash);
54
84
  if (queryString === prevQueryString) return;
55
85
  prevQueryString = queryString;
86
+ const myRun = ++runId;
87
+ teardownActive();
56
88
  setIsFetched(false);
57
- initQuery(query);
58
- onCleanup(() => {
59
- unsubscribe?.();
60
- });
89
+ initQuery(query, myRun);
90
+ });
91
+ onCleanup(() => {
92
+ runId++;
93
+ teardownActive();
94
+ if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
61
95
  });
62
96
  const isLoading = () => {
63
97
  return !isFetched() && error() === void 0;
@@ -65,13 +99,359 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
65
99
  return {
66
100
  data,
67
101
  error,
68
- isLoading
102
+ isLoading,
103
+ isFetching
104
+ };
105
+ }
106
+
107
+ //#endregion
108
+ //#region src/lib/use-sync-status.ts
109
+ /**
110
+ * Observe sync health for a "can't reach the server" banner / indicator.
111
+ *
112
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
113
+ * remote 500 on query registration, a dropped socket) are absorbed by the
114
+ * retry and never flip this; `isDegraded()` only goes true once failures
115
+ * persist for the configured number of consecutive rounds (sp00ky core config
116
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
117
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
118
+ */
119
+ function useSyncStatus() {
120
+ const db = useDb();
121
+ const [health, setHealth] = createSignal(db.syncHealth);
122
+ onCleanup(db.subscribeToSyncHealth(setHealth));
123
+ return {
124
+ health,
125
+ status: () => health().status,
126
+ isHealthy: () => health().status === "healthy",
127
+ isDegraded: () => health().status === "degraded"
128
+ };
129
+ }
130
+
131
+ //#endregion
132
+ //#region src/lib/use-crdt-field.ts
133
+ function useCrdtField(table, recordId, field, fallbackText) {
134
+ const db = useContext(Sp00kyContext);
135
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
136
+ const [crdtField, setCrdtField] = createSignal(null);
137
+ let currentId;
138
+ let initialized = false;
139
+ createEffect(() => {
140
+ const id = recordId();
141
+ if (initialized && id === currentId) return;
142
+ if (currentId && crdtField()) {
143
+ db.getSp00ky().closeCrdtField(table, currentId, field);
144
+ setCrdtField(null);
145
+ }
146
+ currentId = id;
147
+ initialized = true;
148
+ if (!id) return;
149
+ const sp00ky = db.getSp00ky();
150
+ const text = fallbackText?.();
151
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
152
+ if (currentId === id) setCrdtField(cf);
153
+ }).catch((err) => {
154
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
155
+ });
156
+ });
157
+ onCleanup(() => {
158
+ if (currentId && crdtField()) {
159
+ db.getSp00ky().closeCrdtField(table, currentId, field);
160
+ setCrdtField(null);
161
+ }
162
+ });
163
+ return crdtField;
164
+ }
165
+
166
+ //#endregion
167
+ //#region src/lib/use-feature-flag.ts
168
+ /**
169
+ * Subscribe to a feature flag for the currently authenticated user.
170
+ *
171
+ * Returns three Solid accessors that update reactively whenever the
172
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
173
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
174
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
175
+ *
176
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
177
+ * For multi-variant flags, prefer `variant()` directly.
178
+ */
179
+ function useFeatureFlag(key, options) {
180
+ const handle = useDb().getSp00ky().feature(key, options);
181
+ const [variant, setVariant] = createSignal(handle.variant());
182
+ const [payload, setPayload] = createSignal(handle.payload());
183
+ const unsub = handle.subscribe((s) => {
184
+ setVariant(s.variant ?? options?.fallback);
185
+ setPayload(s.payload);
186
+ });
187
+ onCleanup(() => {
188
+ unsub();
189
+ handle.close();
190
+ });
191
+ return {
192
+ variant,
193
+ payload,
194
+ enabled: () => {
195
+ const v = variant();
196
+ return v !== void 0 && v !== "off";
197
+ }
198
+ };
199
+ }
200
+
201
+ //#endregion
202
+ //#region src/lib/use-file-upload.ts
203
+ function useFileUpload(dbOrBucketName, maybeBucketName) {
204
+ let db;
205
+ let bucketName;
206
+ if (typeof dbOrBucketName === "string") {
207
+ db = useDb();
208
+ bucketName = dbOrBucketName;
209
+ } else {
210
+ db = dbOrBucketName;
211
+ bucketName = maybeBucketName;
212
+ }
213
+ const [isUploading, setIsUploading] = createSignal(false);
214
+ const [error, setError] = createSignal(null);
215
+ const objectUrls = [];
216
+ onCleanup(() => {
217
+ for (const url of objectUrls) URL.revokeObjectURL(url);
218
+ });
219
+ const clearError = () => setError(null);
220
+ const validate = (file) => {
221
+ const config = db.getBucketConfig(bucketName);
222
+ if (!config) return;
223
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
224
+ const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
225
+ throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
226
+ }
227
+ if (config.allowedExtensions && config.allowedExtensions.length > 0) {
228
+ const fileName = file.name;
229
+ if (fileName) {
230
+ const ext = fileName.split(".").pop()?.toLowerCase();
231
+ if (!ext || !config.allowedExtensions.includes(ext)) throw new Error(`File type not allowed. Accepted: ${config.allowedExtensions.join(", ")}.`);
232
+ }
233
+ }
234
+ };
235
+ const upload = async (path, file) => {
236
+ setError(null);
237
+ try {
238
+ validate(file);
239
+ } catch (e) {
240
+ setError(e instanceof Error ? e : new Error(String(e)));
241
+ return;
242
+ }
243
+ setIsUploading(true);
244
+ try {
245
+ const bytes = await fileToUint8Array(file);
246
+ await db.bucket(bucketName).put(path, bytes);
247
+ } catch (e) {
248
+ setError(e instanceof Error ? e : new Error(String(e)));
249
+ } finally {
250
+ setIsUploading(false);
251
+ }
252
+ };
253
+ const download = async (path) => {
254
+ setError(null);
255
+ try {
256
+ const content = await db.bucket(bucketName).get(path);
257
+ if (!content) return null;
258
+ const objectUrl = URL.createObjectURL(new Blob([content]));
259
+ objectUrls.push(objectUrl);
260
+ return objectUrl;
261
+ } catch (e) {
262
+ setError(e instanceof Error ? e : new Error(String(e)));
263
+ return null;
264
+ }
265
+ };
266
+ const remove = async (path) => {
267
+ setError(null);
268
+ try {
269
+ await db.bucket(bucketName).delete(path);
270
+ } catch (e) {
271
+ setError(e instanceof Error ? e : new Error(String(e)));
272
+ }
273
+ };
274
+ const exists = async (path) => {
275
+ setError(null);
276
+ try {
277
+ return await db.bucket(bucketName).exists(path);
278
+ } catch (e) {
279
+ setError(e instanceof Error ? e : new Error(String(e)));
280
+ return false;
281
+ }
282
+ };
283
+ return {
284
+ isUploading,
285
+ error,
286
+ clearError,
287
+ upload,
288
+ download,
289
+ remove,
290
+ exists
69
291
  };
70
292
  }
71
293
 
72
294
  //#endregion
73
- //#region src/lib/SpookyProvider.ts
74
- function SpookyProvider(props) {
295
+ //#region src/lib/use-download-file.ts
296
+ const downloadCache = /* @__PURE__ */ new Map();
297
+ const inflightRequests = /* @__PURE__ */ new Map();
298
+ function cacheKey(bucket, path) {
299
+ return `${bucket}:${path}`;
300
+ }
301
+ function releaseEntry(key) {
302
+ const entry = downloadCache.get(key);
303
+ if (!entry) return;
304
+ entry.refCount--;
305
+ if (entry.refCount <= 0) {
306
+ URL.revokeObjectURL(entry.url);
307
+ downloadCache.delete(key);
308
+ }
309
+ }
310
+ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
311
+ let db;
312
+ let bucketName;
313
+ let path;
314
+ let options;
315
+ if (typeof dbOrBucketName === "string") {
316
+ db = useDb();
317
+ bucketName = dbOrBucketName;
318
+ path = bucketNameOrPath;
319
+ options = pathOrOptions ?? {};
320
+ } else {
321
+ db = dbOrBucketName;
322
+ bucketName = bucketNameOrPath;
323
+ path = pathOrOptions;
324
+ options = maybeOptions ?? {};
325
+ }
326
+ const useCache = options.cache !== false;
327
+ const [url, setUrl] = createSignal(null);
328
+ const [isLoading, setIsLoading] = createSignal(false);
329
+ const [error, setError] = createSignal(null);
330
+ let currentKey = null;
331
+ let privateUrl = null;
332
+ const [refetchSignal, setRefetchSignal] = createSignal(0);
333
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
334
+ async function doDownload(key, filePath) {
335
+ if (useCache) {
336
+ const cached = downloadCache.get(key);
337
+ if (cached) {
338
+ cached.refCount++;
339
+ currentKey = key;
340
+ return cached.url;
341
+ }
342
+ const inflight = inflightRequests.get(key);
343
+ if (inflight) {
344
+ const result = await inflight;
345
+ if (result) {
346
+ const entry = downloadCache.get(key);
347
+ if (entry) {
348
+ entry.refCount++;
349
+ currentKey = key;
350
+ }
351
+ }
352
+ return result;
353
+ }
354
+ const promise = (async () => {
355
+ const content = await db.bucket(bucketName).get(filePath);
356
+ if (!content) return null;
357
+ const objectUrl = URL.createObjectURL(new Blob([content]));
358
+ downloadCache.set(key, {
359
+ url: objectUrl,
360
+ refCount: 1
361
+ });
362
+ return objectUrl;
363
+ })();
364
+ inflightRequests.set(key, promise);
365
+ try {
366
+ const result = await promise;
367
+ currentKey = key;
368
+ return result;
369
+ } finally {
370
+ inflightRequests.delete(key);
371
+ }
372
+ } else {
373
+ const content = await db.bucket(bucketName).get(filePath);
374
+ if (!content) return null;
375
+ const objectUrl = URL.createObjectURL(new Blob([content]));
376
+ privateUrl = objectUrl;
377
+ return objectUrl;
378
+ }
379
+ }
380
+ function releaseCurrentEntry() {
381
+ if (useCache && currentKey) {
382
+ releaseEntry(currentKey);
383
+ currentKey = null;
384
+ }
385
+ if (!useCache && privateUrl) {
386
+ URL.revokeObjectURL(privateUrl);
387
+ privateUrl = null;
388
+ }
389
+ }
390
+ createEffect(() => {
391
+ const filePath = path();
392
+ refetchSignal();
393
+ releaseCurrentEntry();
394
+ if (!filePath) {
395
+ setUrl(null);
396
+ setIsLoading(false);
397
+ setError(null);
398
+ return;
399
+ }
400
+ const key = cacheKey(bucketName, filePath);
401
+ if (useCache) {
402
+ const cached = downloadCache.get(key);
403
+ if (cached) {
404
+ cached.refCount++;
405
+ currentKey = key;
406
+ setUrl(cached.url);
407
+ setIsLoading(false);
408
+ setError(null);
409
+ return;
410
+ }
411
+ }
412
+ let cancelled = false;
413
+ setIsLoading(true);
414
+ setError(null);
415
+ doDownload(key, filePath).then((result) => {
416
+ if (!cancelled) {
417
+ setUrl(result);
418
+ setIsLoading(false);
419
+ }
420
+ }, (err) => {
421
+ if (!cancelled) {
422
+ setError(err instanceof Error ? err : new Error(String(err)));
423
+ setIsLoading(false);
424
+ }
425
+ });
426
+ onCleanup(() => {
427
+ cancelled = true;
428
+ });
429
+ });
430
+ onCleanup(() => {
431
+ releaseCurrentEntry();
432
+ });
433
+ const refetch = () => {
434
+ if (useCache && currentKey) {
435
+ const entry = downloadCache.get(currentKey);
436
+ if (entry) {
437
+ URL.revokeObjectURL(entry.url);
438
+ downloadCache.delete(currentKey);
439
+ }
440
+ currentKey = null;
441
+ }
442
+ refetchTrigger();
443
+ };
444
+ return {
445
+ url,
446
+ isLoading,
447
+ error,
448
+ refetch
449
+ };
450
+ }
451
+
452
+ //#endregion
453
+ //#region src/lib/Sp00kyProvider.ts
454
+ function Sp00kyProvider(props) {
75
455
  const merged = mergeProps({ fallback: void 0 }, props);
76
456
  const [db, setDb] = createSignal(void 0);
77
457
  onMount(async () => {
@@ -83,13 +463,13 @@ function SpookyProvider(props) {
83
463
  } catch (e) {
84
464
  const error = e instanceof Error ? e : new Error(String(e));
85
465
  if (merged.onError) merged.onError(error);
86
- else console.error("SpookyProvider: Failed to initialize database", error);
466
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
87
467
  }
88
468
  });
89
469
  return createMemo(() => {
90
470
  const instance = db();
91
471
  if (!instance) return merged.fallback;
92
- return createComponent(SpookyContext.Provider, {
472
+ return createComponent(Sp00kyContext.Provider, {
93
473
  value: instance,
94
474
  get children() {
95
475
  return merged.children;
@@ -101,69 +481,73 @@ function SpookyProvider(props) {
101
481
  //#endregion
102
482
  //#region src/index.ts
103
483
  /**
104
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
105
- * Delegates all logic to the underlying spooky-ts instance
484
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
485
+ * Delegates all logic to the underlying sp00ky-ts instance
106
486
  */
107
487
  var SyncedDb = class {
108
488
  constructor(config) {
109
- this.spooky = null;
489
+ this.sp00ky = null;
110
490
  this._initialized = false;
111
491
  this.config = config;
112
492
  }
113
- getSpooky() {
114
- if (!this.spooky) throw new Error("SyncedDb not initialized");
115
- return this.spooky;
493
+ getSp00ky() {
494
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
495
+ return this.sp00ky;
116
496
  }
117
497
  /**
118
- * Initialize the spooky-ts instance
498
+ * Initialize the sp00ky-ts instance
119
499
  */
120
500
  async init() {
121
501
  if (this._initialized) return;
122
- this.spooky = new SpookyClient(this.config);
123
- await this.spooky.init();
502
+ this.sp00ky = new Sp00kyClient(this.config);
503
+ await this.sp00ky.init();
124
504
  this._initialized = true;
125
505
  }
126
506
  /**
127
507
  * Create a new record in the database
128
508
  */
129
509
  async create(id, payload) {
130
- if (!this.spooky) throw new Error("SyncedDb not initialized");
131
- await this.spooky.create(id, payload);
510
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
511
+ await this.sp00ky.create(id, payload);
132
512
  }
133
513
  /**
134
514
  * Update an existing record in the database
135
515
  */
136
516
  async update(tableName, recordId, payload, options) {
137
- if (!this.spooky) throw new Error("SyncedDb not initialized");
138
- await this.spooky.update(tableName, recordId, payload, options);
517
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
518
+ await this.sp00ky.update(tableName, recordId, payload, options);
139
519
  }
140
520
  /**
141
521
  * Delete an existing record in the database
142
522
  */
143
523
  async delete(tableName, selector) {
144
- if (!this.spooky) throw new Error("SyncedDb not initialized");
145
- if (typeof selector !== "string") throw new Error("Only string ID selectors are supported currently with core");
146
- await this.spooky.delete(tableName, selector);
524
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
525
+ const isRecordId = selector instanceof RecordId || selector?.constructor?.name === "RecordId";
526
+ let id;
527
+ if (typeof selector === "string") id = selector;
528
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
529
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
530
+ await this.sp00ky.delete(tableName, id);
147
531
  }
148
532
  /**
149
533
  * Query data from the database
150
534
  */
151
535
  query(table) {
152
- if (!this.spooky) throw new Error("SyncedDb not initialized");
153
- return this.spooky.query(table, {});
536
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
537
+ return this.sp00ky.query(table, {});
154
538
  }
155
539
  /**
156
540
  * Run a backend operation
157
541
  */
158
542
  async run(backend, path, payload, options) {
159
- if (!this.spooky) throw new Error("SyncedDb not initialized");
160
- await this.spooky.run(backend, path, payload, options);
543
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
544
+ await this.sp00ky.run(backend, path, payload, options);
161
545
  }
162
546
  /**
163
547
  * Authenticate with the database
164
548
  */
165
549
  async authenticate(token) {
166
- await this.spooky?.authenticate(token);
550
+ await this.sp00ky?.authenticate(token);
167
551
  return new RecordId("user", "me");
168
552
  }
169
553
  /**
@@ -177,47 +561,73 @@ var SyncedDb = class {
177
561
  * Sign out, clear session and local storage
178
562
  */
179
563
  async signOut() {
180
- if (!this.spooky) throw new Error("SyncedDb not initialized");
181
- await this.spooky.auth.signOut();
564
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
565
+ await this.sp00ky.auth.signOut();
182
566
  }
183
567
  /**
184
568
  * Execute a function with direct access to the remote database connection
185
569
  */
186
570
  async useRemote(fn) {
187
- if (!this.spooky) throw new Error("SyncedDb not initialized");
188
- return await this.spooky.useRemote(fn);
571
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
572
+ return await this.sp00ky.useRemote(fn);
189
573
  }
190
574
  /**
191
575
  * Access the remote database service directly
192
576
  */
193
577
  get remote() {
194
- if (!this.spooky) throw new Error("SyncedDb not initialized");
195
- return this.spooky.remoteClient;
578
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
579
+ return this.sp00ky.remoteClient;
196
580
  }
197
581
  /**
198
582
  * Access the local database service directly
199
583
  */
200
584
  get local() {
201
- if (!this.spooky) throw new Error("SyncedDb not initialized");
202
- return this.spooky.localClient;
585
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
586
+ return this.sp00ky.localClient;
203
587
  }
204
588
  /**
205
589
  * Access the auth service
206
590
  */
207
591
  get auth() {
208
- if (!this.spooky) throw new Error("SyncedDb not initialized");
209
- return this.spooky.auth;
592
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
593
+ return this.sp00ky.auth;
210
594
  }
211
595
  get pendingMutationCount() {
212
- if (!this.spooky) throw new Error("SyncedDb not initialized");
213
- return this.spooky.pendingMutationCount;
596
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
597
+ return this.sp00ky.pendingMutationCount;
598
+ }
599
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
600
+ get liveRetryCount() {
601
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
602
+ return this.sp00ky.liveRetryCount;
214
603
  }
215
604
  subscribeToPendingMutations(cb) {
216
- if (!this.spooky) throw new Error("SyncedDb not initialized");
217
- return this.spooky.subscribeToPendingMutations(cb);
605
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
606
+ return this.sp00ky.subscribeToPendingMutations(cb);
607
+ }
608
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
609
+ get syncHealth() {
610
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
611
+ return this.sp00ky.syncHealth;
612
+ }
613
+ /**
614
+ * Observe sync health. Fires immediately with the current status and again
615
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
616
+ * components; this is the imperative escape hatch.
617
+ */
618
+ subscribeToSyncHealth(cb) {
619
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
620
+ return this.sp00ky.subscribeToSyncHealth(cb);
621
+ }
622
+ bucket(name) {
623
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
624
+ return this.sp00ky.bucket(name);
625
+ }
626
+ getBucketConfig(name) {
627
+ return this.config.schema.buckets?.find((b) => b.name === name);
218
628
  }
219
629
  };
220
630
 
221
631
  //#endregion
222
- export { RecordId, SpookyProvider, SyncedDb, Uuid, useDb, useQuery };
632
+ export { RecordId, Sp00kyProvider, SyncedDb, Uuid, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, useQuery, useSyncStatus };
223
633
  //# sourceMappingURL=index.js.map