@spooky-sync/client-solid 0.0.1-canary.161 → 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 +38 -98
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +34 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -98
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-solid/references/file-hooks.md +33 -4
- package/src/index.ts +7 -1
- package/src/lib/Sp00kyProvider.ts +11 -9
- package/src/lib/use-download-file.ts +65 -109
- package/src/lib/use-sync-status.ts +15 -1
|
@@ -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 };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
2
|
import { useDb } from './context';
|
|
3
|
-
import type { SyncHealth, SyncHealthStatus } from '@spooky-sync/core';
|
|
3
|
+
import type { ConnectionState, SyncHealth, SyncHealthStatus } from '@spooky-sync/core';
|
|
4
4
|
|
|
5
5
|
export interface UseSyncStatus {
|
|
6
6
|
/** Full health snapshot; updates reactively on every transition. */
|
|
@@ -19,6 +19,18 @@ export interface UseSyncStatus {
|
|
|
19
19
|
* app has actually connected once.
|
|
20
20
|
*/
|
|
21
21
|
isOffline: Accessor<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* Transport state of the remote WebSocket. Flips the instant the socket
|
|
24
|
+
* drops, unlike `status`, which only degrades after a sustained run of failed
|
|
25
|
+
* sync rounds — so this is what to drive a "reconnecting…" affordance off.
|
|
26
|
+
*/
|
|
27
|
+
connection: Accessor<ConnectionState>;
|
|
28
|
+
/**
|
|
29
|
+
* `true` while the connection is being re-established. Usually still
|
|
30
|
+
* `isHealthy()`: a short reconnect is invisible to sync, and writes made
|
|
31
|
+
* during it are queued locally and pushed once the socket is back.
|
|
32
|
+
*/
|
|
33
|
+
isReconnecting: Accessor<boolean>;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
/**
|
|
@@ -46,5 +58,7 @@ export function useSyncStatus(): UseSyncStatus {
|
|
|
46
58
|
isDegraded: () => health().status === 'degraded',
|
|
47
59
|
everConnected: () => health().everConnected,
|
|
48
60
|
isOffline: () => health().status === 'degraded' && health().everConnected,
|
|
61
|
+
connection: () => health().connection,
|
|
62
|
+
isReconnecting: () => health().connection === 'reconnecting',
|
|
49
63
|
};
|
|
50
64
|
}
|