@spooky-sync/client-solid 0.0.1-canary.8 → 0.0.1-canary.80

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,66 @@
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.run(backend, route, payload)` — call a backend RPC route.
40
+ - `db.bucket(name)` — get a `BucketHandle` for file storage.
41
+ - `db.useRemote(fn)` — escape hatch to the raw `Surreal` client (skips cache).
42
+ - `db.authenticate(token)`, `db.signOut()`, `db.auth`.
43
+ - `db.pendingMutationCount`, `db.subscribeToPendingMutations(cb)`.
44
+ - **`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.
45
+ - **`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.*
46
+ - **`useFileUpload()`** / **`useDownloadFile()`** — bucket helpers; the upload result includes the storage path you write into a record column.
47
+
48
+ ## Re-exports for convenience
49
+
50
+ - `RecordId`, `Uuid` from `surrealdb`.
51
+ - Query-builder types: `TableModel`, `TableNames`, `GetTable`, `QueryResult`, etc. (see `@spooky-sync/query-builder/AGENTS.md`).
52
+ - `Model<S, T>`, `GenericModel`, `ModelPayload` — typed row shapes.
53
+
54
+ ## Common gotchas
55
+
56
+ - **`useDb()` requires `<typeof schema>`.** Without the generic, all calls fall back to `unknown` and you lose type safety.
57
+ - **CRDT fields are not regular columns.** Don't read or write them via `useQuery` — read with `useCrdtField`, write via `db.update` with `{ debounced: true }`.
58
+ - **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.
59
+ - **`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.
60
+ - **Provider is mandatory.** Calling any hook outside `<Sp00kyProvider>` throws.
61
+
62
+ ## Pointers
63
+
64
+ - Sync engine: `node_modules/@spooky-sync/core/AGENTS.md`
65
+ - Query builder DSL: `node_modules/@spooky-sync/query-builder/AGENTS.md`
66
+ - Schema authoring + codegen: `node_modules/@spooky-sync/cli/AGENTS.md`
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,14 +81,18 @@ 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;
@@ -69,7 +100,78 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
69
100
  return {
70
101
  data,
71
102
  error,
72
- isLoading
103
+ isLoading,
104
+ isFetching
105
+ };
106
+ }
107
+
108
+ //#endregion
109
+ //#region src/lib/use-crdt-field.ts
110
+ function useCrdtField(table, recordId, field, fallbackText) {
111
+ const db = (0, solid_js.useContext)(Sp00kyContext);
112
+ if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
113
+ const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
114
+ let currentId;
115
+ let initialized = false;
116
+ (0, solid_js.createEffect)(() => {
117
+ const id = recordId();
118
+ if (initialized && id === currentId) return;
119
+ if (currentId && crdtField()) {
120
+ db.getSp00ky().closeCrdtField(table, currentId, field);
121
+ setCrdtField(null);
122
+ }
123
+ currentId = id;
124
+ initialized = true;
125
+ if (!id) return;
126
+ const sp00ky = db.getSp00ky();
127
+ const text = fallbackText?.();
128
+ sp00ky.openCrdtField(table, id, field, text).then((cf) => {
129
+ if (currentId === id) setCrdtField(cf);
130
+ }).catch((err) => {
131
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
132
+ });
133
+ });
134
+ (0, solid_js.onCleanup)(() => {
135
+ if (currentId && crdtField()) {
136
+ db.getSp00ky().closeCrdtField(table, currentId, field);
137
+ setCrdtField(null);
138
+ }
139
+ });
140
+ return crdtField;
141
+ }
142
+
143
+ //#endregion
144
+ //#region src/lib/use-feature-flag.ts
145
+ /**
146
+ * Subscribe to a feature flag for the currently authenticated user.
147
+ *
148
+ * Returns three Solid accessors that update reactively whenever the
149
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
150
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
151
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
152
+ *
153
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
154
+ * For multi-variant flags, prefer `variant()` directly.
155
+ */
156
+ function useFeatureFlag(key, options) {
157
+ const handle = useDb().getSp00ky().feature(key, options);
158
+ const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
159
+ const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
160
+ const unsub = handle.subscribe((s) => {
161
+ setVariant(s.variant ?? options?.fallback);
162
+ setPayload(s.payload);
163
+ });
164
+ (0, solid_js.onCleanup)(() => {
165
+ unsub();
166
+ handle.close();
167
+ });
168
+ return {
169
+ variant,
170
+ payload,
171
+ enabled: () => {
172
+ const v = variant();
173
+ return v !== void 0 && v !== "off";
174
+ }
73
175
  };
74
176
  }
75
177
 
@@ -95,7 +197,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
95
197
  const validate = (file) => {
96
198
  const config = db.getBucketConfig(bucketName);
97
199
  if (!config) return;
98
- if (config.maxSize != null && file.size > config.maxSize) {
200
+ if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
99
201
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
100
202
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
101
203
  }
@@ -204,9 +306,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
204
306
  const [error, setError] = (0, solid_js.createSignal)(null);
205
307
  let currentKey = null;
206
308
  let privateUrl = null;
207
- let refetchTrigger;
208
309
  const [refetchSignal, setRefetchSignal] = (0, solid_js.createSignal)(0);
209
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
310
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
210
311
  async function doDownload(key, filePath) {
211
312
  if (useCache) {
212
313
  const cached = downloadCache.get(key);
@@ -326,8 +427,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
326
427
  }
327
428
 
328
429
  //#endregion
329
- //#region src/lib/SpookyProvider.ts
330
- function SpookyProvider(props) {
430
+ //#region src/lib/Sp00kyProvider.ts
431
+ function Sp00kyProvider(props) {
331
432
  const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
332
433
  const [db, setDb] = (0, solid_js.createSignal)(void 0);
333
434
  (0, solid_js.onMount)(async () => {
@@ -339,13 +440,13 @@ function SpookyProvider(props) {
339
440
  } catch (e) {
340
441
  const error = e instanceof Error ? e : new Error(String(e));
341
442
  if (merged.onError) merged.onError(error);
342
- else console.error("SpookyProvider: Failed to initialize database", error);
443
+ else console.error("Sp00kyProvider: Failed to initialize database", error);
343
444
  }
344
445
  });
345
446
  return (0, solid_js.createMemo)(() => {
346
447
  const instance = db();
347
448
  if (!instance) return merged.fallback;
348
- return (0, solid_js.createComponent)(SpookyContext.Provider, {
449
+ return (0, solid_js.createComponent)(Sp00kyContext.Provider, {
349
450
  value: instance,
350
451
  get children() {
351
452
  return merged.children;
@@ -357,69 +458,73 @@ function SpookyProvider(props) {
357
458
  //#endregion
358
459
  //#region src/index.ts
359
460
  /**
360
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
361
- * Delegates all logic to the underlying spooky-ts instance
461
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
462
+ * Delegates all logic to the underlying sp00ky-ts instance
362
463
  */
363
464
  var SyncedDb = class {
364
465
  constructor(config) {
365
- this.spooky = null;
466
+ this.sp00ky = null;
366
467
  this._initialized = false;
367
468
  this.config = config;
368
469
  }
369
- getSpooky() {
370
- if (!this.spooky) throw new Error("SyncedDb not initialized");
371
- return this.spooky;
470
+ getSp00ky() {
471
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
472
+ return this.sp00ky;
372
473
  }
373
474
  /**
374
- * Initialize the spooky-ts instance
475
+ * Initialize the sp00ky-ts instance
375
476
  */
376
477
  async init() {
377
478
  if (this._initialized) return;
378
- this.spooky = new _spooky_sync_core.SpookyClient(this.config);
379
- await this.spooky.init();
479
+ this.sp00ky = new _spooky_sync_core.Sp00kyClient(this.config);
480
+ await this.sp00ky.init();
380
481
  this._initialized = true;
381
482
  }
382
483
  /**
383
484
  * Create a new record in the database
384
485
  */
385
486
  async create(id, payload) {
386
- if (!this.spooky) throw new Error("SyncedDb not initialized");
387
- await this.spooky.create(id, payload);
487
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
488
+ await this.sp00ky.create(id, payload);
388
489
  }
389
490
  /**
390
491
  * Update an existing record in the database
391
492
  */
392
493
  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);
494
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
495
+ await this.sp00ky.update(tableName, recordId, payload, options);
395
496
  }
396
497
  /**
397
498
  * Delete an existing record in the database
398
499
  */
399
500
  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);
501
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
502
+ const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
503
+ let id;
504
+ if (typeof selector === "string") id = selector;
505
+ else if (isRecordId) id = `${tableName}:${selector.id}`;
506
+ else throw new Error("Only string ID or RecordId selectors are supported currently with core");
507
+ await this.sp00ky.delete(tableName, id);
403
508
  }
404
509
  /**
405
510
  * Query data from the database
406
511
  */
407
512
  query(table) {
408
- if (!this.spooky) throw new Error("SyncedDb not initialized");
409
- return this.spooky.query(table, {});
513
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
514
+ return this.sp00ky.query(table, {});
410
515
  }
411
516
  /**
412
517
  * Run a backend operation
413
518
  */
414
519
  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);
520
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
521
+ await this.sp00ky.run(backend, path, payload, options);
417
522
  }
418
523
  /**
419
524
  * Authenticate with the database
420
525
  */
421
526
  async authenticate(token) {
422
- await this.spooky?.authenticate(token);
527
+ await this.sp00ky?.authenticate(token);
423
528
  return new surrealdb.RecordId("user", "me");
424
529
  }
425
530
  /**
@@ -433,48 +538,53 @@ var SyncedDb = class {
433
538
  * Sign out, clear session and local storage
434
539
  */
435
540
  async signOut() {
436
- if (!this.spooky) throw new Error("SyncedDb not initialized");
437
- await this.spooky.auth.signOut();
541
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
542
+ await this.sp00ky.auth.signOut();
438
543
  }
439
544
  /**
440
545
  * Execute a function with direct access to the remote database connection
441
546
  */
442
547
  async useRemote(fn) {
443
- if (!this.spooky) throw new Error("SyncedDb not initialized");
444
- return await this.spooky.useRemote(fn);
548
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
549
+ return await this.sp00ky.useRemote(fn);
445
550
  }
446
551
  /**
447
552
  * Access the remote database service directly
448
553
  */
449
554
  get remote() {
450
- if (!this.spooky) throw new Error("SyncedDb not initialized");
451
- return this.spooky.remoteClient;
555
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
556
+ return this.sp00ky.remoteClient;
452
557
  }
453
558
  /**
454
559
  * Access the local database service directly
455
560
  */
456
561
  get local() {
457
- if (!this.spooky) throw new Error("SyncedDb not initialized");
458
- return this.spooky.localClient;
562
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
563
+ return this.sp00ky.localClient;
459
564
  }
460
565
  /**
461
566
  * Access the auth service
462
567
  */
463
568
  get auth() {
464
- if (!this.spooky) throw new Error("SyncedDb not initialized");
465
- return this.spooky.auth;
569
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
570
+ return this.sp00ky.auth;
466
571
  }
467
572
  get pendingMutationCount() {
468
- if (!this.spooky) throw new Error("SyncedDb not initialized");
469
- return this.spooky.pendingMutationCount;
573
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
574
+ return this.sp00ky.pendingMutationCount;
575
+ }
576
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
577
+ get liveRetryCount() {
578
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
579
+ return this.sp00ky.liveRetryCount;
470
580
  }
471
581
  subscribeToPendingMutations(cb) {
472
- if (!this.spooky) throw new Error("SyncedDb not initialized");
473
- return this.spooky.subscribeToPendingMutations(cb);
582
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
583
+ return this.sp00ky.subscribeToPendingMutations(cb);
474
584
  }
475
585
  bucket(name) {
476
- if (!this.spooky) throw new Error("SyncedDb not initialized");
477
- return this.spooky.bucket(name);
586
+ if (!this.sp00ky) throw new Error("SyncedDb not initialized");
587
+ return this.sp00ky.bucket(name);
478
588
  }
479
589
  getBucketConfig(name) {
480
590
  return this.config.schema.buckets?.find((b) => b.name === name);
@@ -483,11 +593,13 @@ var SyncedDb = class {
483
593
 
484
594
  //#endregion
485
595
  exports.RecordId = surrealdb.RecordId;
486
- exports.SpookyProvider = SpookyProvider;
596
+ exports.Sp00kyProvider = Sp00kyProvider;
487
597
  exports.SyncedDb = SyncedDb;
488
598
  exports.Uuid = surrealdb.Uuid;
599
+ exports.useCrdtField = useCrdtField;
489
600
  exports.useDb = useDb;
490
601
  exports.useDownloadFile = useDownloadFile;
602
+ exports.useFeatureFlag = useFeatureFlag;
491
603
  exports.useFileUpload = useFileUpload;
492
604
  exports.useQuery = useQuery;
493
605
  //# sourceMappingURL=index.cjs.map