@spooky-sync/client-solid 0.0.1-canary.1 → 0.0.1-canary.100

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,13 +1,15 @@
1
1
  import type { SyncedDbConfig } from './types';
2
2
  import {
3
- SpookyClient,
4
- AuthService,
5
- type SpookyQueryResultPromise,
6
- UpdateOptions,
7
- RunOptions,
3
+ Sp00kyClient,
4
+ type Sp00kyQueryResultPromise,
5
+ type AuthService,
6
+ type BucketHandle,
7
+ type UpdateOptions,
8
+ type RunOptions,
9
+ type SyncHealth,
8
10
  } from '@spooky-sync/core';
9
11
 
10
- import {
12
+ import type {
11
13
  GetTable,
12
14
  QueryBuilder,
13
15
  SchemaStructure,
@@ -22,17 +24,32 @@ import {
22
24
  BackendNames,
23
25
  BackendRoutes,
24
26
  RoutePayload,
27
+ BucketNames,
28
+ BucketDefinitionSchema,
29
+ QueryModifier,
30
+ QueryModifierBuilder,
31
+ QueryInfo,
32
+ RelationshipsMetadata,
33
+ RelationshipDefinition,
34
+ InferRelatedModelFromMetadata,
35
+ GetCardinality,
25
36
  } from '@spooky-sync/query-builder';
26
37
 
27
- import { RecordId, Uuid, Surreal } from 'surrealdb';
38
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
28
39
  export { RecordId, Uuid };
29
40
  export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
30
41
  export { useQuery } from './lib/use-query';
31
- export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
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';
46
+ export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
47
+ export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
48
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
32
49
  export { useDb } from './lib/context';
33
50
 
34
51
  // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
35
- export type {};
52
+
36
53
 
37
54
  // Re-export query builder types for convenience
38
55
  export type {
@@ -47,7 +64,7 @@ export type {
47
64
  TableModel,
48
65
  TableNames,
49
66
  QueryResult,
50
- } from '@spooky-sync/query-builder';
67
+ };
51
68
 
52
69
  export type RelationshipField<
53
70
  Schema extends SchemaStructure,
@@ -89,30 +106,30 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
89
106
  };
90
107
 
91
108
  /**
92
- * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
93
- * 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
94
111
  */
95
112
  export class SyncedDb<S extends SchemaStructure> {
96
113
  private config: SyncedDbConfig<S>;
97
- private spooky: SpookyClient<S> | null = null;
114
+ private sp00ky: Sp00kyClient<S> | null = null;
98
115
  private _initialized = false;
99
116
 
100
117
  constructor(config: SyncedDbConfig<S>) {
101
118
  this.config = config;
102
119
  }
103
120
 
104
- public getSpooky(): SpookyClient<S> {
105
- if (!this.spooky) throw new Error('SyncedDb not initialized');
106
- return this.spooky;
121
+ public getSp00ky(): Sp00kyClient<S> {
122
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
123
+ return this.sp00ky;
107
124
  }
108
125
 
109
126
  /**
110
- * Initialize the spooky-ts instance
127
+ * Initialize the sp00ky-ts instance
111
128
  */
112
129
  async init(): Promise<void> {
113
130
  if (this._initialized) return;
114
- this.spooky = new SpookyClient<S>(this.config);
115
- await this.spooky.init();
131
+ this.sp00ky = new Sp00kyClient<S>(this.config);
132
+ await this.sp00ky.init();
116
133
  this._initialized = true;
117
134
  }
118
135
 
@@ -120,8 +137,8 @@ export class SyncedDb<S extends SchemaStructure> {
120
137
  * Create a new record in the database
121
138
  */
122
139
  async create(id: string, payload: Record<string, unknown>): Promise<void> {
123
- if (!this.spooky) throw new Error('SyncedDb not initialized');
124
- 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>);
125
142
  }
126
143
 
127
144
  /**
@@ -133,8 +150,8 @@ export class SyncedDb<S extends SchemaStructure> {
133
150
  payload: Partial<TableModel<GetTable<S, TName>>>,
134
151
  options?: UpdateOptions
135
152
  ): Promise<void> {
136
- if (!this.spooky) throw new Error('SyncedDb not initialized');
137
- await this.spooky.update(
153
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
154
+ await this.sp00ky.update(
138
155
  tableName as string,
139
156
  recordId,
140
157
  payload as Record<string, unknown>,
@@ -147,12 +164,26 @@ export class SyncedDb<S extends SchemaStructure> {
147
164
  */
148
165
  async delete<TName extends TableNames<S>>(
149
166
  tableName: TName,
150
- selector: string | InnerQuery<GetTable<S, TName>, boolean>
167
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
151
168
  ): Promise<void> {
152
- if (!this.spooky) throw new Error('SyncedDb not initialized');
153
- if (typeof selector !== 'string')
154
- throw new Error('Only string ID selectors are supported currently with core');
155
- 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);
156
187
  }
157
188
 
158
189
  /**
@@ -160,9 +191,9 @@ export class SyncedDb<S extends SchemaStructure> {
160
191
  */
161
192
  public query<TName extends TableNames<S>>(
162
193
  table: TName
163
- ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
164
- if (!this.spooky) throw new Error('SyncedDb not initialized');
165
- 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, {});
166
197
  }
167
198
 
168
199
  /**
@@ -177,17 +208,17 @@ export class SyncedDb<S extends SchemaStructure> {
177
208
  payload: RoutePayload<S, B, R>,
178
209
  options?: RunOptions,
179
210
  ): Promise<void> {
180
- if (!this.spooky) throw new Error('SyncedDb not initialized');
181
- 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);
182
213
  }
183
214
 
184
215
  /**
185
216
  * Authenticate with the database
186
217
  */
187
218
  public async authenticate(token: string): Promise<RecordId<string>> {
188
- const result = await this.spooky?.authenticate(token);
189
- // SpookyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
190
- // 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);
191
222
  // SurrealDB authenticate returns void? or token?
192
223
  // Assuming void or token.
193
224
  return new RecordId('user', 'me'); // Placeholder or actual?
@@ -205,49 +236,80 @@ export class SyncedDb<S extends SchemaStructure> {
205
236
  * Sign out, clear session and local storage
206
237
  */
207
238
  public async signOut(): Promise<void> {
208
- if (!this.spooky) throw new Error('SyncedDb not initialized');
209
- await this.spooky.auth.signOut();
239
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
240
+ await this.sp00ky.auth.signOut();
210
241
  }
211
242
 
212
243
  /**
213
244
  * Execute a function with direct access to the remote database connection
214
245
  */
215
246
  public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
216
- if (!this.spooky) throw new Error('SyncedDb not initialized');
217
- return await this.spooky.useRemote(fn);
247
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
248
+ return await this.sp00ky.useRemote(fn);
218
249
  }
219
250
  /**
220
251
  * Access the remote database service directly
221
252
  */
222
- get remote(): SpookyClient<S>['remoteClient'] {
223
- if (!this.spooky) throw new Error('SyncedDb not initialized');
224
- 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;
225
256
  }
226
257
 
227
258
  /**
228
259
  * Access the local database service directly
229
260
  */
230
- get local(): SpookyClient<S>['localClient'] {
231
- if (!this.spooky) throw new Error('SyncedDb not initialized');
232
- 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;
233
264
  }
234
265
 
235
266
  /**
236
267
  * Access the auth service
237
268
  */
238
269
  get auth(): AuthService<S> {
239
- if (!this.spooky) throw new Error('SyncedDb not initialized');
240
- return this.spooky.auth;
270
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
271
+ return this.sp00ky.auth;
241
272
  }
242
273
 
243
274
  get pendingMutationCount(): number {
244
- if (!this.spooky) throw new Error('SyncedDb not initialized');
245
- 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;
246
283
  }
247
284
 
248
285
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
249
- if (!this.spooky) throw new Error('SyncedDb not initialized');
250
- 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);
304
+ }
305
+
306
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
307
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
308
+ return this.sp00ky.bucket(name);
309
+ }
310
+
311
+ getBucketConfig(name: string): BucketDefinitionSchema | undefined {
312
+ return this.config.schema.buckets?.find((b) => b.name === name);
251
313
  }
252
314
  }
253
315
 
@@ -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
+ }
@@ -0,0 +1,219 @@
1
+ import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
2
+ import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import type { SyncedDb } from '../index';
4
+ import { useDb } from './context';
5
+
6
+ export interface UseDownloadFileOptions {
7
+ cache?: boolean;
8
+ }
9
+
10
+ export interface UseDownloadFileResult {
11
+ url: Accessor<string | null>;
12
+ isLoading: Accessor<boolean>;
13
+ error: Accessor<Error | null>;
14
+ refetch: () => void;
15
+ }
16
+
17
+ interface CacheEntry {
18
+ url: string;
19
+ refCount: number;
20
+ }
21
+
22
+ const downloadCache = new Map<string, CacheEntry>();
23
+ const inflightRequests = new Map<string, Promise<string | null>>();
24
+
25
+ function cacheKey(bucket: string, path: string): string {
26
+ return `${bucket}:${path}`;
27
+ }
28
+
29
+ function releaseEntry(key: string): void {
30
+ const entry = downloadCache.get(key);
31
+ if (!entry) return;
32
+ entry.refCount--;
33
+ if (entry.refCount <= 0) {
34
+ URL.revokeObjectURL(entry.url);
35
+ downloadCache.delete(key);
36
+ }
37
+ }
38
+
39
+ export function useDownloadFile<S extends SchemaStructure>(
40
+ bucketName: BucketNames<S>,
41
+ path: Accessor<string | null | undefined>,
42
+ options?: UseDownloadFileOptions,
43
+ ): UseDownloadFileResult;
44
+ export function useDownloadFile<S extends SchemaStructure>(
45
+ db: SyncedDb<S>,
46
+ bucketName: BucketNames<S>,
47
+ path: Accessor<string | null | undefined>,
48
+ options?: UseDownloadFileOptions,
49
+ ): UseDownloadFileResult;
50
+ export function useDownloadFile<S extends SchemaStructure>(
51
+ dbOrBucketName: SyncedDb<S> | BucketNames<S>,
52
+ bucketNameOrPath?: BucketNames<S> | Accessor<string | null | undefined>,
53
+ pathOrOptions?: Accessor<string | null | undefined> | UseDownloadFileOptions,
54
+ maybeOptions?: UseDownloadFileOptions,
55
+ ): UseDownloadFileResult {
56
+ let db: SyncedDb<S>;
57
+ let bucketName: BucketNames<S>;
58
+ let path: Accessor<string | null | undefined>;
59
+ let options: UseDownloadFileOptions;
60
+
61
+ if (typeof dbOrBucketName === 'string') {
62
+ db = useDb<S>();
63
+ bucketName = dbOrBucketName as BucketNames<S>;
64
+ path = bucketNameOrPath as Accessor<string | null | undefined>;
65
+ options = (pathOrOptions as UseDownloadFileOptions) ?? {};
66
+ } else {
67
+ db = dbOrBucketName as SyncedDb<S>;
68
+ bucketName = bucketNameOrPath as BucketNames<S>;
69
+ path = pathOrOptions as Accessor<string | null | undefined>;
70
+ options = maybeOptions ?? {};
71
+ }
72
+
73
+ const useCache = options.cache !== false;
74
+
75
+ const [url, setUrl] = createSignal<string | null>(null);
76
+ const [isLoading, setIsLoading] = createSignal(false);
77
+ const [error, setError] = createSignal<Error | null>(null);
78
+
79
+ let currentKey: string | null = null;
80
+ let privateUrl: string | null = null;
81
+ const [refetchSignal, setRefetchSignal] = createSignal(0);
82
+ const refetchTrigger = () => setRefetchSignal((n) => n + 1);
83
+
84
+ async function doDownload(key: string, filePath: string): Promise<string | null> {
85
+ if (useCache) {
86
+ // Check cache
87
+ const cached = downloadCache.get(key);
88
+ if (cached) {
89
+ cached.refCount++;
90
+ currentKey = key;
91
+ return cached.url;
92
+ }
93
+
94
+ // Check inflight
95
+ const inflight = inflightRequests.get(key);
96
+ if (inflight) {
97
+ const result = await inflight;
98
+ if (result) {
99
+ const entry = downloadCache.get(key);
100
+ if (entry) {
101
+ entry.refCount++;
102
+ currentKey = key;
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ // Start new download
109
+ const promise = (async () => {
110
+ const content = await db.bucket(bucketName).get(filePath);
111
+ if (!content) return null;
112
+ const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
113
+ downloadCache.set(key, { url: objectUrl, refCount: 1 });
114
+ return objectUrl;
115
+ })();
116
+
117
+ inflightRequests.set(key, promise);
118
+ try {
119
+ const result = await promise;
120
+ currentKey = key;
121
+ return result;
122
+ } finally {
123
+ inflightRequests.delete(key);
124
+ }
125
+ } else {
126
+ // No caching — private URL per instance
127
+ const content = await db.bucket(bucketName).get(filePath);
128
+ if (!content) return null;
129
+ const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
130
+ privateUrl = objectUrl;
131
+ return objectUrl;
132
+ }
133
+ }
134
+
135
+ function releaseCurrentEntry() {
136
+ if (useCache && currentKey) {
137
+ releaseEntry(currentKey);
138
+ currentKey = null;
139
+ }
140
+ if (!useCache && privateUrl) {
141
+ URL.revokeObjectURL(privateUrl);
142
+ privateUrl = null;
143
+ }
144
+ }
145
+
146
+ createEffect(() => {
147
+ const filePath = path();
148
+ // Subscribe to refetch signal so effect re-runs
149
+ refetchSignal();
150
+
151
+ // Release previous entry
152
+ releaseCurrentEntry();
153
+
154
+ if (!filePath) {
155
+ setUrl(null);
156
+ setIsLoading(false);
157
+ setError(null);
158
+ return;
159
+ }
160
+
161
+ const key = cacheKey(bucketName as string, filePath);
162
+
163
+ // Synchronous cache hit
164
+ if (useCache) {
165
+ const cached = downloadCache.get(key);
166
+ if (cached) {
167
+ cached.refCount++;
168
+ currentKey = key;
169
+ setUrl(cached.url);
170
+ setIsLoading(false);
171
+ setError(null);
172
+ return;
173
+ }
174
+ }
175
+
176
+ let cancelled = false;
177
+ setIsLoading(true);
178
+ setError(null);
179
+
180
+ doDownload(key, filePath).then(
181
+ (result) => {
182
+ if (!cancelled) {
183
+ setUrl(result);
184
+ setIsLoading(false);
185
+ }
186
+ return undefined;
187
+ },
188
+ (err) => {
189
+ if (!cancelled) {
190
+ setError(err instanceof Error ? err : new Error(String(err)));
191
+ setIsLoading(false);
192
+ }
193
+ },
194
+ );
195
+
196
+ onCleanup(() => {
197
+ cancelled = true;
198
+ });
199
+ });
200
+
201
+ onCleanup(() => {
202
+ releaseCurrentEntry();
203
+ });
204
+
205
+ const refetch = () => {
206
+ // Evict current entry from cache before re-triggering
207
+ if (useCache && currentKey) {
208
+ const entry = downloadCache.get(currentKey);
209
+ if (entry) {
210
+ URL.revokeObjectURL(entry.url);
211
+ downloadCache.delete(currentKey);
212
+ }
213
+ currentKey = null;
214
+ }
215
+ refetchTrigger();
216
+ };
217
+
218
+ return { url, isLoading, error, refetch };
219
+ }