@spooky-sync/client-solid 0.0.1-canary.9 → 0.0.1-canary.91

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/src/index.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { SyncedDbConfig } from './types';
2
2
  import {
3
- SpookyClient,
4
- AuthService,
5
- BucketHandle,
6
- type SpookyQueryResultPromise,
7
- UpdateOptions,
8
- RunOptions,
3
+ Sp00kyClient,
4
+ type Sp00kyQueryResultPromise,
5
+ type AuthService,
6
+ type BucketHandle,
7
+ type UpdateOptions,
8
+ type RunOptions,
9
+ type SyncHealth,
9
10
  } from '@spooky-sync/core';
10
11
 
11
- import {
12
+ import type {
12
13
  GetTable,
13
14
  QueryBuilder,
14
15
  SchemaStructure,
@@ -25,19 +26,30 @@ import {
25
26
  RoutePayload,
26
27
  BucketNames,
27
28
  BucketDefinitionSchema,
29
+ QueryModifier,
30
+ QueryModifierBuilder,
31
+ QueryInfo,
32
+ RelationshipsMetadata,
33
+ RelationshipDefinition,
34
+ InferRelatedModelFromMetadata,
35
+ GetCardinality,
28
36
  } from '@spooky-sync/query-builder';
29
37
 
30
- import { RecordId, Uuid, Surreal } from 'surrealdb';
38
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
31
39
  export { RecordId, Uuid };
32
40
  export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
33
41
  export { useQuery } from './lib/use-query';
42
+ export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
43
+ export type { SyncHealth, SyncHealthStatus, SyncHealthConfig } from '@spooky-sync/core';
44
+ export { useCrdtField } from './lib/use-crdt-field';
45
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
34
46
  export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
35
47
  export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
36
- export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
48
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
37
49
  export { useDb } from './lib/context';
38
50
 
39
51
  // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
40
- export type {};
52
+
41
53
 
42
54
  // Re-export query builder types for convenience
43
55
  export type {
@@ -52,7 +64,7 @@ export type {
52
64
  TableModel,
53
65
  TableNames,
54
66
  QueryResult,
55
- } from '@spooky-sync/query-builder';
67
+ };
56
68
 
57
69
  export type RelationshipField<
58
70
  Schema extends SchemaStructure,
@@ -94,30 +106,30 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
94
106
  };
95
107
 
96
108
  /**
97
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
98
- * Delegates all logic to the underlying spooky-ts instance
109
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
110
+ * Delegates all logic to the underlying sp00ky-ts instance
99
111
  */
100
112
  export class SyncedDb<S extends SchemaStructure> {
101
113
  private config: SyncedDbConfig<S>;
102
- private spooky: SpookyClient<S> | null = null;
114
+ private sp00ky: Sp00kyClient<S> | null = null;
103
115
  private _initialized = false;
104
116
 
105
117
  constructor(config: SyncedDbConfig<S>) {
106
118
  this.config = config;
107
119
  }
108
120
 
109
- public getSpooky(): SpookyClient<S> {
110
- if (!this.spooky) throw new Error('SyncedDb not initialized');
111
- return this.spooky;
121
+ public getSp00ky(): Sp00kyClient<S> {
122
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
123
+ return this.sp00ky;
112
124
  }
113
125
 
114
126
  /**
115
- * Initialize the spooky-ts instance
127
+ * Initialize the sp00ky-ts instance
116
128
  */
117
129
  async init(): Promise<void> {
118
130
  if (this._initialized) return;
119
- this.spooky = new SpookyClient<S>(this.config);
120
- await this.spooky.init();
131
+ this.sp00ky = new Sp00kyClient<S>(this.config);
132
+ await this.sp00ky.init();
121
133
  this._initialized = true;
122
134
  }
123
135
 
@@ -125,8 +137,8 @@ export class SyncedDb<S extends SchemaStructure> {
125
137
  * Create a new record in the database
126
138
  */
127
139
  async create(id: string, payload: Record<string, unknown>): Promise<void> {
128
- if (!this.spooky) throw new Error('SyncedDb not initialized');
129
- await this.spooky.create(id, payload as Record<string, unknown>);
140
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
141
+ await this.sp00ky.create(id, payload as Record<string, unknown>);
130
142
  }
131
143
 
132
144
  /**
@@ -138,8 +150,8 @@ export class SyncedDb<S extends SchemaStructure> {
138
150
  payload: Partial<TableModel<GetTable<S, TName>>>,
139
151
  options?: UpdateOptions
140
152
  ): Promise<void> {
141
- if (!this.spooky) throw new Error('SyncedDb not initialized');
142
- await this.spooky.update(
153
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
154
+ await this.sp00ky.update(
143
155
  tableName as string,
144
156
  recordId,
145
157
  payload as Record<string, unknown>,
@@ -152,12 +164,26 @@ export class SyncedDb<S extends SchemaStructure> {
152
164
  */
153
165
  async delete<TName extends TableNames<S>>(
154
166
  tableName: TName,
155
- selector: string | InnerQuery<GetTable<S, TName>, boolean>
167
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
156
168
  ): Promise<void> {
157
- if (!this.spooky) throw new Error('SyncedDb not initialized');
158
- if (typeof selector !== 'string')
159
- throw new Error('Only string ID selectors are supported currently with core');
160
- await this.spooky.delete(tableName as string, selector);
169
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
170
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
171
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
172
+ // directly. Build the canonical string from the raw id part (not
173
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
174
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
175
+ // supported yet. (cross-package RecordId instances → match by constructor name.)
176
+ const isRecordId =
177
+ selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
178
+ let id: string;
179
+ if (typeof selector === 'string') {
180
+ id = selector;
181
+ } else if (isRecordId) {
182
+ id = `${tableName as string}:${(selector as RecordId).id}`;
183
+ } else {
184
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
185
+ }
186
+ await this.sp00ky.delete(tableName as string, id);
161
187
  }
162
188
 
163
189
  /**
@@ -165,9 +191,9 @@ export class SyncedDb<S extends SchemaStructure> {
165
191
  */
166
192
  public query<TName extends TableNames<S>>(
167
193
  table: TName
168
- ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
169
- if (!this.spooky) throw new Error('SyncedDb not initialized');
170
- return this.spooky.query(table, {});
194
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
195
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
196
+ return this.sp00ky.query(table, {});
171
197
  }
172
198
 
173
199
  /**
@@ -182,17 +208,17 @@ export class SyncedDb<S extends SchemaStructure> {
182
208
  payload: RoutePayload<S, B, R>,
183
209
  options?: RunOptions,
184
210
  ): Promise<void> {
185
- if (!this.spooky) throw new Error('SyncedDb not initialized');
186
- await this.spooky.run(backend, path, payload, options);
211
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
212
+ await this.sp00ky.run(backend, path, payload, options);
187
213
  }
188
214
 
189
215
  /**
190
216
  * Authenticate with the database
191
217
  */
192
218
  public async authenticate(token: string): Promise<RecordId<string>> {
193
- const result = await this.spooky?.authenticate(token);
194
- // SpookyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
195
- // Wait, checked SpookyClient: return this.remote.getClient().authenticate(token);
219
+ await this.sp00ky?.authenticate(token);
220
+ // Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
221
+ // Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
196
222
  // SurrealDB authenticate returns void? or token?
197
223
  // Assuming void or token.
198
224
  return new RecordId('user', 'me'); // Placeholder or actual?
@@ -210,54 +236,76 @@ export class SyncedDb<S extends SchemaStructure> {
210
236
  * Sign out, clear session and local storage
211
237
  */
212
238
  public async signOut(): Promise<void> {
213
- if (!this.spooky) throw new Error('SyncedDb not initialized');
214
- await this.spooky.auth.signOut();
239
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
240
+ await this.sp00ky.auth.signOut();
215
241
  }
216
242
 
217
243
  /**
218
244
  * Execute a function with direct access to the remote database connection
219
245
  */
220
246
  public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
221
- if (!this.spooky) throw new Error('SyncedDb not initialized');
222
- return await this.spooky.useRemote(fn);
247
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
248
+ return await this.sp00ky.useRemote(fn);
223
249
  }
224
250
  /**
225
251
  * Access the remote database service directly
226
252
  */
227
- get remote(): SpookyClient<S>['remoteClient'] {
228
- if (!this.spooky) throw new Error('SyncedDb not initialized');
229
- return this.spooky.remoteClient;
253
+ get remote(): Sp00kyClient<S>['remoteClient'] {
254
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
255
+ return this.sp00ky.remoteClient;
230
256
  }
231
257
 
232
258
  /**
233
259
  * Access the local database service directly
234
260
  */
235
- get local(): SpookyClient<S>['localClient'] {
236
- if (!this.spooky) throw new Error('SyncedDb not initialized');
237
- return this.spooky.localClient;
261
+ get local(): Sp00kyClient<S>['localClient'] {
262
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
263
+ return this.sp00ky.localClient;
238
264
  }
239
265
 
240
266
  /**
241
267
  * Access the auth service
242
268
  */
243
269
  get auth(): AuthService<S> {
244
- if (!this.spooky) throw new Error('SyncedDb not initialized');
245
- return this.spooky.auth;
270
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
271
+ return this.sp00ky.auth;
246
272
  }
247
273
 
248
274
  get pendingMutationCount(): number {
249
- if (!this.spooky) throw new Error('SyncedDb not initialized');
250
- return this.spooky.pendingMutationCount;
275
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
276
+ return this.sp00ky.pendingMutationCount;
277
+ }
278
+
279
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
280
+ get liveRetryCount(): number {
281
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
282
+ return this.sp00ky.liveRetryCount;
251
283
  }
252
284
 
253
285
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
254
- if (!this.spooky) throw new Error('SyncedDb not initialized');
255
- return this.spooky.subscribeToPendingMutations(cb);
286
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
287
+ return this.sp00ky.subscribeToPendingMutations(cb);
288
+ }
289
+
290
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
291
+ get syncHealth(): SyncHealth {
292
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
293
+ return this.sp00ky.syncHealth;
294
+ }
295
+
296
+ /**
297
+ * Observe sync health. Fires immediately with the current status and again
298
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
299
+ * components; this is the imperative escape hatch.
300
+ */
301
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
302
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
303
+ return this.sp00ky.subscribeToSyncHealth(cb);
256
304
  }
257
305
 
258
306
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
259
- if (!this.spooky) throw new Error('SyncedDb not initialized');
260
- return this.spooky.bucket(name);
307
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
308
+ return this.sp00ky.bucket(name);
261
309
  }
262
310
 
263
311
  getBucketConfig(name: string): BucketDefinitionSchema | undefined {
@@ -1,10 +1,11 @@
1
- import { createSignal, onMount, createComponent, createMemo, JSX, mergeProps } from 'solid-js';
1
+ import type { JSX} from 'solid-js';
2
+ import { createSignal, onMount, createComponent, createMemo, mergeProps } from 'solid-js';
2
3
  import type { SchemaStructure } from '@spooky/query-builder';
3
4
  import type { SyncedDbConfig } from '../types';
4
5
  import { SyncedDb } from '../index';
5
- import { SpookyContext } from './context';
6
+ import { Sp00kyContext } from './context';
6
7
 
7
- export interface SpookyProviderProps<S extends SchemaStructure> {
8
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
8
9
  config: SyncedDbConfig<S>;
9
10
  fallback?: JSX.Element;
10
11
  onError?: (error: Error) => void;
@@ -12,8 +13,8 @@ export interface SpookyProviderProps<S extends SchemaStructure> {
12
13
  children: JSX.Element;
13
14
  }
14
15
 
15
- export function SpookyProvider<S extends SchemaStructure>(
16
- props: SpookyProviderProps<S>
16
+ export function Sp00kyProvider<S extends SchemaStructure>(
17
+ props: Sp00kyProviderProps<S>
17
18
  ): JSX.Element {
18
19
  const merged = mergeProps(
19
20
  {
@@ -35,7 +36,8 @@ export function SpookyProvider<S extends SchemaStructure>(
35
36
  if (merged.onError) {
36
37
  merged.onError(error);
37
38
  } else {
38
- console.error('SpookyProvider: Failed to initialize database', error);
39
+ // oxlint-disable-next-line no-console
40
+ console.error('Sp00kyProvider: Failed to initialize database', error);
39
41
  }
40
42
  }
41
43
  });
@@ -43,7 +45,7 @@ export function SpookyProvider<S extends SchemaStructure>(
43
45
  const content = createMemo(() => {
44
46
  const instance = db();
45
47
  if (!instance) return merged.fallback;
46
- return createComponent(SpookyContext.Provider, {
48
+ return createComponent(Sp00kyContext.Provider, {
47
49
  value: instance,
48
50
  get children() {
49
51
  return merged.children;
@@ -2,12 +2,12 @@ import { createContext, useContext } from 'solid-js';
2
2
  import type { SchemaStructure } from '@spooky/query-builder';
3
3
  import type { SyncedDb } from '../index';
4
4
 
5
- export const SpookyContext = createContext<SyncedDb<any> | undefined>();
5
+ export const Sp00kyContext = createContext<SyncedDb<any> | undefined>();
6
6
 
7
7
  export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
8
- const db = useContext(SpookyContext);
8
+ const db = useContext(Sp00kyContext);
9
9
  if (!db) {
10
- throw new Error('useDb must be used within a <SpookyProvider>. Wrap your app in <SpookyProvider config={...}>.');
10
+ throw new Error('useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.');
11
11
  }
12
12
  return db as SyncedDb<S>;
13
13
  }
package/src/lib/models.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RecordId } from 'surrealdb';
1
+ import type { RecordId } from 'surrealdb';
2
2
 
3
3
  // Re-export types from query-builder for backward compatibility
4
4
  export type { GenericModel, GenericSchema } from '@spooky/query-builder';
@@ -0,0 +1,68 @@
1
+ import { createEffect, createSignal, onCleanup, useContext, type Accessor } from 'solid-js';
2
+ import { Sp00kyContext } from './context';
3
+ import type { CrdtField } from '@spooky-sync/core';
4
+
5
+ export function useCrdtField(
6
+ table: string,
7
+ recordId: () => string | undefined,
8
+ field: string,
9
+ fallbackText?: () => string | undefined,
10
+ ): Accessor<CrdtField | null> {
11
+ const db = useContext(Sp00kyContext);
12
+ if (!db) {
13
+ throw new Error('useCrdtField must be used within a <Sp00kyProvider>');
14
+ }
15
+
16
+ const [crdtField, setCrdtField] = createSignal<CrdtField | null>(null);
17
+ let currentId: string | undefined;
18
+ let initialized = false;
19
+
20
+ createEffect(() => {
21
+ const id = recordId();
22
+
23
+ // Skip if the ID hasn't changed (but allow the first non-undefined value through)
24
+ if (initialized && id === currentId) return;
25
+
26
+ // Close previous field
27
+ if (currentId && crdtField()) {
28
+ db.getSp00ky().closeCrdtField(table, currentId, field);
29
+ setCrdtField(null);
30
+ }
31
+
32
+ currentId = id;
33
+ initialized = true;
34
+
35
+ if (!id) return;
36
+
37
+ const sp00ky = db.getSp00ky();
38
+ const text = fallbackText?.();
39
+ sp00ky
40
+ .openCrdtField(table, id, field, text)
41
+ .then((cf) => {
42
+ if (currentId === id) {
43
+ setCrdtField(cf);
44
+ }
45
+ })
46
+ .catch((err) => {
47
+ // Silent rejections here leave the consumer's `Show when={field()}`
48
+ // permanently stuck on its fallback (typically a static `<p>` with
49
+ // no editing UI), with no error trail. Surface the failure so the
50
+ // root cause (missing `@crdt` annotation, schema codegen drift,
51
+ // local DB query failure, etc.) is visible in the console instead
52
+ // of silently breaking collaborative fields.
53
+ console.error(
54
+ `[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`,
55
+ err,
56
+ );
57
+ });
58
+ });
59
+
60
+ onCleanup(() => {
61
+ if (currentId && crdtField()) {
62
+ db.getSp00ky().closeCrdtField(table, currentId, field);
63
+ setCrdtField(null);
64
+ }
65
+ });
66
+
67
+ return crdtField;
68
+ }
@@ -78,9 +78,8 @@ export function useDownloadFile<S extends SchemaStructure>(
78
78
 
79
79
  let currentKey: string | null = null;
80
80
  let privateUrl: string | null = null;
81
- let refetchTrigger: () => void;
82
81
  const [refetchSignal, setRefetchSignal] = createSignal(0);
83
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
82
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
84
83
 
85
84
  async function doDownload(key: string, filePath: string): Promise<string | null> {
86
85
  if (useCache) {
@@ -184,6 +183,7 @@ export function useDownloadFile<S extends SchemaStructure>(
184
183
  setUrl(result);
185
184
  setIsLoading(false);
186
185
  }
186
+ return undefined;
187
187
  },
188
188
  (err) => {
189
189
  if (!cancelled) {
@@ -0,0 +1,50 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { FeatureFlagOptions } from '@spooky-sync/core';
4
+
5
+ export interface UseFeatureFlag {
6
+ variant: Accessor<string | undefined>;
7
+ payload: Accessor<unknown | undefined>;
8
+ enabled: Accessor<boolean>;
9
+ }
10
+
11
+ /**
12
+ * Subscribe to a feature flag for the currently authenticated user.
13
+ *
14
+ * Returns three Solid accessors that update reactively whenever the
15
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
16
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
17
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
18
+ *
19
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
20
+ * For multi-variant flags, prefer `variant()` directly.
21
+ */
22
+ export function useFeatureFlag(
23
+ key: string,
24
+ options?: FeatureFlagOptions,
25
+ ): UseFeatureFlag {
26
+ const db = useDb();
27
+ const handle = db.getSp00ky().feature(key, options);
28
+
29
+ const [variant, setVariant] = createSignal<string | undefined>(handle.variant());
30
+ const [payload, setPayload] = createSignal<unknown | undefined>(handle.payload());
31
+
32
+ const unsub = handle.subscribe((s) => {
33
+ setVariant(s.variant ?? options?.fallback);
34
+ setPayload(s.payload);
35
+ });
36
+
37
+ onCleanup(() => {
38
+ unsub();
39
+ handle.close();
40
+ });
41
+
42
+ return {
43
+ variant,
44
+ payload,
45
+ enabled: () => {
46
+ const v = variant();
47
+ return v !== undefined && v !== 'off';
48
+ },
49
+ };
50
+ }
@@ -33,6 +33,7 @@ export function useFileUpload<S extends SchemaStructure>(
33
33
  bucketName = dbOrBucketName as BucketNames<S>;
34
34
  } else {
35
35
  db = dbOrBucketName as SyncedDb<S>;
36
+ // oxlint-disable-next-line no-non-null-assertion
36
37
  bucketName = maybeBucketName!;
37
38
  }
38
39
 
@@ -52,7 +53,7 @@ export function useFileUpload<S extends SchemaStructure>(
52
53
  const config = db.getBucketConfig(bucketName as string);
53
54
  if (!config) return;
54
55
 
55
- if (config.maxSize != null && file.size > config.maxSize) {
56
+ if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
56
57
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
57
58
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
58
59
  }