@spooky-sync/client-solid 0.0.1-canary.20 → 0.0.1-canary.201

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,18 @@
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 StorageHealth,
11
+ type PreloadOptions,
12
+ type PreloadRefresh,
9
13
  } from '@spooky-sync/core';
10
14
 
11
- import {
15
+ import type {
12
16
  GetTable,
13
17
  QueryBuilder,
14
18
  SchemaStructure,
@@ -19,25 +23,63 @@ import {
19
23
  RelationshipFieldsFromSchema,
20
24
  GetRelationship,
21
25
  RelatedFieldMapEntry,
26
+ FinalQuery,
22
27
  InnerQuery,
23
28
  BackendNames,
24
29
  BackendRoutes,
25
30
  RoutePayload,
26
31
  BucketNames,
27
32
  BucketDefinitionSchema,
33
+ QueryModifier,
34
+ QueryModifierBuilder,
35
+ QueryInfo,
36
+ RelationshipsMetadata,
37
+ RelationshipDefinition,
38
+ InferRelatedModelFromMetadata,
39
+ GetCardinality,
28
40
  } from '@spooky-sync/query-builder';
29
41
 
30
- import { RecordId, Uuid, Surreal } from 'surrealdb';
42
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
31
43
  export { RecordId, Uuid };
32
44
  export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
33
45
  export { useQuery } from './lib/use-query';
46
+ export { createPreload } from './lib/create-preload';
47
+ export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
48
+ export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
49
+ export type {
50
+ SyncHealth,
51
+ SyncHealthStatus,
52
+ SyncHealthConfig,
53
+ ConnectionState,
54
+ ReconnectConfig,
55
+ } from '@spooky-sync/core';
56
+ export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
57
+ export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
58
+ export { useCrdtField } from './lib/use-crdt-field';
59
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
60
+ export {
61
+ useAppRelease,
62
+ type UseAppRelease,
63
+ type UseAppReleaseOptions,
64
+ } from './lib/use-app-release';
34
65
  export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
35
- export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
36
- export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
66
+ export {
67
+ useDownloadFile,
68
+ type UseDownloadFileOptions,
69
+ type UseDownloadFileResult,
70
+ } from './lib/use-download-file';
71
+ export { useBlurhash, type UseBlurhashResult } from './lib/use-blurhash';
72
+ export {
73
+ useBucketImage,
74
+ type UseBucketImageOptions,
75
+ type UseBucketImageResult,
76
+ } from './lib/use-bucket-image';
77
+ export { Blurhash, type BlurhashProps } from './lib/Blurhash';
78
+ export { BucketImage, type BucketImageProps } from './lib/BucketImage';
79
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
37
80
  export { useDb } from './lib/context';
38
81
 
39
82
  // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
40
- export type {};
41
83
 
42
84
  // Re-export query builder types for convenience
43
85
  export type {
@@ -52,7 +94,7 @@ export type {
52
94
  TableModel,
53
95
  TableNames,
54
96
  QueryResult,
55
- } from '@spooky-sync/query-builder';
97
+ };
56
98
 
57
99
  export type RelationshipField<
58
100
  Schema extends SchemaStructure,
@@ -94,39 +136,52 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
94
136
  };
95
137
 
96
138
  /**
97
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
98
- * Delegates all logic to the underlying spooky-ts instance
139
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
140
+ * Delegates all logic to the underlying sp00ky-ts instance
99
141
  */
100
142
  export class SyncedDb<S extends SchemaStructure> {
101
143
  private config: SyncedDbConfig<S>;
102
- private spooky: SpookyClient<S> | null = null;
144
+ private sp00ky: Sp00kyClient<S> | null = null;
103
145
  private _initialized = false;
104
146
 
105
147
  constructor(config: SyncedDbConfig<S>) {
106
148
  this.config = config;
107
149
  }
108
150
 
109
- public getSpooky(): SpookyClient<S> {
110
- if (!this.spooky) throw new Error('SyncedDb not initialized');
111
- return this.spooky;
151
+ public getSp00ky(): Sp00kyClient<S> {
152
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
153
+ return this.sp00ky;
112
154
  }
113
155
 
114
156
  /**
115
- * Initialize the spooky-ts instance
157
+ * Initialize the sp00ky-ts instance
116
158
  */
117
159
  async init(): Promise<void> {
118
160
  if (this._initialized) return;
119
- this.spooky = new SpookyClient<S>(this.config);
120
- await this.spooky.init();
161
+ this.sp00ky = new Sp00kyClient<S>(this.config);
162
+ await this.sp00ky.init();
121
163
  this._initialized = true;
122
164
  }
123
165
 
166
+ /**
167
+ * Tear down the client: leaves the tabs broker, closes the local store and
168
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
169
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
170
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
171
+ */
172
+ async close(): Promise<void> {
173
+ const instance = this.sp00ky;
174
+ this.sp00ky = null;
175
+ this._initialized = false;
176
+ if (instance) await instance.close();
177
+ }
178
+
124
179
  /**
125
180
  * Create a new record in the database
126
181
  */
127
182
  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>);
183
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
184
+ await this.sp00ky.create(id, payload as Record<string, unknown>);
130
185
  }
131
186
 
132
187
  /**
@@ -138,8 +193,8 @@ export class SyncedDb<S extends SchemaStructure> {
138
193
  payload: Partial<TableModel<GetTable<S, TName>>>,
139
194
  options?: UpdateOptions
140
195
  ): Promise<void> {
141
- if (!this.spooky) throw new Error('SyncedDb not initialized');
142
- await this.spooky.update(
196
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
197
+ await this.sp00ky.update(
143
198
  tableName as string,
144
199
  recordId,
145
200
  payload as Record<string, unknown>,
@@ -152,12 +207,39 @@ export class SyncedDb<S extends SchemaStructure> {
152
207
  */
153
208
  async delete<TName extends TableNames<S>>(
154
209
  tableName: TName,
155
- selector: string | InnerQuery<GetTable<S, TName>, boolean>
210
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
156
211
  ): 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);
212
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
213
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
214
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
215
+ // directly. Build the canonical string from the raw id part (not
216
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
217
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
218
+ // supported yet. (cross-package RecordId instances → match by constructor name.)
219
+ const isRecordId =
220
+ selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
221
+ let id: string;
222
+ if (typeof selector === 'string') {
223
+ id = selector;
224
+ } else if (isRecordId) {
225
+ id = `${tableName as string}:${(selector as RecordId).id}`;
226
+ } else {
227
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
228
+ }
229
+ await this.sp00ky.delete(tableName as string, id);
230
+ }
231
+
232
+ /**
233
+ * Preload/prewarm a built query into the local cache without registering a
234
+ * live view. Fetches once and stores the rows (+ embedded related children)
235
+ * locally so a later `useQuery` for the same data paints instantly. Best-effort.
236
+ */
237
+ public async preload(
238
+ finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
239
+ options?: PreloadOptions
240
+ ): Promise<void> {
241
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
242
+ await this.sp00ky.preload(finalQuery, options);
161
243
  }
162
244
 
163
245
  /**
@@ -165,34 +247,31 @@ export class SyncedDb<S extends SchemaStructure> {
165
247
  */
166
248
  public query<TName extends TableNames<S>>(
167
249
  table: TName
168
- ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
169
- if (!this.spooky) throw new Error('SyncedDb not initialized');
170
- return this.spooky.query(table, {});
250
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
251
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
252
+ return this.sp00ky.query(table, {});
171
253
  }
172
254
 
173
255
  /**
174
256
  * Run a backend operation
175
257
  */
176
- public async run<
177
- B extends BackendNames<S>,
178
- R extends BackendRoutes<S, B>,
179
- >(
258
+ public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
180
259
  backend: B,
181
260
  path: R,
182
261
  payload: RoutePayload<S, B, R>,
183
- options?: RunOptions,
262
+ options?: RunOptions
184
263
  ): Promise<void> {
185
- if (!this.spooky) throw new Error('SyncedDb not initialized');
186
- await this.spooky.run(backend, path, payload, options);
264
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
265
+ await this.sp00ky.run(backend, path, payload, options);
187
266
  }
188
267
 
189
268
  /**
190
269
  * Authenticate with the database
191
270
  */
192
271
  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);
272
+ await this.sp00ky?.authenticate(token);
273
+ // Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
274
+ // Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
196
275
  // SurrealDB authenticate returns void? or token?
197
276
  // Assuming void or token.
198
277
  return new RecordId('user', 'me'); // Placeholder or actual?
@@ -210,54 +289,92 @@ export class SyncedDb<S extends SchemaStructure> {
210
289
  * Sign out, clear session and local storage
211
290
  */
212
291
  public async signOut(): Promise<void> {
213
- if (!this.spooky) throw new Error('SyncedDb not initialized');
214
- await this.spooky.auth.signOut();
292
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
293
+ await this.sp00ky.auth.signOut();
215
294
  }
216
295
 
217
296
  /**
218
297
  * Execute a function with direct access to the remote database connection
219
298
  */
220
299
  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);
300
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
301
+ return await this.sp00ky.useRemote(fn);
223
302
  }
224
303
  /**
225
304
  * Access the remote database service directly
226
305
  */
227
- get remote(): SpookyClient<S>['remoteClient'] {
228
- if (!this.spooky) throw new Error('SyncedDb not initialized');
229
- return this.spooky.remoteClient;
306
+ get remote(): Sp00kyClient<S>['remoteClient'] {
307
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
308
+ return this.sp00ky.remoteClient;
230
309
  }
231
310
 
232
311
  /**
233
312
  * Access the local database service directly
234
313
  */
235
- get local(): SpookyClient<S>['localClient'] {
236
- if (!this.spooky) throw new Error('SyncedDb not initialized');
237
- return this.spooky.localClient;
314
+ get local(): Sp00kyClient<S>['localClient'] {
315
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
316
+ return this.sp00ky.localClient;
238
317
  }
239
318
 
240
319
  /**
241
320
  * Access the auth service
242
321
  */
243
322
  get auth(): AuthService<S> {
244
- if (!this.spooky) throw new Error('SyncedDb not initialized');
245
- return this.spooky.auth;
323
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
324
+ return this.sp00ky.auth;
246
325
  }
247
326
 
248
327
  get pendingMutationCount(): number {
249
- if (!this.spooky) throw new Error('SyncedDb not initialized');
250
- return this.spooky.pendingMutationCount;
328
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
329
+ return this.sp00ky.pendingMutationCount;
330
+ }
331
+
332
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
333
+ get liveRetryCount(): number {
334
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
335
+ return this.sp00ky.liveRetryCount;
251
336
  }
252
337
 
253
338
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
254
- if (!this.spooky) throw new Error('SyncedDb not initialized');
255
- return this.spooky.subscribeToPendingMutations(cb);
339
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
340
+ return this.sp00ky.subscribeToPendingMutations(cb);
341
+ }
342
+
343
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
344
+ get syncHealth(): SyncHealth {
345
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
346
+ return this.sp00ky.syncHealth;
347
+ }
348
+
349
+ /**
350
+ * Observe sync health. Fires immediately with the current status and again
351
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
352
+ * components; this is the imperative escape hatch.
353
+ */
354
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
355
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
356
+ return this.sp00ky.subscribeToSyncHealth(cb);
357
+ }
358
+
359
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
360
+ get storageHealth(): StorageHealth {
361
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
362
+ return this.sp00ky.storageHealth;
363
+ }
364
+
365
+ /**
366
+ * Observe local-store durability. Fires immediately with the current snapshot
367
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
368
+ * is the imperative escape hatch.
369
+ */
370
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
371
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
372
+ return this.sp00ky.subscribeToStorageHealth(cb);
256
373
  }
257
374
 
258
375
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
259
- if (!this.spooky) throw new Error('SyncedDb not initialized');
260
- return this.spooky.bucket(name);
376
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
377
+ return this.sp00ky.bucket(name);
261
378
  }
262
379
 
263
380
  getBucketConfig(name: string): BucketDefinitionSchema | undefined {
@@ -0,0 +1,54 @@
1
+ import { createEffect } from 'solid-js';
2
+ import type { JSX } from 'solid-js';
3
+ import { decodeBlurhash } from '@spooky-sync/core';
4
+
5
+ export interface BlurhashProps {
6
+ /** The blurhash string. Nullish paints nothing (transparent canvas). */
7
+ hash: string | null | undefined;
8
+ /** Decode resolution. 32x32 is plenty: blurhash carries at most 9x9 DCT
9
+ * components, the canvas is meant to be CSS-scaled to fill. */
10
+ width?: number;
11
+ height?: number;
12
+ /** Contrast punch, see the blurhash reference decoder. Defaults to 1. */
13
+ punch?: number;
14
+ class?: string;
15
+ style?: string;
16
+ }
17
+
18
+ /**
19
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
20
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
21
+ * resolution stays tiny regardless of the displayed size.
22
+ */
23
+ export function Blurhash(props: BlurhashProps): JSX.Element {
24
+ if (typeof document === 'undefined') return null;
25
+ const canvas = document.createElement('canvas');
26
+
27
+ createEffect(() => {
28
+ canvas.className = props.class ?? '';
29
+ });
30
+ createEffect(() => {
31
+ canvas.style.cssText = props.style ?? '';
32
+ });
33
+
34
+ createEffect(() => {
35
+ const width = props.width ?? 32;
36
+ const height = props.height ?? 32;
37
+ canvas.width = width;
38
+ canvas.height = height;
39
+ const hash = props.hash;
40
+ if (!hash) return;
41
+ try {
42
+ const pixels = decodeBlurhash(hash, width, height, props.punch ?? 1);
43
+ const ctx = canvas.getContext('2d');
44
+ if (!ctx) return;
45
+ const imageData = ctx.createImageData(width, height);
46
+ imageData.data.set(pixels);
47
+ ctx.putImageData(imageData, 0, 0);
48
+ } catch {
49
+ // An invalid hash paints nothing; the layer below stays visible.
50
+ }
51
+ });
52
+
53
+ return canvas;
54
+ }
@@ -0,0 +1,141 @@
1
+ import { createSignal, createEffect, onMount, onCleanup, children } from 'solid-js';
2
+ import type { JSX } from 'solid-js';
3
+ import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
4
+ import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
5
+ import { Blurhash } from './Blurhash';
6
+
7
+ export interface BucketImageProps {
8
+ /** Bucket name from the schema. */
9
+ bucket: string;
10
+ /** Path within the bucket. Nullish renders only the fallback layers. */
11
+ path: string | null | undefined;
12
+ alt?: string;
13
+ /** Classes for the container element (sizing/positioning). */
14
+ class?: string;
15
+ /** Classes for the inner `<img>` (the layout styles are inline). */
16
+ imgClass?: string;
17
+ /** `object-fit` for the image. Defaults to `cover`. */
18
+ fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
19
+ /**
20
+ * Bottom placeholder layer (your own plate/skeleton), shown until the image
21
+ * settles. The blurhash layer paints on top of it once the sidecar resolves.
22
+ */
23
+ fallback?: JSX.Element;
24
+ /** Crossfade duration in ms. Defaults to 300. */
25
+ transition?: number;
26
+ /** Crossfade easing. Defaults to an ease-out-expo curve. */
27
+ easing?: string;
28
+ /** Resolve the blurhash sidecar. Defaults to true. */
29
+ blurhash?: boolean;
30
+ /** Download tuning, forwarded to the underlying `useDownloadFile`. */
31
+ options?: UseBucketImageOptions;
32
+ }
33
+
34
+ const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
35
+
36
+ /**
37
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
38
+ * plate, the automatically stored blurhash, and the real image, which stays
39
+ * transparent until the bitmap is DECODED and then crossfades over the
40
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
41
+ * prefers-reduced-motion (instant swap). The container is made
42
+ * `position: relative` unless your `class` positions it already.
43
+ *
44
+ * ```tsx
45
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
46
+ * fallback={<MyPlate />} alt="" />
47
+ * ```
48
+ */
49
+ export function BucketImage(props: BucketImageProps): JSX.Element {
50
+ if (typeof document === 'undefined') return null;
51
+
52
+ const image = useBucketImage(
53
+ props.bucket as BucketNames<SchemaStructure>,
54
+ () => props.path,
55
+ { ...props.options, blurhash: props.blurhash !== false }
56
+ );
57
+
58
+ const reducedMotion =
59
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
60
+
61
+ const root = document.createElement('div');
62
+ createEffect(() => {
63
+ root.className = props.class ?? '';
64
+ });
65
+ // Layers are absolutely positioned; give them an anchor without stomping on
66
+ // a caller class that already positions the container (inline would win).
67
+ onMount(() => {
68
+ if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
69
+ });
70
+
71
+ const placeholder = document.createElement('div');
72
+ placeholder.style.cssText = LAYER_STYLE;
73
+ // Solid hands JSX props over as lazy thunks; `children` resolves them (and
74
+ // nested arrays/functions) to real nodes and keeps them alive reactively.
75
+ const fallbackHolder = document.createElement('div');
76
+ fallbackHolder.style.cssText = LAYER_STYLE;
77
+ placeholder.append(fallbackHolder);
78
+ const resolvedFallback = children(() => props.fallback);
79
+ createEffect(() => {
80
+ const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[];
81
+ fallbackHolder.replaceChildren(...nodes);
82
+ });
83
+ const hashCanvas = Blurhash({
84
+ get hash() {
85
+ return image.blurhash();
86
+ },
87
+ style: LAYER_STYLE,
88
+ });
89
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
90
+
91
+ const img = document.createElement('img');
92
+ img.decoding = 'async';
93
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
94
+ createEffect(() => {
95
+ img.className = props.imgClass ?? '';
96
+ });
97
+ createEffect(() => {
98
+ img.style.objectFit = props.fit ?? 'cover';
99
+ });
100
+ createEffect(() => {
101
+ img.alt = props.alt ?? '';
102
+ });
103
+ createEffect(() => {
104
+ img.style.transition = reducedMotion
105
+ ? 'none'
106
+ : `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`;
107
+ });
108
+ createEffect(() => {
109
+ const url = image.url();
110
+ if (!url) {
111
+ img.removeAttribute('src');
112
+ return;
113
+ }
114
+ img.src = url;
115
+ image.gate(img);
116
+ });
117
+ createEffect(() => {
118
+ img.style.opacity = image.ready() ? '1' : '0';
119
+ });
120
+
121
+ // Placeholders leave the DOM once the fade is over (a shelf of covers should
122
+ // not composite three layers each forever) and come back when the path
123
+ // changes mid-life (`ready` re-arms via the hook).
124
+ const [settled, setSettled] = createSignal(false);
125
+ createEffect(() => {
126
+ if (!image.ready()) {
127
+ setSettled(false);
128
+ return;
129
+ }
130
+ const wait = (reducedMotion ? 0 : (props.transition ?? 300)) + 120;
131
+ const timer = setTimeout(() => setSettled(true), wait);
132
+ onCleanup(() => clearTimeout(timer));
133
+ });
134
+ createEffect(() => {
135
+ if (settled()) placeholder.remove();
136
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
137
+ });
138
+
139
+ root.append(placeholder, img);
140
+ return root;
141
+ }
@@ -0,0 +1,104 @@
1
+ import type { JSX } from 'solid-js';
2
+ import {
3
+ createSignal,
4
+ onMount,
5
+ onCleanup,
6
+ createComponent,
7
+ createMemo,
8
+ mergeProps,
9
+ } from 'solid-js';
10
+ import type { SchemaStructure } from '@spooky/query-builder';
11
+ import type { SyncedDbConfig } from '../types';
12
+ import { SyncedDb } from '../index';
13
+ import { Sp00kyContext } from './context';
14
+
15
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
16
+ config: SyncedDbConfig<S>;
17
+ fallback?: JSX.Element;
18
+ onError?: (error: Error) => void;
19
+ onReady?: (db: SyncedDb<S>) => void;
20
+ /**
21
+ * Prewarm data into the local cache before revealing the UI. Runs after
22
+ * `init()`; the `fallback` stays visible until it resolves. Use awaitable
23
+ * `db.preload(...)` calls here to gate first-load on essential data (e.g.
24
+ * config). On warm loads preload returns instantly, so there's no perceptible
25
+ * gate after the first run. Best-effort: a rejection is caught and the UI is
26
+ * revealed anyway.
27
+ */
28
+ preload?: (db: SyncedDb<S>) => Promise<void>;
29
+ children: JSX.Element;
30
+ }
31
+
32
+ export function Sp00kyProvider<S extends SchemaStructure>(
33
+ props: Sp00kyProviderProps<S>
34
+ ): JSX.Element {
35
+ const merged = mergeProps(
36
+ {
37
+ fallback: undefined as JSX.Element | undefined,
38
+ },
39
+ props
40
+ );
41
+
42
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
43
+
44
+ // `onMount` is async, so a dispose can land mid-init. Only that narrow race is
45
+ // handled here: an instance whose init finished AFTER the provider was already
46
+ // gone is closed, because nothing will ever reference it.
47
+ //
48
+ // A live, mounted client is deliberately NOT closed on cleanup. Doing that
49
+ // nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
50
+ // "SyncedDb not initialized" while reads keep rendering from state that is
51
+ // already subscribed — i.e. mutations die silently and the app looks fine. In
52
+ // a host app the provider wraps the whole tree and only unmounts with the
53
+ // page, where the browser reclaims the worker anyway, so the leak this was
54
+ // meant to fix is worth far less than that risk.
55
+ let disposed = false;
56
+
57
+ onCleanup(() => {
58
+ disposed = true;
59
+ });
60
+
61
+ onMount(async () => {
62
+ try {
63
+ const instance = new SyncedDb<S>(merged.config);
64
+ await instance.init();
65
+ if (disposed) {
66
+ await instance.close();
67
+ return;
68
+ }
69
+ // Gate first-load UI on prewarmed data. Best-effort: never let a preload
70
+ // failure keep the app stuck on the fallback.
71
+ if (merged.preload) {
72
+ try {
73
+ await merged.preload(instance);
74
+ } catch (e) {
75
+ // oxlint-disable-next-line no-console
76
+ console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
77
+ }
78
+ }
79
+ setDb(() => instance);
80
+ merged.onReady?.(instance);
81
+ } catch (e) {
82
+ const error = e instanceof Error ? e : new Error(String(e));
83
+ if (merged.onError) {
84
+ merged.onError(error);
85
+ } else {
86
+ // oxlint-disable-next-line no-console
87
+ console.error('Sp00kyProvider: Failed to initialize database', error);
88
+ }
89
+ }
90
+ });
91
+
92
+ const content = createMemo(() => {
93
+ const instance = db();
94
+ if (!instance) return merged.fallback;
95
+ return createComponent(Sp00kyContext.Provider, {
96
+ value: instance,
97
+ get children() {
98
+ return merged.children;
99
+ },
100
+ });
101
+ });
102
+
103
+ return content as unknown as JSX.Element;
104
+ }