@spooky-sync/client-solid 0.0.1-canary.11 → 0.0.1-canary.111

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 } 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,125 @@ 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/use-sync-status.ts
111
+ /**
112
+ * Observe sync health for a "can't reach the server" banner / indicator.
113
+ *
114
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
115
+ * remote 500 on query registration, a dropped socket) are absorbed by the
116
+ * retry and never flip this; `isDegraded()` only goes true once failures
117
+ * persist for the configured number of consecutive rounds (sp00ky core config
118
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
119
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
120
+ */
121
+ function useSyncStatus() {
122
+ const db = useDb();
123
+ const [health, setHealth] = createSignal(db.syncHealth);
124
+ onCleanup(db.subscribeToSyncHealth(setHealth));
125
+ return {
126
+ health,
127
+ status: () => health().status,
128
+ isHealthy: () => health().status === "healthy",
129
+ isDegraded: () => health().status === "degraded",
130
+ everConnected: () => health().everConnected,
131
+ isOffline: () => health().status === "degraded" && health().everConnected
132
+ };
133
+ }
134
+
135
+ //#endregion
136
+ //#region src/lib/use-crdt-field.ts
137
+ function useCrdtField(table, recordId, field, fallbackText) {
138
+ const db = useContext(Sp00kyContext);
139
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
140
+ const [crdtField, setCrdtField] = createSignal(null);
141
+ let currentId;
142
+ let initialized = false;
143
+ createEffect(() => {
144
+ const id = recordId();
145
+ if (initialized && id === currentId) return;
146
+ if (currentId && crdtField()) {
147
+ db.getSp00ky().closeCrdtField(table, currentId, field);
148
+ setCrdtField(null);
149
+ }
150
+ currentId = id;
151
+ initialized = true;
152
+ if (!id) return;
153
+ const sp00ky = db.getSp00ky();
154
+ const text = fallbackText?.();
155
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
156
+ if (currentId === id) setCrdtField(cf);
157
+ }).catch((err) => {
158
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
159
+ });
160
+ });
161
+ onCleanup(() => {
162
+ if (currentId && crdtField()) {
163
+ db.getSp00ky().closeCrdtField(table, currentId, field);
164
+ setCrdtField(null);
165
+ }
166
+ });
167
+ return crdtField;
168
+ }
169
+
170
+ //#endregion
171
+ //#region src/lib/use-feature-flag.ts
172
+ /**
173
+ * Subscribe to a feature flag for the currently authenticated user.
174
+ *
175
+ * Returns three Solid accessors that update reactively whenever the
176
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
177
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
178
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
179
+ *
180
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
181
+ * For multi-variant flags, prefer `variant()` directly.
182
+ */
183
+ function useFeatureFlag(key, options) {
184
+ const handle = useDb().getSp00ky().feature(key, options);
185
+ const [variant, setVariant] = createSignal(handle.variant());
186
+ const [payload, setPayload] = createSignal(handle.payload());
187
+ const unsub = handle.subscribe((s) => {
188
+ setVariant(s.variant ?? options?.fallback);
189
+ setPayload(s.payload);
190
+ });
191
+ onCleanup(() => {
192
+ unsub();
193
+ handle.close();
194
+ });
195
+ return {
196
+ variant,
197
+ payload,
198
+ enabled: () => {
199
+ const v = variant();
200
+ return v !== void 0 && v !== "off";
201
+ }
72
202
  };
73
203
  }
74
204
 
@@ -94,7 +224,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
94
224
  const validate = (file) => {
95
225
  const config = db.getBucketConfig(bucketName);
96
226
  if (!config) return;
97
- if (config.maxSize != null && file.size > config.maxSize) {
227
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
98
228
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
99
229
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
100
230
  }
@@ -203,9 +333,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
203
333
  const [error, setError] = createSignal(null);
204
334
  let currentKey = null;
205
335
  let privateUrl = null;
206
- let refetchTrigger;
207
336
  const [refetchSignal, setRefetchSignal] = createSignal(0);
208
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
337
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
209
338
  async function doDownload(key, filePath) {
210
339
  if (useCache) {
211
340
  const cached = downloadCache.get(key);
@@ -325,8 +454,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
325
454
  }
326
455
 
327
456
  //#endregion
328
- //#region src/lib/SpookyProvider.ts
329
- function SpookyProvider(props) {
457
+ //#region src/lib/Sp00kyProvider.ts
458
+ function Sp00kyProvider(props) {
330
459
  const merged = mergeProps({ fallback: void 0 }, props);
331
460
  const [db, setDb] = createSignal(void 0);
332
461
  onMount(async () => {
@@ -338,13 +467,13 @@ function SpookyProvider(props) {
338
467
  } catch (e) {
339
468
  const error = e instanceof Error ? e : new Error(String(e));
340
469
  if (merged.onError) merged.onError(error);
341
- else console.error("SpookyProvider: Failed to initialize database", error);
470
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
342
471
  }
343
472
  });
344
473
  return createMemo(() => {
345
474
  const instance = db();
346
475
  if (!instance) return merged.fallback;
347
- return createComponent(SpookyContext.Provider, {
476
+ return createComponent(Sp00kyContext.Provider, {
348
477
  value: instance,
349
478
  get children() {
350
479
  return merged.children;
@@ -356,69 +485,73 @@ function SpookyProvider(props) {
356
485
  //#endregion
357
486
  //#region src/index.ts
358
487
  /**
359
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
360
- * Delegates all logic to the underlying spooky-ts instance
488
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
489
+ * Delegates all logic to the underlying sp00ky-ts instance
361
490
  */
362
491
  var SyncedDb = class {
363
492
  constructor(config) {
364
- this.spooky = null;
493
+ this.sp00ky = null;
365
494
  this._initialized = false;
366
495
  this.config = config;
367
496
  }
368
- getSpooky() {
369
- if (!this.spooky) throw new Error("SyncedDb not initialized");
370
- return this.spooky;
497
+ getSp00ky() {
498
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
499
+ return this.sp00ky;
371
500
  }
372
501
  /**
373
- * Initialize the spooky-ts instance
502
+ * Initialize the sp00ky-ts instance
374
503
  */
375
504
  async init() {
376
505
  if (this._initialized) return;
377
- this.spooky = new SpookyClient(this.config);
378
- await this.spooky.init();
506
+ this.sp00ky = new Sp00kyClient(this.config);
507
+ await this.sp00ky.init();
379
508
  this._initialized = true;
380
509
  }
381
510
  /**
382
511
  * Create a new record in the database
383
512
  */
384
513
  async create(id, payload) {
385
- if (!this.spooky) throw new Error("SyncedDb not initialized");
386
- await this.spooky.create(id, payload);
514
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
515
+ await this.sp00ky.create(id, payload);
387
516
  }
388
517
  /**
389
518
  * Update an existing record in the database
390
519
  */
391
520
  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);
521
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
522
+ await this.sp00ky.update(tableName, recordId, payload, options);
394
523
  }
395
524
  /**
396
525
  * Delete an existing record in the database
397
526
  */
398
527
  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);
528
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
529
+ const isRecordId = selector instanceof RecordId || selector?.constructor?.name === "RecordId";
530
+ let id;
531
+ if (typeof selector === "string") id = selector;
532
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
533
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
534
+ await this.sp00ky.delete(tableName, id);
402
535
  }
403
536
  /**
404
537
  * Query data from the database
405
538
  */
406
539
  query(table) {
407
- if (!this.spooky) throw new Error("SyncedDb not initialized");
408
- return this.spooky.query(table, {});
540
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
541
+ return this.sp00ky.query(table, {});
409
542
  }
410
543
  /**
411
544
  * Run a backend operation
412
545
  */
413
546
  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);
547
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
548
+ await this.sp00ky.run(backend, path, payload, options);
416
549
  }
417
550
  /**
418
551
  * Authenticate with the database
419
552
  */
420
553
  async authenticate(token) {
421
- await this.spooky?.authenticate(token);
554
+ await this.sp00ky?.authenticate(token);
422
555
  return new RecordId("user", "me");
423
556
  }
424
557
  /**
@@ -432,48 +565,67 @@ var SyncedDb = class {
432
565
  * Sign out, clear session and local storage
433
566
  */
434
567
  async signOut() {
435
- if (!this.spooky) throw new Error("SyncedDb not initialized");
436
- await this.spooky.auth.signOut();
568
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
569
+ await this.sp00ky.auth.signOut();
437
570
  }
438
571
  /**
439
572
  * Execute a function with direct access to the remote database connection
440
573
  */
441
574
  async useRemote(fn) {
442
- if (!this.spooky) throw new Error("SyncedDb not initialized");
443
- return await this.spooky.useRemote(fn);
575
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
576
+ return await this.sp00ky.useRemote(fn);
444
577
  }
445
578
  /**
446
579
  * Access the remote database service directly
447
580
  */
448
581
  get remote() {
449
- if (!this.spooky) throw new Error("SyncedDb not initialized");
450
- return this.spooky.remoteClient;
582
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
583
+ return this.sp00ky.remoteClient;
451
584
  }
452
585
  /**
453
586
  * Access the local database service directly
454
587
  */
455
588
  get local() {
456
- if (!this.spooky) throw new Error("SyncedDb not initialized");
457
- return this.spooky.localClient;
589
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
590
+ return this.sp00ky.localClient;
458
591
  }
459
592
  /**
460
593
  * Access the auth service
461
594
  */
462
595
  get auth() {
463
- if (!this.spooky) throw new Error("SyncedDb not initialized");
464
- return this.spooky.auth;
596
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
597
+ return this.sp00ky.auth;
465
598
  }
466
599
  get pendingMutationCount() {
467
- if (!this.spooky) throw new Error("SyncedDb not initialized");
468
- return this.spooky.pendingMutationCount;
600
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
601
+ return this.sp00ky.pendingMutationCount;
602
+ }
603
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
604
+ get liveRetryCount() {
605
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
606
+ return this.sp00ky.liveRetryCount;
469
607
  }
470
608
  subscribeToPendingMutations(cb) {
471
- if (!this.spooky) throw new Error("SyncedDb not initialized");
472
- return this.spooky.subscribeToPendingMutations(cb);
609
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
610
+ return this.sp00ky.subscribeToPendingMutations(cb);
611
+ }
612
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
613
+ get syncHealth() {
614
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
615
+ return this.sp00ky.syncHealth;
616
+ }
617
+ /**
618
+ * Observe sync health. Fires immediately with the current status and again
619
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
620
+ * components; this is the imperative escape hatch.
621
+ */
622
+ subscribeToSyncHealth(cb) {
623
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
624
+ return this.sp00ky.subscribeToSyncHealth(cb);
473
625
  }
474
626
  bucket(name) {
475
- if (!this.spooky) throw new Error("SyncedDb not initialized");
476
- return this.spooky.bucket(name);
627
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
628
+ return this.sp00ky.bucket(name);
477
629
  }
478
630
  getBucketConfig(name) {
479
631
  return this.config.schema.buckets?.find((b) => b.name === name);
@@ -481,5 +633,5 @@ var SyncedDb = class {
481
633
  };
482
634
 
483
635
  //#endregion
484
- export { RecordId, SpookyProvider, SyncedDb, Uuid, useDb, useDownloadFile, useFileUpload, useQuery };
636
+ export { RecordId, Sp00kyProvider, SyncedDb, Uuid, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, useQuery, useSyncStatus };
485
637
  //# sourceMappingURL=index.js.map