@spooky-sync/client-solid 0.0.1-canary.162 → 0.0.1-canary.163

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.
@@ -64,11 +64,18 @@ Reactively download a file from a bucket. Re-fetches when the path changes.
64
64
  ### Signatures
65
65
 
66
66
  ```typescript
67
+ interface UseDownloadFileOptions {
68
+ cache?: boolean; // default true — false disables every layer
69
+ persist?: boolean; // default true — keep bytes in OPFS
70
+ pin?: boolean; // exempt from pressure eviction
71
+ revalidate?: 'never' | 'head'; // default 'never' (paths are immutable)
72
+ }
73
+
67
74
  // Context-based
68
75
  useDownloadFile<S>(
69
76
  bucketName: BucketNames<S>,
70
77
  path: Accessor<string | null | undefined>,
71
- options?: { cache?: boolean },
78
+ options?: UseDownloadFileOptions,
72
79
  ): UseDownloadFileResult;
73
80
 
74
81
  // Explicit db
@@ -76,7 +83,7 @@ useDownloadFile<S>(
76
83
  db: SyncedDb<S>,
77
84
  bucketName: BucketNames<S>,
78
85
  path: Accessor<string | null | undefined>,
79
- options?: { cache?: boolean },
86
+ options?: UseDownloadFileOptions,
80
87
  ): UseDownloadFileResult;
81
88
  ```
82
89
 
@@ -87,13 +94,35 @@ interface UseDownloadFileResult {
87
94
  url: Accessor<string | null>; // Object URL for the file
88
95
  isLoading: Accessor<boolean>;
89
96
  error: Accessor<Error | null>;
90
- refetch: () => void; // Force re-download (evicts cache)
97
+ refetch: () => void; // Force re-download, bypassing every layer
91
98
  }
92
99
  ```
93
100
 
94
101
  ### Caching
95
102
 
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.
103
+ Three layers, checked in order:
104
+
105
+ 1. **Object URLs**, refcounted per `bucket:path` and shared between components. Revoked once the last holder releases and the entry ages out of a 32-entry hot window.
106
+ 2. **OPFS**, under `sp00ky-blobs/<bucketId>/<bucket>/<path>`. Survives reload, works offline, and is namespaced per signed-in user.
107
+ 3. **The bucket**, over the sync WebSocket.
108
+
109
+ Nothing expires on a timer — an image whose row is still cached locally has to stay viewable offline. Bytes are dropped only when the app invalidates the path (`bucket.put`/`bucket.delete` do this automatically), when boot reconcile finds no file behind a row, or when the cache exceeds its byte budget, in which case the least-recently-used unpinned entries that nothing is rendering go first.
110
+
111
+ Configure with `blobCache: { enabled, maxBytes, clearOnSignOut }` on the client. The budget defaults to `min(512 MB, quota × 0.25)`. Inspect live numbers in the DevTools Storage tab under "Bucket file cache".
112
+
113
+ `persist: false` keeps the in-tab sharing but writes nothing durable. `cache: false` gives each hook instance a private URL fetched fresh and revoked on unmount.
114
+
115
+ A bucket path is treated as immutable, which is how paths are normally written (`crypto.randomUUID() + ext`). If your app overwrites a path in place from another device, pass `revalidate: 'head'` — it spends a remote `head()` to compare sizes before trusting the cached copy, and keeps the cached copy when that call fails so going offline never blanks an image.
116
+
117
+ ### Pinning and prefetching
118
+
119
+ ```tsx
120
+ const bucket = db.bucket('avatars');
121
+ await bucket.prefetch(paths); // warm the cache for offline use
122
+ bucket.pin('logo.png'); // never evicted under pressure
123
+ bucket.unpin('logo.png');
124
+ await bucket.evict('old.png'); // drop locally, leave the remote file alone
125
+ ```
97
126
 
98
127
  ### Example
99
128
 
@@ -1,10 +1,31 @@
1
1
  import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
2
2
  import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import type { BlobUrlLease } from '@spooky-sync/core';
3
4
  import type { SyncedDb } from '../index';
4
5
  import { useDb } from './context';
5
6
 
6
7
  export interface UseDownloadFileOptions {
8
+ /**
9
+ * Master switch, default `true`. `false` gives every hook instance its own
10
+ * private object URL fetched fresh from the bucket and revoked on unmount —
11
+ * no sharing, no persistence, no reuse.
12
+ */
7
13
  cache?: boolean;
14
+ /**
15
+ * Keep the bytes in OPFS so they survive a reload and are available offline.
16
+ * Default `true`. Turn off for one-shot or sensitive files; the in-tab object
17
+ * URL is still shared between components rendering the same path.
18
+ */
19
+ persist?: boolean;
20
+ /** Exempt this file from pressure eviction. Pinned bytes never expire. */
21
+ pin?: boolean;
22
+ /**
23
+ * `'never'` (default) treats a bucket path as immutable, which is how paths
24
+ * are written (`crypto.randomUUID() + ext`). `'head'` spends a remote `head()`
25
+ * to compare sizes before trusting the cached copy — for paths the app
26
+ * overwrites in place.
27
+ */
28
+ revalidate?: 'never' | 'head';
8
29
  }
9
30
 
10
31
  export interface UseDownloadFileResult {
@@ -14,28 +35,6 @@ export interface UseDownloadFileResult {
14
35
  refetch: () => void;
15
36
  }
16
37
 
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
38
  export function useDownloadFile<S extends SchemaStructure>(
40
39
  bucketName: BucketNames<S>,
41
40
  path: Accessor<string | null | undefined>,
@@ -76,68 +75,19 @@ export function useDownloadFile<S extends SchemaStructure>(
76
75
  const [isLoading, setIsLoading] = createSignal(false);
77
76
  const [error, setError] = createSignal<Error | null>(null);
78
77
 
79
- let currentKey: string | null = null;
78
+ // Exactly one of these is held at a time: a refcounted lease on the shared
79
+ // cache entry, or a private URL this instance minted and must revoke itself.
80
+ let lease: BlobUrlLease | null = null;
80
81
  let privateUrl: string | null = null;
82
+
81
83
  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
- }
84
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
85
+ let reloadOnce = false;
134
86
 
135
- function releaseCurrentEntry() {
136
- if (useCache && currentKey) {
137
- releaseEntry(currentKey);
138
- currentKey = null;
139
- }
140
- if (!useCache && privateUrl) {
87
+ function releaseCurrent() {
88
+ lease?.release();
89
+ lease = null;
90
+ if (privateUrl) {
141
91
  URL.revokeObjectURL(privateUrl);
142
92
  privateUrl = null;
143
93
  }
@@ -148,8 +98,7 @@ export function useDownloadFile<S extends SchemaStructure>(
148
98
  // Subscribe to refetch signal so effect re-runs
149
99
  refetchSignal();
150
100
 
151
- // Release previous entry
152
- releaseCurrentEntry();
101
+ releaseCurrent();
153
102
 
154
103
  if (!filePath) {
155
104
  setUrl(null);
@@ -158,26 +107,41 @@ export function useDownloadFile<S extends SchemaStructure>(
158
107
  return;
159
108
  }
160
109
 
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
- }
110
+ const reload = reloadOnce;
111
+ reloadOnce = false;
175
112
 
176
113
  let cancelled = false;
177
114
  setIsLoading(true);
178
115
  setError(null);
179
116
 
180
- doDownload(key, filePath).then(
117
+ const bucket = db.bucket(bucketName);
118
+ const resolve = useCache
119
+ ? bucket
120
+ .url(filePath, {
121
+ persist: options.persist !== false,
122
+ pin: options.pin,
123
+ revalidate: options.revalidate,
124
+ reload,
125
+ })
126
+ .then((acquired) => {
127
+ if (!acquired) return null;
128
+ if (cancelled) {
129
+ // Unmounted or the path changed mid-flight — hand the reference
130
+ // straight back, or the entry never drops to zero and its object
131
+ // URL leaks for the life of the tab.
132
+ acquired.release();
133
+ return null;
134
+ }
135
+ lease = acquired;
136
+ return acquired.url;
137
+ })
138
+ : bucket.read(filePath, { persist: false, reload: true }).then((blob) => {
139
+ if (!blob || cancelled) return null;
140
+ privateUrl = URL.createObjectURL(blob);
141
+ return privateUrl;
142
+ });
143
+
144
+ resolve.then(
181
145
  (result) => {
182
146
  if (!cancelled) {
183
147
  setUrl(result);
@@ -199,20 +163,12 @@ export function useDownloadFile<S extends SchemaStructure>(
199
163
  });
200
164
 
201
165
  onCleanup(() => {
202
- releaseCurrentEntry();
166
+ releaseCurrent();
203
167
  });
204
168
 
205
169
  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();
170
+ reloadOnce = true;
171
+ setRefetchSignal((n) => n + 1);
216
172
  };
217
173
 
218
174
  return { url, isLoading, error, refetch };