@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/src/index.ts CHANGED
@@ -1,14 +1,14 @@
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
9
  } from '@spooky-sync/core';
10
10
 
11
- import {
11
+ import type {
12
12
  GetTable,
13
13
  QueryBuilder,
14
14
  SchemaStructure,
@@ -25,19 +25,28 @@ import {
25
25
  RoutePayload,
26
26
  BucketNames,
27
27
  BucketDefinitionSchema,
28
+ QueryModifier,
29
+ QueryModifierBuilder,
30
+ QueryInfo,
31
+ RelationshipsMetadata,
32
+ RelationshipDefinition,
33
+ InferRelatedModelFromMetadata,
34
+ GetCardinality,
28
35
  } from '@spooky-sync/query-builder';
29
36
 
30
- import { RecordId, Uuid, Surreal } from 'surrealdb';
37
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
31
38
  export { RecordId, Uuid };
32
39
  export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
33
40
  export { useQuery } from './lib/use-query';
41
+ export { useCrdtField } from './lib/use-crdt-field';
42
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
34
43
  export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
35
44
  export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
36
- export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
45
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
37
46
  export { useDb } from './lib/context';
38
47
 
39
48
  // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
40
- export type {};
49
+
41
50
 
42
51
  // Re-export query builder types for convenience
43
52
  export type {
@@ -52,7 +61,7 @@ export type {
52
61
  TableModel,
53
62
  TableNames,
54
63
  QueryResult,
55
- } from '@spooky-sync/query-builder';
64
+ };
56
65
 
57
66
  export type RelationshipField<
58
67
  Schema extends SchemaStructure,
@@ -94,30 +103,30 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
94
103
  };
95
104
 
96
105
  /**
97
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
98
- * Delegates all logic to the underlying spooky-ts instance
106
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
107
+ * Delegates all logic to the underlying sp00ky-ts instance
99
108
  */
100
109
  export class SyncedDb<S extends SchemaStructure> {
101
110
  private config: SyncedDbConfig<S>;
102
- private spooky: SpookyClient<S> | null = null;
111
+ private sp00ky: Sp00kyClient<S> | null = null;
103
112
  private _initialized = false;
104
113
 
105
114
  constructor(config: SyncedDbConfig<S>) {
106
115
  this.config = config;
107
116
  }
108
117
 
109
- public getSpooky(): SpookyClient<S> {
110
- if (!this.spooky) throw new Error('SyncedDb not initialized');
111
- return this.spooky;
118
+ public getSp00ky(): Sp00kyClient<S> {
119
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
120
+ return this.sp00ky;
112
121
  }
113
122
 
114
123
  /**
115
- * Initialize the spooky-ts instance
124
+ * Initialize the sp00ky-ts instance
116
125
  */
117
126
  async init(): Promise<void> {
118
127
  if (this._initialized) return;
119
- this.spooky = new SpookyClient<S>(this.config);
120
- await this.spooky.init();
128
+ this.sp00ky = new Sp00kyClient<S>(this.config);
129
+ await this.sp00ky.init();
121
130
  this._initialized = true;
122
131
  }
123
132
 
@@ -125,8 +134,8 @@ export class SyncedDb<S extends SchemaStructure> {
125
134
  * Create a new record in the database
126
135
  */
127
136
  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>);
137
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
138
+ await this.sp00ky.create(id, payload as Record<string, unknown>);
130
139
  }
131
140
 
132
141
  /**
@@ -138,8 +147,8 @@ export class SyncedDb<S extends SchemaStructure> {
138
147
  payload: Partial<TableModel<GetTable<S, TName>>>,
139
148
  options?: UpdateOptions
140
149
  ): Promise<void> {
141
- if (!this.spooky) throw new Error('SyncedDb not initialized');
142
- await this.spooky.update(
150
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
151
+ await this.sp00ky.update(
143
152
  tableName as string,
144
153
  recordId,
145
154
  payload as Record<string, unknown>,
@@ -152,12 +161,26 @@ export class SyncedDb<S extends SchemaStructure> {
152
161
  */
153
162
  async delete<TName extends TableNames<S>>(
154
163
  tableName: TName,
155
- selector: string | InnerQuery<GetTable<S, TName>, boolean>
164
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
156
165
  ): 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);
166
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
167
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
168
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
169
+ // directly. Build the canonical string from the raw id part (not
170
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
171
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
172
+ // supported yet. (cross-package RecordId instances → match by constructor name.)
173
+ const isRecordId =
174
+ selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
175
+ let id: string;
176
+ if (typeof selector === 'string') {
177
+ id = selector;
178
+ } else if (isRecordId) {
179
+ id = `${tableName as string}:${(selector as RecordId).id}`;
180
+ } else {
181
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
182
+ }
183
+ await this.sp00ky.delete(tableName as string, id);
161
184
  }
162
185
 
163
186
  /**
@@ -165,9 +188,9 @@ export class SyncedDb<S extends SchemaStructure> {
165
188
  */
166
189
  public query<TName extends TableNames<S>>(
167
190
  table: TName
168
- ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
169
- if (!this.spooky) throw new Error('SyncedDb not initialized');
170
- return this.spooky.query(table, {});
191
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
192
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
193
+ return this.sp00ky.query(table, {});
171
194
  }
172
195
 
173
196
  /**
@@ -182,17 +205,17 @@ export class SyncedDb<S extends SchemaStructure> {
182
205
  payload: RoutePayload<S, B, R>,
183
206
  options?: RunOptions,
184
207
  ): Promise<void> {
185
- if (!this.spooky) throw new Error('SyncedDb not initialized');
186
- await this.spooky.run(backend, path, payload, options);
208
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
209
+ await this.sp00ky.run(backend, path, payload, options);
187
210
  }
188
211
 
189
212
  /**
190
213
  * Authenticate with the database
191
214
  */
192
215
  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);
216
+ await this.sp00ky?.authenticate(token);
217
+ // Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
218
+ // Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
196
219
  // SurrealDB authenticate returns void? or token?
197
220
  // Assuming void or token.
198
221
  return new RecordId('user', 'me'); // Placeholder or actual?
@@ -210,54 +233,60 @@ export class SyncedDb<S extends SchemaStructure> {
210
233
  * Sign out, clear session and local storage
211
234
  */
212
235
  public async signOut(): Promise<void> {
213
- if (!this.spooky) throw new Error('SyncedDb not initialized');
214
- await this.spooky.auth.signOut();
236
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
237
+ await this.sp00ky.auth.signOut();
215
238
  }
216
239
 
217
240
  /**
218
241
  * Execute a function with direct access to the remote database connection
219
242
  */
220
243
  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);
244
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
245
+ return await this.sp00ky.useRemote(fn);
223
246
  }
224
247
  /**
225
248
  * Access the remote database service directly
226
249
  */
227
- get remote(): SpookyClient<S>['remoteClient'] {
228
- if (!this.spooky) throw new Error('SyncedDb not initialized');
229
- return this.spooky.remoteClient;
250
+ get remote(): Sp00kyClient<S>['remoteClient'] {
251
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
252
+ return this.sp00ky.remoteClient;
230
253
  }
231
254
 
232
255
  /**
233
256
  * Access the local database service directly
234
257
  */
235
- get local(): SpookyClient<S>['localClient'] {
236
- if (!this.spooky) throw new Error('SyncedDb not initialized');
237
- return this.spooky.localClient;
258
+ get local(): Sp00kyClient<S>['localClient'] {
259
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
260
+ return this.sp00ky.localClient;
238
261
  }
239
262
 
240
263
  /**
241
264
  * Access the auth service
242
265
  */
243
266
  get auth(): AuthService<S> {
244
- if (!this.spooky) throw new Error('SyncedDb not initialized');
245
- return this.spooky.auth;
267
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
268
+ return this.sp00ky.auth;
246
269
  }
247
270
 
248
271
  get pendingMutationCount(): number {
249
- if (!this.spooky) throw new Error('SyncedDb not initialized');
250
- return this.spooky.pendingMutationCount;
272
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
273
+ return this.sp00ky.pendingMutationCount;
274
+ }
275
+
276
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
277
+ get liveRetryCount(): number {
278
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
279
+ return this.sp00ky.liveRetryCount;
251
280
  }
252
281
 
253
282
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
254
- if (!this.spooky) throw new Error('SyncedDb not initialized');
255
- return this.spooky.subscribeToPendingMutations(cb);
283
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
284
+ return this.sp00ky.subscribeToPendingMutations(cb);
256
285
  }
257
286
 
258
287
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
259
- if (!this.spooky) throw new Error('SyncedDb not initialized');
260
- return this.spooky.bucket(name);
288
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
289
+ return this.sp00ky.bucket(name);
261
290
  }
262
291
 
263
292
  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
  }