@spooky-sync/client-solid 0.0.1-canary.13 → 0.0.1-canary.130

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/AGENTS.md ADDED
@@ -0,0 +1,68 @@
1
+ # `@spooky-sync/client-solid` — agent guide
2
+
3
+ ## What this package is
4
+
5
+ The SolidJS binding for sp00ky. Exposes a `Sp00kyProvider` that initializes a `Sp00kyClient` and a set of reactive hooks (`useDb`, `useQuery`, `useCrdtField`, `useFileUpload`, `useDownloadFile`). All hooks expect to be called inside a `<Sp00kyProvider>` boundary.
6
+
7
+ ## Setup pattern
8
+
9
+ ```ts
10
+ // db.ts
11
+ import type { SyncedDbConfig } from '@spooky-sync/client-solid';
12
+ import { schema, SURQL_SCHEMA } from './schema.gen'; // generated by `spky generate`
13
+
14
+ export const dbConfig: SyncedDbConfig<typeof schema> = {
15
+ schema,
16
+ schemaSurql: SURQL_SCHEMA,
17
+ database: {
18
+ namespace: 'main',
19
+ database: 'app',
20
+ endpoint: 'ws://localhost:8666/rpc',
21
+ store: 'memory', // or 'indexeddb' for persistence
22
+ persistenceClient: 'localstorage',
23
+ },
24
+ };
25
+ ```
26
+
27
+ ```tsx
28
+ // App.tsx
29
+ <Sp00kyProvider config={dbConfig}>{/* app */}</Sp00kyProvider>
30
+ ```
31
+
32
+ ## Key hooks
33
+
34
+ - **`useDb<typeof schema>()`** — returns the `SyncedDb<S>` instance. Methods:
35
+ - `db.create(id, payload)` — `id` is a full record ID like `'thread:abc'`.
36
+ - `db.update(table, id, payload, options?)` — `options.debounced` coalesces updates.
37
+ - `db.delete(table, idOrSelector)`.
38
+ - `db.query(table)` — returns a `QueryBuilder`. Chain `.related()`, `.orderBy()`, `.limit()`, etc., end with `.build()`.
39
+ - `db.preload(query, options?)` — cache-aware, awaitable prewarm. Cold (nothing cached in the bucket) fetches + persists and the promise awaits it (blocks); warm returns instantly. `options.refresh` (`'onUse'` default / `'background'` / `'stale'`) + `options.staleTime` control warm-refresh. One-shot snapshot, no live view — freshens on use.
40
+ - `db.run(backend, route, payload)` — call a backend RPC route.
41
+ - `db.bucket(name)` — get a `BucketHandle` for file storage.
42
+ - `db.useRemote(fn)` — escape hatch to the raw `Surreal` client (skips cache).
43
+ - `db.authenticate(token)`, `db.signOut()`, `db.auth`.
44
+ - `db.pendingMutationCount`, `db.subscribeToPendingMutations(cb)`.
45
+ - **`useQuery(() => db.query(...).build())`** — reactive query. Returns `{ data, status, error, ... }` accessors. The factory function is tracked, so passing reactive params (signals) re-runs the query.
46
+ - **`createPreload(() => db.query(...).build(), options?)`** — reactive, fire-and-forget prewarm (same overloads/`enabled` as `useQuery`, plus `refresh`/`staleTime`). Warms a query the user is likely to open next (e.g. a list row's detail) into the local cache. For blocking first-load, use the `Sp00kyProvider` `preload` prop instead.
47
+ - **`useCrdtField(table, () => recordId, field, () => valueAccessor)`** — wires a CRDT text field to a Loro doc. Pair with `db.update(table, id, { [field]: newValue }, { debounced: true })` so rapid keystrokes don't flood the queue. *All four arguments take accessor functions where reactive — that's deliberate, for SolidJS tracking.*
48
+ - **`useFileUpload()`** / **`useDownloadFile()`** — bucket helpers; the upload result includes the storage path you write into a record column.
49
+
50
+ ## Re-exports for convenience
51
+
52
+ - `RecordId`, `Uuid` from `surrealdb`.
53
+ - Query-builder types: `TableModel`, `TableNames`, `GetTable`, `QueryResult`, etc. (see `@spooky-sync/query-builder/AGENTS.md`).
54
+ - `Model<S, T>`, `GenericModel`, `ModelPayload` — typed row shapes.
55
+
56
+ ## Common gotchas
57
+
58
+ - **`useDb()` requires `<typeof schema>`.** Without the generic, all calls fall back to `unknown` and you lose type safety.
59
+ - **CRDT fields are not regular columns.** Don't read or write them via `useQuery` — read with `useCrdtField`, write via `db.update` with `{ debounced: true }`.
60
+ - **Generate IDs explicitly.** `const id = new Uuid().toString()`, then `db.create(\`thread:\${id}\`, ...)`. SurrealDB's auto-id only fires on direct DB writes, not through the sync queue.
61
+ - **`useQuery` factories must call `.build()` (or `.all()`, `.first()`, etc.).** A bare `db.query('thread')` is a builder, not a query — `useQuery` will throw or return forever-loading.
62
+ - **Provider is mandatory.** Calling any hook outside `<Sp00kyProvider>` throws.
63
+
64
+ ## Pointers
65
+
66
+ - Sync engine: `node_modules/@spooky-sync/core/AGENTS.md`
67
+ - Query builder DSL: `node_modules/@spooky-sync/query-builder/AGENTS.md`
68
+ - Schema authoring + codegen: `node_modules/@spooky-sync/cli/AGENTS.md`
package/README.md CHANGED
@@ -11,6 +11,7 @@ A SurrealDB client for Solid.js with automatic cache synchronization and live qu
11
11
  - **Type-Safe**: Full TypeScript support with generated schema types
12
12
  - **Reactive**: Seamless integration with Solid.js reactivity
13
13
  - **Offline Support**: Local cache works even when remote is temporarily unavailable
14
+ - **Preload / Prewarm**: Warm data into the local cache ahead of time so screens open instantly — awaitable to gate first load, reactive to prefetch what's next
14
15
 
15
16
  ## Quick Start
16
17
 
@@ -215,6 +216,25 @@ const liveQuery = await db.query.thread
215
216
  .orderBy('created_at', 'desc'); // ✅ Field names autocompleted
216
217
  ```
217
218
 
219
+ ### Preload / Prewarm
220
+
221
+ Warm a query's results into the local cache before they're needed, so a later `useQuery` paints
222
+ instantly instead of waiting on the network. Preload is a one-shot snapshot — it does not register
223
+ a live view; the data freshens on use when the real `useQuery` mounts.
224
+
225
+ ```typescript
226
+ // Awaitable + cache-aware: blocks on the FIRST load (nothing cached), returns
227
+ // instantly on later loads (already cached). Great for gating the UI on config.
228
+ await db.preload(db.query('config').build());
229
+
230
+ // Opt into a one-time background refresh when the cache is stale:
231
+ await db.preload(db.query('config').build(), { refresh: 'stale', staleTime: '1d' });
232
+ ```
233
+
234
+ Block first-load UI on essential data via the `Sp00kyProvider` `preload` prop, or reactively
235
+ prefetch what the user is likely to open next with `createPreload(() => …build())`. Full details
236
+ in the [SKILL guide](./skills/sp00ky-solid/SKILL.md#preload).
237
+
218
238
  ## Documentation
219
239
 
220
240
  - **[Quick Start Guide](./QUICK_START.md)**: Get up and running quickly
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,56 @@ 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
+ const initQuery = async (query, myRun) => {
38
51
  const { hash } = await query.run();
52
+ if (myRun !== runId) return;
53
+ activeHash = hash;
39
54
  setError(void 0);
40
55
  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;
56
+ const unsub = await sp00ky.subscribe(hash, (e) => {
57
+ const queryData = query.isOne ? e[0] : e;
58
+ const reconcileStart = performance.now();
59
+ setState("value", (0, solid_js_store.reconcile)(queryData, { key: "id" }));
60
+ setVersion((v) => v + 1);
61
+ sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
62
+ const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
45
63
  if (!isFirstCall || hasData) setIsFetched(true);
46
64
  isFirstCall = false;
47
65
  }, { immediate: true });
48
- setUnsubscribe(() => unsub);
66
+ const unsubStatus = sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true });
67
+ const teardown = () => {
68
+ unsub();
69
+ unsubStatus();
70
+ };
71
+ if (myRun !== runId) {
72
+ teardown();
73
+ return;
74
+ }
75
+ activeUnsub = teardown;
49
76
  };
50
77
  (0, solid_js.createEffect)(() => {
51
78
  if (!(options?.enabled?.() ?? true)) {
@@ -54,22 +81,165 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
54
81
  }
55
82
  const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
56
83
  if (!query) return;
57
- const queryString = JSON.stringify(query);
84
+ const queryString = String(query.hash);
58
85
  if (queryString === prevQueryString) return;
59
86
  prevQueryString = queryString;
87
+ const myRun = ++runId;
88
+ teardownActive();
60
89
  setIsFetched(false);
61
- initQuery(query);
62
- (0, solid_js.onCleanup)(() => {
63
- unsubscribe()?.();
64
- });
90
+ initQuery(query, myRun);
91
+ });
92
+ (0, solid_js.onCleanup)(() => {
93
+ runId++;
94
+ teardownActive();
95
+ if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
65
96
  });
66
97
  const isLoading = () => {
67
98
  return !isFetched() && error() === void 0;
68
99
  };
100
+ const isSettled = () => isFetched() && !isFetching();
69
101
  return {
70
102
  data,
71
103
  error,
72
- isLoading
104
+ isLoading,
105
+ isFetching,
106
+ isSettled
107
+ };
108
+ }
109
+
110
+ //#endregion
111
+ //#region src/lib/create-preload.ts
112
+ /**
113
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
114
+ * function so it tracks reactive deps), dedupes on the query's stable identity
115
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
116
+ * no cleanup: preload registers nothing that needs tearing down.
117
+ *
118
+ * Typical use: inside a list row, preload the detail query the user is likely
119
+ * to open next, so navigation paints from cache instead of the network.
120
+ */
121
+ function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
122
+ let db;
123
+ let finalQuery;
124
+ let options;
125
+ if (dbOrQuery instanceof SyncedDb) {
126
+ db = dbOrQuery;
127
+ finalQuery = queryOrOptions;
128
+ options = maybeOptions;
129
+ } else {
130
+ const contextDb = (0, solid_js.useContext)(Sp00kyContext);
131
+ 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>.");
132
+ db = contextDb;
133
+ finalQuery = dbOrQuery;
134
+ options = queryOrOptions;
135
+ }
136
+ let prevHash;
137
+ (0, solid_js.createEffect)(() => {
138
+ if (!(options?.enabled?.() ?? true)) return;
139
+ const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
140
+ if (!query) return;
141
+ if (query.hash === prevHash) return;
142
+ prevHash = query.hash;
143
+ db.getSp00ky().preload(query, {
144
+ refresh: options?.refresh,
145
+ staleTime: options?.staleTime
146
+ });
147
+ });
148
+ }
149
+
150
+ //#endregion
151
+ //#region src/lib/use-sync-status.ts
152
+ /**
153
+ * Observe sync health for a "can't reach the server" banner / indicator.
154
+ *
155
+ * Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
156
+ * remote 500 on query registration, a dropped socket) are absorbed by the
157
+ * retry and never flip this; `isDegraded()` only goes true once failures
158
+ * persist for the configured number of consecutive rounds (sp00ky core config
159
+ * `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
160
+ * the next successful round. Must be used within a `<Sp00kyProvider>`.
161
+ */
162
+ function useSyncStatus() {
163
+ const db = useDb();
164
+ const [health, setHealth] = (0, solid_js.createSignal)(db.syncHealth);
165
+ (0, solid_js.onCleanup)(db.subscribeToSyncHealth(setHealth));
166
+ return {
167
+ health,
168
+ status: () => health().status,
169
+ isHealthy: () => health().status === "healthy",
170
+ isDegraded: () => health().status === "degraded",
171
+ everConnected: () => health().everConnected,
172
+ isOffline: () => health().status === "degraded" && health().everConnected
173
+ };
174
+ }
175
+
176
+ //#endregion
177
+ //#region src/lib/use-crdt-field.ts
178
+ function useCrdtField(table, recordId, field, fallbackText) {
179
+ const db = (0, solid_js.useContext)(Sp00kyContext);
180
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
181
+ const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
182
+ let currentId;
183
+ let initialized = false;
184
+ (0, solid_js.createEffect)(() => {
185
+ const id = recordId();
186
+ if (initialized && id === currentId) return;
187
+ if (currentId && crdtField()) {
188
+ db.getSp00ky().closeCrdtField(table, currentId, field);
189
+ setCrdtField(null);
190
+ }
191
+ currentId = id;
192
+ initialized = true;
193
+ if (!id) return;
194
+ const sp00ky = db.getSp00ky();
195
+ const text = fallbackText?.();
196
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
197
+ if (currentId === id) setCrdtField(cf);
198
+ }).catch((err) => {
199
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
200
+ });
201
+ });
202
+ (0, solid_js.onCleanup)(() => {
203
+ if (currentId && crdtField()) {
204
+ db.getSp00ky().closeCrdtField(table, currentId, field);
205
+ setCrdtField(null);
206
+ }
207
+ });
208
+ return crdtField;
209
+ }
210
+
211
+ //#endregion
212
+ //#region src/lib/use-feature-flag.ts
213
+ /**
214
+ * Subscribe to a feature flag for the currently authenticated user.
215
+ *
216
+ * Returns three Solid accessors that update reactively whenever the
217
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
218
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
219
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
220
+ *
221
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
222
+ * For multi-variant flags, prefer `variant()` directly.
223
+ */
224
+ function useFeatureFlag(key, options) {
225
+ const handle = useDb().getSp00ky().feature(key, options);
226
+ const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
227
+ const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
228
+ const unsub = handle.subscribe((s) => {
229
+ setVariant(s.variant ?? options?.fallback);
230
+ setPayload(s.payload);
231
+ });
232
+ (0, solid_js.onCleanup)(() => {
233
+ unsub();
234
+ handle.close();
235
+ });
236
+ return {
237
+ variant,
238
+ payload,
239
+ enabled: () => {
240
+ const v = variant();
241
+ return v !== void 0 && v !== "off";
242
+ }
73
243
  };
74
244
  }
75
245
 
@@ -95,7 +265,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
95
265
  const validate = (file) => {
96
266
  const config = db.getBucketConfig(bucketName);
97
267
  if (!config) return;
98
- if (config.maxSize != null && file.size > config.maxSize) {
268
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
99
269
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
100
270
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
101
271
  }
@@ -204,9 +374,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
204
374
  const [error, setError] = (0, solid_js.createSignal)(null);
205
375
  let currentKey = null;
206
376
  let privateUrl = null;
207
- let refetchTrigger;
208
377
  const [refetchSignal, setRefetchSignal] = (0, solid_js.createSignal)(0);
209
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
378
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
210
379
  async function doDownload(key, filePath) {
211
380
  if (useCache) {
212
381
  const cached = downloadCache.get(key);
@@ -326,26 +495,31 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
326
495
  }
327
496
 
328
497
  //#endregion
329
- //#region src/lib/SpookyProvider.ts
330
- function SpookyProvider(props) {
498
+ //#region src/lib/Sp00kyProvider.ts
499
+ function Sp00kyProvider(props) {
331
500
  const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
332
501
  const [db, setDb] = (0, solid_js.createSignal)(void 0);
333
502
  (0, solid_js.onMount)(async () => {
334
503
  try {
335
504
  const instance = new SyncedDb(merged.config);
336
505
  await instance.init();
506
+ if (merged.preload) try {
507
+ await merged.preload(instance);
508
+ } catch (e) {
509
+ console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
510
+ }
337
511
  setDb(() => instance);
338
512
  merged.onReady?.(instance);
339
513
  } catch (e) {
340
514
  const error = e instanceof Error ? e : new Error(String(e));
341
515
  if (merged.onError) merged.onError(error);
342
- else console.error("SpookyProvider: Failed to initialize database", error);
516
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
343
517
  }
344
518
  });
345
519
  return (0, solid_js.createMemo)(() => {
346
520
  const instance = db();
347
521
  if (!instance) return merged.fallback;
348
- return (0, solid_js.createComponent)(SpookyContext.Provider, {
522
+ return (0, solid_js.createComponent)(Sp00kyContext.Provider, {
349
523
  value: instance,
350
524
  get children() {
351
525
  return merged.children;
@@ -357,69 +531,82 @@ function SpookyProvider(props) {
357
531
  //#endregion
358
532
  //#region src/index.ts
359
533
  /**
360
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
361
- * Delegates all logic to the underlying spooky-ts instance
534
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
535
+ * Delegates all logic to the underlying sp00ky-ts instance
362
536
  */
363
537
  var SyncedDb = class {
364
538
  constructor(config) {
365
- this.spooky = null;
539
+ this.sp00ky = null;
366
540
  this._initialized = false;
367
541
  this.config = config;
368
542
  }
369
- getSpooky() {
370
- if (!this.spooky) throw new Error("SyncedDb not initialized");
371
- return this.spooky;
543
+ getSp00ky() {
544
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
545
+ return this.sp00ky;
372
546
  }
373
547
  /**
374
- * Initialize the spooky-ts instance
548
+ * Initialize the sp00ky-ts instance
375
549
  */
376
550
  async init() {
377
551
  if (this._initialized) return;
378
- this.spooky = new _spooky_sync_core.SpookyClient(this.config);
379
- await this.spooky.init();
552
+ this.sp00ky = new _spooky_sync_core.Sp00kyClient(this.config);
553
+ await this.sp00ky.init();
380
554
  this._initialized = true;
381
555
  }
382
556
  /**
383
557
  * Create a new record in the database
384
558
  */
385
559
  async create(id, payload) {
386
- if (!this.spooky) throw new Error("SyncedDb not initialized");
387
- await this.spooky.create(id, payload);
560
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
561
+ await this.sp00ky.create(id, payload);
388
562
  }
389
563
  /**
390
564
  * Update an existing record in the database
391
565
  */
392
566
  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);
567
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
568
+ await this.sp00ky.update(tableName, recordId, payload, options);
395
569
  }
396
570
  /**
397
571
  * Delete an existing record in the database
398
572
  */
399
573
  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);
574
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
575
+ const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
576
+ let id;
577
+ if (typeof selector === "string") id = selector;
578
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
579
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
580
+ await this.sp00ky.delete(tableName, id);
581
+ }
582
+ /**
583
+ * Preload/prewarm a built query into the local cache without registering a
584
+ * live view. Fetches once and stores the rows (+ embedded related children)
585
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
586
+ */
587
+ async preload(finalQuery, options) {
588
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
589
+ await this.sp00ky.preload(finalQuery, options);
403
590
  }
404
591
  /**
405
592
  * Query data from the database
406
593
  */
407
594
  query(table) {
408
- if (!this.spooky) throw new Error("SyncedDb not initialized");
409
- return this.spooky.query(table, {});
595
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
596
+ return this.sp00ky.query(table, {});
410
597
  }
411
598
  /**
412
599
  * Run a backend operation
413
600
  */
414
601
  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);
602
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
603
+ await this.sp00ky.run(backend, path, payload, options);
417
604
  }
418
605
  /**
419
606
  * Authenticate with the database
420
607
  */
421
608
  async authenticate(token) {
422
- await this.spooky?.authenticate(token);
609
+ await this.sp00ky?.authenticate(token);
423
610
  return new surrealdb.RecordId("user", "me");
424
611
  }
425
612
  /**
@@ -433,48 +620,67 @@ var SyncedDb = class {
433
620
  * Sign out, clear session and local storage
434
621
  */
435
622
  async signOut() {
436
- if (!this.spooky) throw new Error("SyncedDb not initialized");
437
- await this.spooky.auth.signOut();
623
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
624
+ await this.sp00ky.auth.signOut();
438
625
  }
439
626
  /**
440
627
  * Execute a function with direct access to the remote database connection
441
628
  */
442
629
  async useRemote(fn) {
443
- if (!this.spooky) throw new Error("SyncedDb not initialized");
444
- return await this.spooky.useRemote(fn);
630
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
631
+ return await this.sp00ky.useRemote(fn);
445
632
  }
446
633
  /**
447
634
  * Access the remote database service directly
448
635
  */
449
636
  get remote() {
450
- if (!this.spooky) throw new Error("SyncedDb not initialized");
451
- return this.spooky.remoteClient;
637
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
638
+ return this.sp00ky.remoteClient;
452
639
  }
453
640
  /**
454
641
  * Access the local database service directly
455
642
  */
456
643
  get local() {
457
- if (!this.spooky) throw new Error("SyncedDb not initialized");
458
- return this.spooky.localClient;
644
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
645
+ return this.sp00ky.localClient;
459
646
  }
460
647
  /**
461
648
  * Access the auth service
462
649
  */
463
650
  get auth() {
464
- if (!this.spooky) throw new Error("SyncedDb not initialized");
465
- return this.spooky.auth;
651
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
652
+ return this.sp00ky.auth;
466
653
  }
467
654
  get pendingMutationCount() {
468
- if (!this.spooky) throw new Error("SyncedDb not initialized");
469
- return this.spooky.pendingMutationCount;
655
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
656
+ return this.sp00ky.pendingMutationCount;
657
+ }
658
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
659
+ get liveRetryCount() {
660
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
661
+ return this.sp00ky.liveRetryCount;
470
662
  }
471
663
  subscribeToPendingMutations(cb) {
472
- if (!this.spooky) throw new Error("SyncedDb not initialized");
473
- return this.spooky.subscribeToPendingMutations(cb);
664
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
665
+ return this.sp00ky.subscribeToPendingMutations(cb);
666
+ }
667
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
668
+ get syncHealth() {
669
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
670
+ return this.sp00ky.syncHealth;
671
+ }
672
+ /**
673
+ * Observe sync health. Fires immediately with the current status and again
674
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
675
+ * components; this is the imperative escape hatch.
676
+ */
677
+ subscribeToSyncHealth(cb) {
678
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
679
+ return this.sp00ky.subscribeToSyncHealth(cb);
474
680
  }
475
681
  bucket(name) {
476
- if (!this.spooky) throw new Error("SyncedDb not initialized");
477
- return this.spooky.bucket(name);
682
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
683
+ return this.sp00ky.bucket(name);
478
684
  }
479
685
  getBucketConfig(name) {
480
686
  return this.config.schema.buckets?.find((b) => b.name === name);
@@ -483,11 +689,15 @@ var SyncedDb = class {
483
689
 
484
690
  //#endregion
485
691
  exports.RecordId = surrealdb.RecordId;
486
- exports.SpookyProvider = SpookyProvider;
692
+ exports.Sp00kyProvider = Sp00kyProvider;
487
693
  exports.SyncedDb = SyncedDb;
488
694
  exports.Uuid = surrealdb.Uuid;
695
+ exports.createPreload = createPreload;
696
+ exports.useCrdtField = useCrdtField;
489
697
  exports.useDb = useDb;
490
698
  exports.useDownloadFile = useDownloadFile;
699
+ exports.useFeatureFlag = useFeatureFlag;
491
700
  exports.useFileUpload = useFileUpload;
492
701
  exports.useQuery = useQuery;
702
+ exports.useSyncStatus = useSyncStatus;
493
703
  //# sourceMappingURL=index.cjs.map