@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.
@@ -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 };
@@ -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
  }