@spooky-sync/client-solid 0.0.1-canary.27 → 0.0.1-canary.270
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 +70 -0
- package/README.md +20 -0
- package/dist/index.cjs +682 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +393 -22
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +393 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +673 -160
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/skills/{spooky-solid → sp00ky-solid}/SKILL.md +85 -14
- package/skills/{spooky-solid → sp00ky-solid}/references/file-hooks.md +34 -5
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +193 -61
- package/src/lib/Blurhash.ts +54 -0
- package/src/lib/BucketImage.ts +141 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/context.ts +3 -3
- package/src/lib/create-preload.ts +112 -0
- package/src/lib/models.ts +1 -1
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +77 -0
- package/src/lib/use-bucket-image.ts +100 -0
- package/src/lib/use-crdt-field.ts +68 -0
- package/src/lib/use-download-file.ts +66 -110
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +10 -4
- package/src/lib/use-impersonation.ts +44 -0
- package/src/lib/use-query.ts +168 -28
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +3 -4
- package/src/lib/SpookyProvider.ts +0 -55
|
@@ -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,69 +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;
|
|
81
|
-
|
|
82
|
+
|
|
82
83
|
const [refetchSignal, setRefetchSignal] = createSignal(0);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
async function doDownload(key: string, filePath: string): Promise<string | null> {
|
|
86
|
-
if (useCache) {
|
|
87
|
-
// Check cache
|
|
88
|
-
const cached = downloadCache.get(key);
|
|
89
|
-
if (cached) {
|
|
90
|
-
cached.refCount++;
|
|
91
|
-
currentKey = key;
|
|
92
|
-
return cached.url;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Check inflight
|
|
96
|
-
const inflight = inflightRequests.get(key);
|
|
97
|
-
if (inflight) {
|
|
98
|
-
const result = await inflight;
|
|
99
|
-
if (result) {
|
|
100
|
-
const entry = downloadCache.get(key);
|
|
101
|
-
if (entry) {
|
|
102
|
-
entry.refCount++;
|
|
103
|
-
currentKey = key;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Start new download
|
|
110
|
-
const promise = (async () => {
|
|
111
|
-
const content = await db.bucket(bucketName).get(filePath);
|
|
112
|
-
if (!content) return null;
|
|
113
|
-
const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
|
|
114
|
-
downloadCache.set(key, { url: objectUrl, refCount: 1 });
|
|
115
|
-
return objectUrl;
|
|
116
|
-
})();
|
|
117
|
-
|
|
118
|
-
inflightRequests.set(key, promise);
|
|
119
|
-
try {
|
|
120
|
-
const result = await promise;
|
|
121
|
-
currentKey = key;
|
|
122
|
-
return result;
|
|
123
|
-
} finally {
|
|
124
|
-
inflightRequests.delete(key);
|
|
125
|
-
}
|
|
126
|
-
} else {
|
|
127
|
-
// No caching — private URL per instance
|
|
128
|
-
const content = await db.bucket(bucketName).get(filePath);
|
|
129
|
-
if (!content) return null;
|
|
130
|
-
const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
|
|
131
|
-
privateUrl = objectUrl;
|
|
132
|
-
return objectUrl;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
84
|
+
/** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
|
|
85
|
+
let reloadOnce = false;
|
|
135
86
|
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
if (!useCache && privateUrl) {
|
|
87
|
+
function releaseCurrent() {
|
|
88
|
+
lease?.release();
|
|
89
|
+
lease = null;
|
|
90
|
+
if (privateUrl) {
|
|
142
91
|
URL.revokeObjectURL(privateUrl);
|
|
143
92
|
privateUrl = null;
|
|
144
93
|
}
|
|
@@ -149,8 +98,7 @@ export function useDownloadFile<S extends SchemaStructure>(
|
|
|
149
98
|
// Subscribe to refetch signal so effect re-runs
|
|
150
99
|
refetchSignal();
|
|
151
100
|
|
|
152
|
-
|
|
153
|
-
releaseCurrentEntry();
|
|
101
|
+
releaseCurrent();
|
|
154
102
|
|
|
155
103
|
if (!filePath) {
|
|
156
104
|
setUrl(null);
|
|
@@ -159,31 +107,47 @@ export function useDownloadFile<S extends SchemaStructure>(
|
|
|
159
107
|
return;
|
|
160
108
|
}
|
|
161
109
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
// Synchronous cache hit
|
|
165
|
-
if (useCache) {
|
|
166
|
-
const cached = downloadCache.get(key);
|
|
167
|
-
if (cached) {
|
|
168
|
-
cached.refCount++;
|
|
169
|
-
currentKey = key;
|
|
170
|
-
setUrl(cached.url);
|
|
171
|
-
setIsLoading(false);
|
|
172
|
-
setError(null);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
110
|
+
const reload = reloadOnce;
|
|
111
|
+
reloadOnce = false;
|
|
176
112
|
|
|
177
113
|
let cancelled = false;
|
|
178
114
|
setIsLoading(true);
|
|
179
115
|
setError(null);
|
|
180
116
|
|
|
181
|
-
|
|
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(
|
|
182
145
|
(result) => {
|
|
183
146
|
if (!cancelled) {
|
|
184
147
|
setUrl(result);
|
|
185
148
|
setIsLoading(false);
|
|
186
149
|
}
|
|
150
|
+
return undefined;
|
|
187
151
|
},
|
|
188
152
|
(err) => {
|
|
189
153
|
if (!cancelled) {
|
|
@@ -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 };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import type { FeatureFlagOptions } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface UseFeatureFlag {
|
|
6
|
+
variant: Accessor<string | undefined>;
|
|
7
|
+
payload: Accessor<unknown | undefined>;
|
|
8
|
+
enabled: Accessor<boolean>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to a feature flag for the currently authenticated user.
|
|
13
|
+
*
|
|
14
|
+
* Returns three Solid accessors that update reactively whenever the
|
|
15
|
+
* server-materialized assignment in `_00_user_feature` changes. Backed by
|
|
16
|
+
* the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
|
|
17
|
+
* via `spky flag enable <key>` propagates to the UI without a refresh.
|
|
18
|
+
*
|
|
19
|
+
* `enabled()` is `true` when the resolved variant exists and is not 'off'.
|
|
20
|
+
* For multi-variant flags, prefer `variant()` directly.
|
|
21
|
+
*/
|
|
22
|
+
export function useFeatureFlag(
|
|
23
|
+
key: string,
|
|
24
|
+
options?: FeatureFlagOptions,
|
|
25
|
+
): UseFeatureFlag {
|
|
26
|
+
const db = useDb();
|
|
27
|
+
const handle = db.getSp00ky().feature(key, options);
|
|
28
|
+
|
|
29
|
+
const [variant, setVariant] = createSignal<string | undefined>(handle.variant());
|
|
30
|
+
const [payload, setPayload] = createSignal<unknown | undefined>(handle.payload());
|
|
31
|
+
|
|
32
|
+
const unsub = handle.subscribe((s) => {
|
|
33
|
+
setVariant(s.variant ?? options?.fallback);
|
|
34
|
+
setPayload(s.payload);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
onCleanup(() => {
|
|
38
|
+
unsub();
|
|
39
|
+
handle.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
variant,
|
|
44
|
+
payload,
|
|
45
|
+
enabled: () => {
|
|
46
|
+
const v = variant();
|
|
47
|
+
return v !== undefined && v !== 'off';
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSignal, onCleanup } from 'solid-js';
|
|
2
2
|
import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
|
|
3
3
|
import { fileToUint8Array } from '@spooky-sync/core';
|
|
4
|
+
import type { BucketPutOptions, BucketPutResult } from '@spooky-sync/core';
|
|
4
5
|
import type { SyncedDb } from '../index';
|
|
5
6
|
import { useDb } from './context';
|
|
6
7
|
|
|
@@ -8,7 +9,7 @@ export interface FileUploadResult {
|
|
|
8
9
|
isUploading: () => boolean;
|
|
9
10
|
error: () => Error | null;
|
|
10
11
|
clearError: () => void;
|
|
11
|
-
upload: (path: string, file: File | Blob) => Promise<void>;
|
|
12
|
+
upload: (path: string, file: File | Blob, options?: BucketPutOptions) => Promise<BucketPutResult | void>;
|
|
12
13
|
download: (path: string) => Promise<string | null>;
|
|
13
14
|
remove: (path: string) => Promise<void>;
|
|
14
15
|
exists: (path: string) => Promise<boolean>;
|
|
@@ -33,6 +34,7 @@ export function useFileUpload<S extends SchemaStructure>(
|
|
|
33
34
|
bucketName = dbOrBucketName as BucketNames<S>;
|
|
34
35
|
} else {
|
|
35
36
|
db = dbOrBucketName as SyncedDb<S>;
|
|
37
|
+
// oxlint-disable-next-line no-non-null-assertion
|
|
36
38
|
bucketName = maybeBucketName!;
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -52,7 +54,7 @@ export function useFileUpload<S extends SchemaStructure>(
|
|
|
52
54
|
const config = db.getBucketConfig(bucketName as string);
|
|
53
55
|
if (!config) return;
|
|
54
56
|
|
|
55
|
-
if (config.maxSize
|
|
57
|
+
if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
|
|
56
58
|
const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
|
|
57
59
|
throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
|
|
58
60
|
}
|
|
@@ -70,7 +72,11 @@ export function useFileUpload<S extends SchemaStructure>(
|
|
|
70
72
|
}
|
|
71
73
|
};
|
|
72
74
|
|
|
73
|
-
const upload = async (
|
|
75
|
+
const upload = async (
|
|
76
|
+
path: string,
|
|
77
|
+
file: File | Blob,
|
|
78
|
+
options?: BucketPutOptions
|
|
79
|
+
): Promise<BucketPutResult | void> => {
|
|
74
80
|
setError(null);
|
|
75
81
|
try {
|
|
76
82
|
validate(file);
|
|
@@ -82,7 +88,7 @@ export function useFileUpload<S extends SchemaStructure>(
|
|
|
82
88
|
setIsUploading(true);
|
|
83
89
|
try {
|
|
84
90
|
const bytes = await fileToUint8Array(file);
|
|
85
|
-
await db.bucket(bucketName).put(path, bytes);
|
|
91
|
+
return await db.bucket(bucketName).put(path, bytes, options);
|
|
86
92
|
} catch (e) {
|
|
87
93
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
88
94
|
} finally {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import type { ImpersonationInfo } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface UseImpersonationOptions {
|
|
6
|
+
/**
|
|
7
|
+
* This component renders the app's own impersonation banner. Required with
|
|
8
|
+
* `impersonationBanner: { mode: 'custom' }`: it acknowledges the banner, so
|
|
9
|
+
* the client does not fall back to its built-in one.
|
|
10
|
+
*/
|
|
11
|
+
rendersBanner?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UseImpersonation {
|
|
15
|
+
/** The active impersonation, or `null`. */
|
|
16
|
+
impersonation: Accessor<ImpersonationInfo | null>;
|
|
17
|
+
isImpersonating: Accessor<boolean>;
|
|
18
|
+
/** Return to the admin session. */
|
|
19
|
+
stop: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Observe admin impersonation (started from the DevTools). The client already
|
|
24
|
+
* shows its own warning bar with a Stop button; use this for anything extra,
|
|
25
|
+
* such as hiding actions an admin should not take on someone's behalf.
|
|
26
|
+
* Must be used within a `<Sp00kyProvider>`.
|
|
27
|
+
*/
|
|
28
|
+
export function useImpersonation(options: UseImpersonationOptions = {}): UseImpersonation {
|
|
29
|
+
const db = useDb();
|
|
30
|
+
const [impersonation, setImpersonation] = createSignal<ImpersonationInfo | null>(
|
|
31
|
+
db.auth.impersonation
|
|
32
|
+
);
|
|
33
|
+
onCleanup(db.auth.subscribeImpersonation(setImpersonation));
|
|
34
|
+
// Re-acknowledged per session: a new impersonation restarts the client's
|
|
35
|
+
// fallback timer, and this component is what cancels it.
|
|
36
|
+
createEffect(() => {
|
|
37
|
+
if (options.rendersBanner && impersonation()) db.acknowledgeImpersonationBanner();
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
impersonation,
|
|
41
|
+
isImpersonating: () => impersonation() !== null,
|
|
42
|
+
stop: () => db.auth.stopImpersonating(),
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/lib/use-query.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {
|
|
2
2
|
ColumnSchema,
|
|
3
3
|
FinalQuery,
|
|
4
4
|
SchemaStructure,
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
QueryResult,
|
|
7
7
|
} from '@spooky-sync/query-builder';
|
|
8
8
|
import { createEffect, createSignal, onCleanup, useContext } from 'solid-js';
|
|
9
|
+
import { createStore, reconcile } from 'solid-js/store';
|
|
9
10
|
import { SyncedDb } from '..';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
|
|
12
|
+
import { Sp00kyContext } from './context';
|
|
12
13
|
|
|
13
14
|
type QueryArg<
|
|
14
15
|
S extends SchemaStructure,
|
|
@@ -17,13 +18,23 @@ type QueryArg<
|
|
|
17
18
|
RelatedFields extends Record<string, any>,
|
|
18
19
|
IsOne extends boolean,
|
|
19
20
|
> =
|
|
20
|
-
| FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
21
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
21
22
|
| (() =>
|
|
22
|
-
| FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
23
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
23
24
|
| null
|
|
24
25
|
| undefined);
|
|
25
26
|
|
|
26
|
-
type QueryOptions = {
|
|
27
|
+
type QueryOptions = {
|
|
28
|
+
enabled?: () => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Tear down the query (remote `_00_query` view + local WASM view) when this
|
|
31
|
+
* hook is disposed and no other subscriber remains, instead of keeping it
|
|
32
|
+
* resident for cheap re-subscription. Use for viewport-windowed lists that
|
|
33
|
+
* mount/unmount a query per scroll window and want off-screen windows
|
|
34
|
+
* cancelled. Trade-off: scrolling back to a torn-down window re-registers it.
|
|
35
|
+
*/
|
|
36
|
+
deregisterOnCleanup?: boolean;
|
|
37
|
+
};
|
|
27
38
|
|
|
28
39
|
// Overload: context-based (no explicit db)
|
|
29
40
|
export function useQuery<
|
|
@@ -36,7 +47,13 @@ export function useQuery<
|
|
|
36
47
|
>(
|
|
37
48
|
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
38
49
|
options?: QueryOptions,
|
|
39
|
-
): {
|
|
50
|
+
): {
|
|
51
|
+
data: () => TData | undefined;
|
|
52
|
+
error: () => Error | undefined;
|
|
53
|
+
isLoading: () => boolean;
|
|
54
|
+
isFetching: () => boolean;
|
|
55
|
+
isSettled: () => boolean;
|
|
56
|
+
};
|
|
40
57
|
|
|
41
58
|
// Overload: explicit db (backward-compatible)
|
|
42
59
|
export function useQuery<
|
|
@@ -50,7 +67,13 @@ export function useQuery<
|
|
|
50
67
|
db: SyncedDb<S>,
|
|
51
68
|
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
52
69
|
options?: QueryOptions,
|
|
53
|
-
): {
|
|
70
|
+
): {
|
|
71
|
+
data: () => TData | undefined;
|
|
72
|
+
error: () => Error | undefined;
|
|
73
|
+
isLoading: () => boolean;
|
|
74
|
+
isFetching: () => boolean;
|
|
75
|
+
isSettled: () => boolean;
|
|
76
|
+
};
|
|
54
77
|
|
|
55
78
|
// Implementation
|
|
56
79
|
export function useQuery<
|
|
@@ -82,11 +105,11 @@ export function useQuery<
|
|
|
82
105
|
options = maybeOptions;
|
|
83
106
|
} else {
|
|
84
107
|
// Context-based overload: useQuery(query, options?)
|
|
85
|
-
const contextDb = useContext(
|
|
108
|
+
const contextDb = useContext(Sp00kyContext);
|
|
86
109
|
if (!contextDb) {
|
|
87
110
|
throw new Error(
|
|
88
|
-
'useQuery: No db argument provided and no
|
|
89
|
-
'Either pass a SyncedDb instance or wrap your app in <
|
|
111
|
+
'useQuery: No db argument provided and no Sp00kyContext found. ' +
|
|
112
|
+
'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.'
|
|
90
113
|
);
|
|
91
114
|
}
|
|
92
115
|
db = contextDb as SyncedDb<S>;
|
|
@@ -94,29 +117,98 @@ export function useQuery<
|
|
|
94
117
|
options = queryOrOptions as QueryOptions | undefined;
|
|
95
118
|
}
|
|
96
119
|
|
|
97
|
-
const [data, setData] = createSignal<TData | undefined>(undefined);
|
|
98
120
|
const [error, setError] = createSignal<Error | undefined>(undefined);
|
|
99
121
|
const [isFetched, setIsFetched] = createSignal(false);
|
|
100
|
-
const [
|
|
122
|
+
const [isFetching, setIsFetching] = createSignal(false);
|
|
123
|
+
// Results live in a store (not a signal) so consecutive live-query emissions
|
|
124
|
+
// are merged with `reconcile`: unchanged rows keep their object identity and
|
|
125
|
+
// changed rows are mutated in place. That keeps Solid's reference-keyed `<For>`
|
|
126
|
+
// rows — and any `useQuery` subscriptions mounted inside them — alive across
|
|
127
|
+
// updates, instead of tearing every row down and re-registering its queries.
|
|
128
|
+
const [state, setState] = createStore<{ value: TData | undefined }>({ value: undefined });
|
|
129
|
+
// `reconcile` (below) merges each emission into `state.value` IN PLACE, keeping
|
|
130
|
+
// the array reference stable. That's ideal for granular per-row reactivity, but
|
|
131
|
+
// it means a *coarse* reader of `data()` — `<For each={data()}>`, or an effect
|
|
132
|
+
// that copies the whole array elsewhere (e.g. GameList's windowed store) — is
|
|
133
|
+
// NOT re-run when rows are added/removed/reordered within a same-length result
|
|
134
|
+
// (the classic case: deleting a row in a windowed list shifts the next one in,
|
|
135
|
+
// so length stays 50 and the array ref never changes). Bump a version on every
|
|
136
|
+
// emission and read it in `data()` so every consumer re-runs on any change while
|
|
137
|
+
// reconcile still preserves row identity underneath.
|
|
138
|
+
const [version, setVersion] = createSignal(0);
|
|
139
|
+
const data = () => {
|
|
140
|
+
version();
|
|
141
|
+
return state.value;
|
|
142
|
+
};
|
|
143
|
+
|
|
101
144
|
let prevQueryString: string | undefined;
|
|
145
|
+
// Monotonic token for each subscription generation. Bumped whenever the query
|
|
146
|
+
// identity changes or the hook is disposed, so a slow async `initQuery`
|
|
147
|
+
// continuation can detect it was superseded and avoid installing a stale (and
|
|
148
|
+
// leaked) subscription.
|
|
149
|
+
let runId = 0;
|
|
150
|
+
let activeUnsub: (() => void) | undefined;
|
|
151
|
+
// The hash of the currently-installed subscription, for opt-in deregister on
|
|
152
|
+
// dispose (see `deregisterOnCleanup`).
|
|
153
|
+
let activeHash: string | undefined;
|
|
102
154
|
|
|
103
|
-
const
|
|
155
|
+
const teardownActive = () => {
|
|
156
|
+
activeUnsub?.();
|
|
157
|
+
activeUnsub = undefined;
|
|
158
|
+
};
|
|
104
159
|
|
|
160
|
+
const sp00ky = db.getSp00ky();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Registration can fail — the canonical case is the SSP answering 503
|
|
164
|
+
* NOT_READY while it bootstraps. Nothing here used to catch that: the
|
|
165
|
+
* rejection escaped as an unhandled promise, `isFetched` stayed false, and
|
|
166
|
+
* `isLoading()` therefore stayed true FOREVER, which is what a spinner that
|
|
167
|
+
* never resolves actually was. Surface it as `error()` instead; the sync
|
|
168
|
+
* scheduler retries the registration underneath, so a transient failure
|
|
169
|
+
* still recovers on its own.
|
|
170
|
+
*/
|
|
105
171
|
const initQuery = async (
|
|
106
|
-
query: FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
172
|
+
query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
|
|
173
|
+
myRun: number
|
|
174
|
+
) => {
|
|
175
|
+
try {
|
|
176
|
+
await subscribeQuery(query, myRun);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// A superseded run's failure is not this subscription's problem.
|
|
179
|
+
if (myRun !== runId) return;
|
|
180
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const subscribeQuery = async (
|
|
185
|
+
query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
|
|
186
|
+
myRun: number
|
|
107
187
|
) => {
|
|
108
188
|
const { hash } = await query.run();
|
|
189
|
+
// A newer query identity (or disposal) won the race while we awaited run().
|
|
190
|
+
if (myRun !== runId) return;
|
|
191
|
+
activeHash = hash;
|
|
109
192
|
setError(undefined);
|
|
110
193
|
|
|
111
194
|
let isFirstCall = true;
|
|
112
|
-
const unsub = await
|
|
195
|
+
const unsub = await sp00ky.subscribe(
|
|
113
196
|
hash,
|
|
114
197
|
(e) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
198
|
+
const queryData = (query.isOne ? e[0] : e) as TData;
|
|
199
|
+
// Merge into the store by record id: unchanged rows keep their identity,
|
|
200
|
+
// changed rows update in place. Replaces wholesale for `one()`/null.
|
|
201
|
+
// Time the reconcile → report as the "frontend" phase for DevTools/MCP.
|
|
202
|
+
const reconcileStart = performance.now();
|
|
203
|
+
setState('value', reconcile(queryData as any, { key: 'id' }));
|
|
204
|
+
// Notify coarse `data()` readers (see the `version` note above): reconcile
|
|
205
|
+
// keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
|
|
206
|
+
// on add/remove/reorder.
|
|
207
|
+
setVersion((v) => v + 1);
|
|
208
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
|
|
117
209
|
// The first (immediate) callback with no data likely means the local DB
|
|
118
210
|
// hasn't synced yet — don't mark as fetched so UI shows loading state
|
|
119
|
-
const hasData = query.isOne ?
|
|
211
|
+
const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
|
|
120
212
|
if (!isFirstCall || hasData) {
|
|
121
213
|
setIsFetched(true);
|
|
122
214
|
}
|
|
@@ -125,7 +217,25 @@ export function useQuery<
|
|
|
125
217
|
{ immediate: true }
|
|
126
218
|
);
|
|
127
219
|
|
|
128
|
-
|
|
220
|
+
// Mirror the query's fetch status so the UI can show a "loading more"
|
|
221
|
+
// state while the sync engine pulls missing records in the background.
|
|
222
|
+
const unsubStatus = sp00ky.subscribeQueryStatus(
|
|
223
|
+
hash,
|
|
224
|
+
(status) => setIsFetching(status === 'fetching'),
|
|
225
|
+
{ immediate: true }
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const teardown = () => {
|
|
229
|
+
unsub();
|
|
230
|
+
unsubStatus();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Superseded while awaiting subscribe()? Don't leak — tear down immediately.
|
|
234
|
+
if (myRun !== runId) {
|
|
235
|
+
teardown();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
activeUnsub = teardown;
|
|
129
239
|
};
|
|
130
240
|
|
|
131
241
|
createEffect(() => {
|
|
@@ -143,30 +253,60 @@ export function useQuery<
|
|
|
143
253
|
return;
|
|
144
254
|
}
|
|
145
255
|
|
|
146
|
-
//
|
|
147
|
-
|
|
256
|
+
// Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
|
|
257
|
+
// full `JSON.stringify` of the FinalQuery (which walks the whole schema +
|
|
258
|
+
// inner query on every reactive tick and isn't guaranteed stable). When the
|
|
259
|
+
// identity is unchanged we keep the existing subscription alive.
|
|
260
|
+
const queryString = String(query.hash);
|
|
148
261
|
if (queryString === prevQueryString) {
|
|
149
262
|
return;
|
|
150
263
|
}
|
|
151
264
|
prevQueryString = queryString;
|
|
152
265
|
|
|
153
|
-
//
|
|
266
|
+
// New query identity → supersede the previous subscription and start fresh.
|
|
267
|
+
const myRun = ++runId;
|
|
268
|
+
teardownActive();
|
|
154
269
|
setIsFetched(false);
|
|
155
|
-
|
|
270
|
+
// A new identity starts clean: a previous identity's failure must not keep
|
|
271
|
+
// this one out of its loading state.
|
|
272
|
+
setError(undefined);
|
|
273
|
+
void initQuery(query, myRun);
|
|
274
|
+
});
|
|
156
275
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
276
|
+
// Tear down the live subscription when the hook's owner is disposed. Registered
|
|
277
|
+
// on the hook (component) scope rather than inside the effect, so an effect
|
|
278
|
+
// re-run that early-returns (unchanged query) doesn't clean up the still-valid
|
|
279
|
+
// subscription. Bumping runId also invalidates any in-flight initQuery.
|
|
280
|
+
onCleanup(() => {
|
|
281
|
+
runId++;
|
|
282
|
+
teardownActive();
|
|
283
|
+
// Opt-in: cancel the query once this hook (its last subscriber) is gone.
|
|
284
|
+
// teardownActive() above already removed this hook's callback, so
|
|
285
|
+
// deregisterQuery's refcount guard sees the true remaining-subscriber count.
|
|
286
|
+
if (options?.deregisterOnCleanup && activeHash) {
|
|
287
|
+
sp00ky.deregisterQuery(activeHash);
|
|
288
|
+
}
|
|
161
289
|
});
|
|
162
290
|
|
|
163
291
|
const isLoading = () => {
|
|
164
292
|
return !isFetched() && error() === undefined;
|
|
165
293
|
};
|
|
166
294
|
|
|
295
|
+
// True once the query has delivered a result AND no fetch cycle is in flight
|
|
296
|
+
// (registration + initial sync included — the core holds `fetching` across
|
|
297
|
+
// the whole registration and flushes debounced results before flipping back
|
|
298
|
+
// to idle). While settled, the results are authoritative: a windowed query
|
|
299
|
+
// returning fewer rows than its LIMIT really is the end of the list, so
|
|
300
|
+
// virtualized lists may size themselves to it without the scrollbar jumping
|
|
301
|
+
// when a still-syncing window transiently reports short. Resets to false
|
|
302
|
+
// whenever the query identity changes.
|
|
303
|
+
const isSettled = () => isFetched() && !isFetching();
|
|
304
|
+
|
|
167
305
|
return {
|
|
168
306
|
data,
|
|
169
307
|
error,
|
|
170
308
|
isLoading,
|
|
309
|
+
isFetching,
|
|
310
|
+
isSettled,
|
|
171
311
|
};
|
|
172
312
|
}
|