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

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.
@@ -0,0 +1,112 @@
1
+ # File Hooks Reference
2
+
3
+ ## useFileUpload
4
+
5
+ Upload, download, and manage files in a SurrealDB bucket.
6
+
7
+ ### Signatures
8
+
9
+ ```typescript
10
+ // Context-based (inside Sp00kyProvider)
11
+ useFileUpload<S>(bucketName: BucketNames<S>): FileUploadResult;
12
+
13
+ // Explicit db
14
+ useFileUpload<S>(db: SyncedDb<S>, bucketName: BucketNames<S>): FileUploadResult;
15
+ ```
16
+
17
+ ### Return Value
18
+
19
+ ```typescript
20
+ interface FileUploadResult {
21
+ isUploading: () => boolean;
22
+ error: () => Error | null;
23
+ clearError: () => void;
24
+ upload: (path: string, file: File | Blob) => Promise<void>;
25
+ download: (path: string) => Promise<string | null>; // Returns object URL
26
+ remove: (path: string) => Promise<void>;
27
+ exists: (path: string) => Promise<boolean>;
28
+ }
29
+ ```
30
+
31
+ ### Validation
32
+
33
+ If the bucket has `maxSize` or `allowedExtensions` configured in the schema, the hook validates files before upload and sets `error()` on failure.
34
+
35
+ ### Example
36
+
37
+ ```tsx
38
+ function AvatarUpload() {
39
+ const { upload, isUploading, error, clearError } = useFileUpload('avatars');
40
+
41
+ const handleFile = async (e: Event) => {
42
+ const file = (e.target as HTMLInputElement).files?.[0];
43
+ if (file) {
44
+ await upload(`user/${userId()}/avatar.png`, file);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <div>
50
+ <input type="file" onChange={handleFile} disabled={isUploading()} />
51
+ <Show when={error()}>
52
+ <p class="error">{error()!.message}</p>
53
+ <button onClick={clearError}>Dismiss</button>
54
+ </Show>
55
+ </div>
56
+ );
57
+ }
58
+ ```
59
+
60
+ ## useDownloadFile
61
+
62
+ Reactively download a file from a bucket. Re-fetches when the path changes.
63
+
64
+ ### Signatures
65
+
66
+ ```typescript
67
+ // Context-based
68
+ useDownloadFile<S>(
69
+ bucketName: BucketNames<S>,
70
+ path: Accessor<string | null | undefined>,
71
+ options?: { cache?: boolean },
72
+ ): UseDownloadFileResult;
73
+
74
+ // Explicit db
75
+ useDownloadFile<S>(
76
+ db: SyncedDb<S>,
77
+ bucketName: BucketNames<S>,
78
+ path: Accessor<string | null | undefined>,
79
+ options?: { cache?: boolean },
80
+ ): UseDownloadFileResult;
81
+ ```
82
+
83
+ ### Return Value
84
+
85
+ ```typescript
86
+ interface UseDownloadFileResult {
87
+ url: Accessor<string | null>; // Object URL for the file
88
+ isLoading: Accessor<boolean>;
89
+ error: Accessor<Error | null>;
90
+ refetch: () => void; // Force re-download (evicts cache)
91
+ }
92
+ ```
93
+
94
+ ### Caching
95
+
96
+ By default, downloads are cached by `bucket:path` key with reference counting. Object URLs are revoked when no component references them. Set `cache: false` to disable.
97
+
98
+ ### Example
99
+
100
+ ```tsx
101
+ function Avatar(props: { path: string | null }) {
102
+ const { url, isLoading } = useDownloadFile('avatars', () => props.path);
103
+
104
+ return (
105
+ <Show when={!isLoading()} fallback={<Spinner />}>
106
+ <Show when={url()}>
107
+ <img src={url()!} alt="Avatar" />
108
+ </Show>
109
+ </Show>
110
+ );
111
+ }
112
+ ```
@@ -1,6 +1,6 @@
1
1
  export { SurrealDBWasmFactory } from './surrealdb-wasm-factory';
2
2
 
3
- import { Surreal } from 'surrealdb';
3
+ import type { Surreal } from 'surrealdb';
4
4
  import type { CacheStrategy } from '../types';
5
5
 
6
6
  /**
@@ -1,9 +1,11 @@
1
- import { Diagnostic, Surreal, applyDiagnostics } from 'surrealdb';
1
+ import type { Diagnostic} from 'surrealdb';
2
+ import { Surreal, applyDiagnostics } from 'surrealdb';
2
3
  import { createWasmEngines } from '@surrealdb/wasm';
3
4
  import type { CacheStrategy } from '../types';
4
5
 
5
6
  const printDiagnostic = ({ key, type, phase, ...other }: Diagnostic) => {
6
7
  if (phase === 'progress' || phase === 'after') {
8
+ // oxlint-disable-next-line no-console -- intentional diagnostic logging
7
9
  console.log(`[SurrealDB_WASM] [${key}] ${type}:${phase}\n${JSON.stringify(other, null, 2)}`);
8
10
  }
9
11
  };
@@ -11,6 +13,7 @@ const printDiagnostic = ({ key, type, phase, ...other }: Diagnostic) => {
11
13
  /**
12
14
  * SurrealDB WASM client factory for different storage strategies
13
15
  */
16
+ // oxlint-disable-next-line no-extraneous-class -- factory pattern groups related static methods
14
17
  export class SurrealDBWasmFactory {
15
18
  /**
16
19
  * Creates a SurrealDB WASM instance with the specified storage strategy
package/src/index.ts CHANGED
@@ -1,14 +1,17 @@
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,
10
+ type PreloadOptions,
11
+ type PreloadRefresh,
9
12
  } from '@spooky-sync/core';
10
13
 
11
- import {
14
+ import type {
12
15
  GetTable,
13
16
  QueryBuilder,
14
17
  SchemaStructure,
@@ -19,25 +22,39 @@ import {
19
22
  RelationshipFieldsFromSchema,
20
23
  GetRelationship,
21
24
  RelatedFieldMapEntry,
25
+ FinalQuery,
22
26
  InnerQuery,
23
27
  BackendNames,
24
28
  BackendRoutes,
25
29
  RoutePayload,
26
30
  BucketNames,
27
31
  BucketDefinitionSchema,
32
+ QueryModifier,
33
+ QueryModifierBuilder,
34
+ QueryInfo,
35
+ RelationshipsMetadata,
36
+ RelationshipDefinition,
37
+ InferRelatedModelFromMetadata,
38
+ GetCardinality,
28
39
  } from '@spooky-sync/query-builder';
29
40
 
30
- import { RecordId, Uuid, Surreal } from 'surrealdb';
41
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
31
42
  export { RecordId, Uuid };
32
43
  export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
33
44
  export { useQuery } from './lib/use-query';
45
+ export { createPreload } from './lib/create-preload';
46
+ export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
47
+ export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
48
+ export type { SyncHealth, SyncHealthStatus, SyncHealthConfig } from '@spooky-sync/core';
49
+ export { useCrdtField } from './lib/use-crdt-field';
50
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
34
51
  export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
35
52
  export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
36
- export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
53
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
37
54
  export { useDb } from './lib/context';
38
55
 
39
56
  // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
40
- export type {};
57
+
41
58
 
42
59
  // Re-export query builder types for convenience
43
60
  export type {
@@ -52,7 +69,7 @@ export type {
52
69
  TableModel,
53
70
  TableNames,
54
71
  QueryResult,
55
- } from '@spooky-sync/query-builder';
72
+ };
56
73
 
57
74
  export type RelationshipField<
58
75
  Schema extends SchemaStructure,
@@ -94,30 +111,30 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
94
111
  };
95
112
 
96
113
  /**
97
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
98
- * Delegates all logic to the underlying spooky-ts instance
114
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
115
+ * Delegates all logic to the underlying sp00ky-ts instance
99
116
  */
100
117
  export class SyncedDb<S extends SchemaStructure> {
101
118
  private config: SyncedDbConfig<S>;
102
- private spooky: SpookyClient<S> | null = null;
119
+ private sp00ky: Sp00kyClient<S> | null = null;
103
120
  private _initialized = false;
104
121
 
105
122
  constructor(config: SyncedDbConfig<S>) {
106
123
  this.config = config;
107
124
  }
108
125
 
109
- public getSpooky(): SpookyClient<S> {
110
- if (!this.spooky) throw new Error('SyncedDb not initialized');
111
- return this.spooky;
126
+ public getSp00ky(): Sp00kyClient<S> {
127
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
128
+ return this.sp00ky;
112
129
  }
113
130
 
114
131
  /**
115
- * Initialize the spooky-ts instance
132
+ * Initialize the sp00ky-ts instance
116
133
  */
117
134
  async init(): Promise<void> {
118
135
  if (this._initialized) return;
119
- this.spooky = new SpookyClient<S>(this.config);
120
- await this.spooky.init();
136
+ this.sp00ky = new Sp00kyClient<S>(this.config);
137
+ await this.sp00ky.init();
121
138
  this._initialized = true;
122
139
  }
123
140
 
@@ -125,8 +142,8 @@ export class SyncedDb<S extends SchemaStructure> {
125
142
  * Create a new record in the database
126
143
  */
127
144
  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>);
145
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
146
+ await this.sp00ky.create(id, payload as Record<string, unknown>);
130
147
  }
131
148
 
132
149
  /**
@@ -138,8 +155,8 @@ export class SyncedDb<S extends SchemaStructure> {
138
155
  payload: Partial<TableModel<GetTable<S, TName>>>,
139
156
  options?: UpdateOptions
140
157
  ): Promise<void> {
141
- if (!this.spooky) throw new Error('SyncedDb not initialized');
142
- await this.spooky.update(
158
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
159
+ await this.sp00ky.update(
143
160
  tableName as string,
144
161
  recordId,
145
162
  payload as Record<string, unknown>,
@@ -152,12 +169,39 @@ export class SyncedDb<S extends SchemaStructure> {
152
169
  */
153
170
  async delete<TName extends TableNames<S>>(
154
171
  tableName: TName,
155
- selector: string | InnerQuery<GetTable<S, TName>, boolean>
172
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
173
+ ): Promise<void> {
174
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
175
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
176
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
177
+ // directly. Build the canonical string from the raw id part (not
178
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
179
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
180
+ // supported yet. (cross-package RecordId instances → match by constructor name.)
181
+ const isRecordId =
182
+ selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
183
+ let id: string;
184
+ if (typeof selector === 'string') {
185
+ id = selector;
186
+ } else if (isRecordId) {
187
+ id = `${tableName as string}:${(selector as RecordId).id}`;
188
+ } else {
189
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
190
+ }
191
+ await this.sp00ky.delete(tableName as string, id);
192
+ }
193
+
194
+ /**
195
+ * Preload/prewarm a built query into the local cache without registering a
196
+ * live view. Fetches once and stores the rows (+ embedded related children)
197
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
198
+ */
199
+ public async preload(
200
+ finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
201
+ options?: PreloadOptions
156
202
  ): 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);
203
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
204
+ await this.sp00ky.preload(finalQuery, options);
161
205
  }
162
206
 
163
207
  /**
@@ -165,9 +209,9 @@ export class SyncedDb<S extends SchemaStructure> {
165
209
  */
166
210
  public query<TName extends TableNames<S>>(
167
211
  table: TName
168
- ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
169
- if (!this.spooky) throw new Error('SyncedDb not initialized');
170
- return this.spooky.query(table, {});
212
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
213
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
214
+ return this.sp00ky.query(table, {});
171
215
  }
172
216
 
173
217
  /**
@@ -182,17 +226,17 @@ export class SyncedDb<S extends SchemaStructure> {
182
226
  payload: RoutePayload<S, B, R>,
183
227
  options?: RunOptions,
184
228
  ): Promise<void> {
185
- if (!this.spooky) throw new Error('SyncedDb not initialized');
186
- await this.spooky.run(backend, path, payload, options);
229
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
230
+ await this.sp00ky.run(backend, path, payload, options);
187
231
  }
188
232
 
189
233
  /**
190
234
  * Authenticate with the database
191
235
  */
192
236
  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);
237
+ await this.sp00ky?.authenticate(token);
238
+ // Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
239
+ // Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
196
240
  // SurrealDB authenticate returns void? or token?
197
241
  // Assuming void or token.
198
242
  return new RecordId('user', 'me'); // Placeholder or actual?
@@ -210,54 +254,76 @@ export class SyncedDb<S extends SchemaStructure> {
210
254
  * Sign out, clear session and local storage
211
255
  */
212
256
  public async signOut(): Promise<void> {
213
- if (!this.spooky) throw new Error('SyncedDb not initialized');
214
- await this.spooky.auth.signOut();
257
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
258
+ await this.sp00ky.auth.signOut();
215
259
  }
216
260
 
217
261
  /**
218
262
  * Execute a function with direct access to the remote database connection
219
263
  */
220
264
  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);
265
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
266
+ return await this.sp00ky.useRemote(fn);
223
267
  }
224
268
  /**
225
269
  * Access the remote database service directly
226
270
  */
227
- get remote(): SpookyClient<S>['remoteClient'] {
228
- if (!this.spooky) throw new Error('SyncedDb not initialized');
229
- return this.spooky.remoteClient;
271
+ get remote(): Sp00kyClient<S>['remoteClient'] {
272
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
273
+ return this.sp00ky.remoteClient;
230
274
  }
231
275
 
232
276
  /**
233
277
  * Access the local database service directly
234
278
  */
235
- get local(): SpookyClient<S>['localClient'] {
236
- if (!this.spooky) throw new Error('SyncedDb not initialized');
237
- return this.spooky.localClient;
279
+ get local(): Sp00kyClient<S>['localClient'] {
280
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
281
+ return this.sp00ky.localClient;
238
282
  }
239
283
 
240
284
  /**
241
285
  * Access the auth service
242
286
  */
243
287
  get auth(): AuthService<S> {
244
- if (!this.spooky) throw new Error('SyncedDb not initialized');
245
- return this.spooky.auth;
288
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
289
+ return this.sp00ky.auth;
246
290
  }
247
291
 
248
292
  get pendingMutationCount(): number {
249
- if (!this.spooky) throw new Error('SyncedDb not initialized');
250
- return this.spooky.pendingMutationCount;
293
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
294
+ return this.sp00ky.pendingMutationCount;
295
+ }
296
+
297
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
298
+ get liveRetryCount(): number {
299
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
300
+ return this.sp00ky.liveRetryCount;
251
301
  }
252
302
 
253
303
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
254
- if (!this.spooky) throw new Error('SyncedDb not initialized');
255
- return this.spooky.subscribeToPendingMutations(cb);
304
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
305
+ return this.sp00ky.subscribeToPendingMutations(cb);
306
+ }
307
+
308
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
309
+ get syncHealth(): SyncHealth {
310
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
311
+ return this.sp00ky.syncHealth;
312
+ }
313
+
314
+ /**
315
+ * Observe sync health. Fires immediately with the current status and again
316
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
317
+ * components; this is the imperative escape hatch.
318
+ */
319
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
320
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
321
+ return this.sp00ky.subscribeToSyncHealth(cb);
256
322
  }
257
323
 
258
324
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
259
- if (!this.spooky) throw new Error('SyncedDb not initialized');
260
- return this.spooky.bucket(name);
325
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
326
+ return this.sp00ky.bucket(name);
261
327
  }
262
328
 
263
329
  getBucketConfig(name: string): BucketDefinitionSchema | undefined {
@@ -0,0 +1,76 @@
1
+ import type { JSX} from 'solid-js';
2
+ import { createSignal, onMount, createComponent, createMemo, mergeProps } from 'solid-js';
3
+ import type { SchemaStructure } from '@spooky/query-builder';
4
+ import type { SyncedDbConfig } from '../types';
5
+ import { SyncedDb } from '../index';
6
+ import { Sp00kyContext } from './context';
7
+
8
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
9
+ config: SyncedDbConfig<S>;
10
+ fallback?: JSX.Element;
11
+ onError?: (error: Error) => void;
12
+ onReady?: (db: SyncedDb<S>) => void;
13
+ /**
14
+ * Prewarm data into the local cache before revealing the UI. Runs after
15
+ * `init()`; the `fallback` stays visible until it resolves. Use awaitable
16
+ * `db.preload(...)` calls here to gate first-load on essential data (e.g.
17
+ * config). On warm loads preload returns instantly, so there's no perceptible
18
+ * gate after the first run. Best-effort: a rejection is caught and the UI is
19
+ * revealed anyway.
20
+ */
21
+ preload?: (db: SyncedDb<S>) => Promise<void>;
22
+ children: JSX.Element;
23
+ }
24
+
25
+ export function Sp00kyProvider<S extends SchemaStructure>(
26
+ props: Sp00kyProviderProps<S>
27
+ ): JSX.Element {
28
+ const merged = mergeProps(
29
+ {
30
+ fallback: undefined as JSX.Element | undefined,
31
+ },
32
+ props
33
+ );
34
+
35
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
36
+
37
+ onMount(async () => {
38
+ try {
39
+ const instance = new SyncedDb<S>(merged.config);
40
+ await instance.init();
41
+ // Gate first-load UI on prewarmed data. Best-effort: never let a preload
42
+ // failure keep the app stuck on the fallback.
43
+ if (merged.preload) {
44
+ try {
45
+ await merged.preload(instance);
46
+ } catch (e) {
47
+ // oxlint-disable-next-line no-console
48
+ console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
49
+ }
50
+ }
51
+ setDb(() => instance);
52
+ merged.onReady?.(instance);
53
+ } catch (e) {
54
+ const error = e instanceof Error ? e : new Error(String(e));
55
+ if (merged.onError) {
56
+ merged.onError(error);
57
+ } else {
58
+ // oxlint-disable-next-line no-console
59
+ console.error('Sp00kyProvider: Failed to initialize database', error);
60
+ }
61
+ }
62
+ });
63
+
64
+ const content = createMemo(() => {
65
+ const instance = db();
66
+ if (!instance) return merged.fallback;
67
+ return createComponent(Sp00kyContext.Provider, {
68
+ value: instance,
69
+ get children() {
70
+ return merged.children;
71
+ },
72
+ });
73
+ });
74
+
75
+ return content as unknown as JSX.Element;
76
+ }
@@ -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
  }
@@ -0,0 +1,111 @@
1
+ import type {
2
+ ColumnSchema,
3
+ FinalQuery,
4
+ SchemaStructure,
5
+ TableNames,
6
+ } from '@spooky-sync/query-builder';
7
+ import { createEffect, useContext } from 'solid-js';
8
+ import { SyncedDb } from '..';
9
+ import type { Sp00kyQueryResultPromise, PreloadOptions as CorePreloadOptions } from '@spooky-sync/core';
10
+ import { Sp00kyContext } from './context';
11
+
12
+ type PreloadArg<
13
+ S extends SchemaStructure,
14
+ TableName extends TableNames<S>,
15
+ T extends { columns: Record<string, ColumnSchema> },
16
+ RelatedFields extends Record<string, any>,
17
+ IsOne extends boolean,
18
+ > =
19
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
20
+ | (() =>
21
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
22
+ | null
23
+ | undefined);
24
+
25
+ type PreloadOptions = CorePreloadOptions & {
26
+ /** Only preload while this returns true (defaults to always). */
27
+ enabled?: () => boolean;
28
+ };
29
+
30
+ // Overload: context-based (no explicit db)
31
+ export function createPreload<
32
+ S extends SchemaStructure,
33
+ TableName extends TableNames<S>,
34
+ T extends { columns: Record<string, ColumnSchema> },
35
+ RelatedFields extends Record<string, any>,
36
+ IsOne extends boolean,
37
+ >(
38
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
39
+ options?: PreloadOptions,
40
+ ): void;
41
+
42
+ // Overload: explicit db
43
+ export function createPreload<
44
+ S extends SchemaStructure,
45
+ TableName extends TableNames<S>,
46
+ T extends { columns: Record<string, ColumnSchema> },
47
+ RelatedFields extends Record<string, any>,
48
+ IsOne extends boolean,
49
+ >(
50
+ db: SyncedDb<S>,
51
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
52
+ options?: PreloadOptions,
53
+ ): void;
54
+
55
+ /**
56
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
57
+ * function so it tracks reactive deps), dedupes on the query's stable identity
58
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
59
+ * no cleanup: preload registers nothing that needs tearing down.
60
+ *
61
+ * Typical use: inside a list row, preload the detail query the user is likely
62
+ * to open next, so navigation paints from cache instead of the network.
63
+ */
64
+ export function createPreload<
65
+ S extends SchemaStructure,
66
+ TableName extends TableNames<S>,
67
+ T extends { columns: Record<string, ColumnSchema> },
68
+ RelatedFields extends Record<string, any>,
69
+ IsOne extends boolean,
70
+ >(
71
+ dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
72
+ queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
73
+ maybeOptions?: PreloadOptions,
74
+ ): void {
75
+ let db: SyncedDb<S>;
76
+ let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
77
+ let options: PreloadOptions | undefined;
78
+
79
+ if (dbOrQuery instanceof SyncedDb) {
80
+ db = dbOrQuery;
81
+ finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
82
+ options = maybeOptions;
83
+ } else {
84
+ const contextDb = useContext(Sp00kyContext);
85
+ if (!contextDb) {
86
+ throw new Error(
87
+ 'createPreload: No db argument provided and no Sp00kyContext found. ' +
88
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.',
89
+ );
90
+ }
91
+ db = contextDb as SyncedDb<S>;
92
+ finalQuery = dbOrQuery;
93
+ options = queryOrOptions as PreloadOptions | undefined;
94
+ }
95
+
96
+ let prevHash: number | undefined;
97
+
98
+ createEffect(() => {
99
+ if (!(options?.enabled?.() ?? true)) return;
100
+
101
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
102
+ if (!query) return;
103
+
104
+ // Dedupe on the query's stable identity hash so a reactive re-run with an
105
+ // unchanged query doesn't refetch (the core also dedupes per session).
106
+ if (query.hash === prevHash) return;
107
+ prevHash = query.hash;
108
+
109
+ void db.getSp00ky().preload(query, { refresh: options?.refresh, staleTime: options?.staleTime });
110
+ });
111
+ }
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';