@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.
- package/AGENTS.md +1 -1
- package/dist/index.cjs +35 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -92
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-solid/references/file-hooks.md +33 -4
- package/src/lib/use-download-file.ts +65 -109
|
@@ -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?:
|
|
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?:
|
|
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
|
|
97
|
+
refetch: () => void; // Force re-download, bypassing every layer
|
|
91
98
|
}
|
|
92
99
|
```
|
|
93
100
|
|
|
94
101
|
### Caching
|
|
95
102
|
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
releaseCurrent();
|
|
203
167
|
});
|
|
204
168
|
|
|
205
169
|
const refetch = () => {
|
|
206
|
-
|
|
207
|
-
|
|
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 };
|