@spooky-sync/client-solid 0.0.1-canary.32 → 0.0.1-canary.321
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 +650 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +386 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +386 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +636 -114
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/skills/sp00ky-solid/SKILL.md +72 -1
- package/skills/sp00ky-solid/references/file-hooks.md +33 -4
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +183 -19
- package/src/lib/Blurhash.ts +54 -0
- package/src/lib/BucketImage.ts +141 -0
- package/src/lib/Sp00kyProvider.ts +50 -1
- 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 +160 -20
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +1 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import { semverGt, type AppReleaseOptions, type AppReleaseSnapshot } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface UseAppReleaseOptions extends AppReleaseOptions {
|
|
6
|
+
/** App name from sp00ky.yml, e.g. `web`. */
|
|
7
|
+
app: string;
|
|
8
|
+
/**
|
|
9
|
+
* The running build's version (X.Y.Z), typically baked in at build time
|
|
10
|
+
* (e.g. a vite `define` from package.json). `updateAvailable()` is true when
|
|
11
|
+
* the announced release is semver-newer than this.
|
|
12
|
+
*/
|
|
13
|
+
currentVersion: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseAppRelease {
|
|
17
|
+
/** Latest announced version for the app, or undefined when no row exists. */
|
|
18
|
+
latestVersion: Accessor<string | undefined>;
|
|
19
|
+
/** Announced version is semver-newer than the running build. */
|
|
20
|
+
updateAvailable: Accessor<boolean>;
|
|
21
|
+
/** The newer release asks clients to update/reload without prompting. */
|
|
22
|
+
mandatory: Accessor<boolean>;
|
|
23
|
+
/** The newer release asks reloads to clear service-worker caches first. */
|
|
24
|
+
cacheBust: Accessor<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Reload onto the announced release. Plain `location.reload()` normally;
|
|
27
|
+
* when the release is flagged cache-bust, CacheStorage is cleared, the
|
|
28
|
+
* service-worker registration is nudged to update, and navigation carries a
|
|
29
|
+
* `?cb=` token to punch through intermediary caches. The service worker is
|
|
30
|
+
* deliberately NOT unregistered: navigating while still controlled by a
|
|
31
|
+
* just-unregistered worker strands subresource fetches on the dead worker
|
|
32
|
+
* and the page hangs until a manual reload.
|
|
33
|
+
*/
|
|
34
|
+
reload: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function reloadForSnapshot(snapshot: AppReleaseSnapshot): Promise<void> {
|
|
38
|
+
if (typeof window === 'undefined') return;
|
|
39
|
+
if (snapshot.cacheBust) {
|
|
40
|
+
try {
|
|
41
|
+
if (window.caches) {
|
|
42
|
+
const keys = await window.caches.keys();
|
|
43
|
+
await Promise.all(keys.map((k) => window.caches.delete(k)));
|
|
44
|
+
}
|
|
45
|
+
if (navigator.serviceWorker) {
|
|
46
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
47
|
+
for (const r of regs) r.update().catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
window.location.href = window.location.pathname + '?cb=' + Date.now();
|
|
50
|
+
return;
|
|
51
|
+
} catch {
|
|
52
|
+
/* fall through to a plain reload */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
window.location.reload();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Observe the app's announced release (`_00_app_release:<app>`, written by
|
|
60
|
+
* `spky deploy` / `spky release`) and compare it against the running build.
|
|
61
|
+
*
|
|
62
|
+
* Typical use: mount a small "new version available — Reload" notification
|
|
63
|
+
* gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
|
|
64
|
+
* (guard the auto path against reload loops with a per-version marker, since
|
|
65
|
+
* a client can reload while the deploy is still rolling out and land on the
|
|
66
|
+
* old bundle again).
|
|
67
|
+
*/
|
|
68
|
+
export function useAppRelease(options: UseAppReleaseOptions): UseAppRelease {
|
|
69
|
+
const db = useDb();
|
|
70
|
+
const handle = db.getSp00ky().appRelease(options.app, { ttl: options.ttl });
|
|
71
|
+
|
|
72
|
+
const [snapshot, setSnapshot] = createSignal<AppReleaseSnapshot>(handle.snapshot());
|
|
73
|
+
const unsub = handle.subscribe(setSnapshot);
|
|
74
|
+
|
|
75
|
+
onCleanup(() => {
|
|
76
|
+
unsub();
|
|
77
|
+
handle.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const updateAvailable = () => semverGt(snapshot().version, options.currentVersion);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
latestVersion: () => snapshot().version,
|
|
84
|
+
updateAvailable,
|
|
85
|
+
mandatory: () => updateAvailable() && snapshot().mandatory,
|
|
86
|
+
cacheBust: () => snapshot().cacheBust,
|
|
87
|
+
reload: () => reloadForSnapshot(snapshot()),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { SyncedDb } from '../index';
|
|
4
|
+
import { useDb } from './context';
|
|
5
|
+
|
|
6
|
+
export interface UseBlurhashResult {
|
|
7
|
+
/** The stored blurhash for the path, or null while loading / when none exists. */
|
|
8
|
+
hash: Accessor<string | null>;
|
|
9
|
+
isLoading: Accessor<boolean>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The blurhash sidecar for a bucket image (written automatically by
|
|
14
|
+
* `bucket.put`, see `Sp00kyConfig.blurhash`). Resolves from OPFS instantly on
|
|
15
|
+
* warm clients; a miss is remembered per tab. Use this directly when the hash
|
|
16
|
+
* belongs to a different rendition than the displayed image; otherwise
|
|
17
|
+
* `useBucketImage` / `BucketImage` bundle it with the download.
|
|
18
|
+
*/
|
|
19
|
+
export function useBlurhash<S extends SchemaStructure>(
|
|
20
|
+
bucketName: BucketNames<S>,
|
|
21
|
+
path: Accessor<string | null | undefined>
|
|
22
|
+
): UseBlurhashResult;
|
|
23
|
+
export function useBlurhash<S extends SchemaStructure>(
|
|
24
|
+
db: SyncedDb<S>,
|
|
25
|
+
bucketName: BucketNames<S>,
|
|
26
|
+
path: Accessor<string | null | undefined>
|
|
27
|
+
): UseBlurhashResult;
|
|
28
|
+
export function useBlurhash<S extends SchemaStructure>(
|
|
29
|
+
dbOrBucketName: SyncedDb<S> | BucketNames<S>,
|
|
30
|
+
bucketNameOrPath?: BucketNames<S> | Accessor<string | null | undefined>,
|
|
31
|
+
maybePath?: Accessor<string | null | undefined>
|
|
32
|
+
): UseBlurhashResult {
|
|
33
|
+
let db: SyncedDb<S>;
|
|
34
|
+
let bucketName: BucketNames<S>;
|
|
35
|
+
let path: Accessor<string | null | undefined>;
|
|
36
|
+
|
|
37
|
+
if (typeof dbOrBucketName === 'string') {
|
|
38
|
+
db = useDb<S>();
|
|
39
|
+
bucketName = dbOrBucketName as BucketNames<S>;
|
|
40
|
+
path = bucketNameOrPath as Accessor<string | null | undefined>;
|
|
41
|
+
} else {
|
|
42
|
+
db = dbOrBucketName as SyncedDb<S>;
|
|
43
|
+
bucketName = bucketNameOrPath as BucketNames<S>;
|
|
44
|
+
path = maybePath as Accessor<string | null | undefined>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [hash, setHash] = createSignal<string | null>(null);
|
|
48
|
+
const [isLoading, setIsLoading] = createSignal(false);
|
|
49
|
+
|
|
50
|
+
createEffect(() => {
|
|
51
|
+
const filePath = path();
|
|
52
|
+
if (!filePath) {
|
|
53
|
+
setHash(null);
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
setIsLoading(true);
|
|
59
|
+
db.bucket(bucketName)
|
|
60
|
+
.blurhash(filePath)
|
|
61
|
+
.then((result) => {
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
setHash(result);
|
|
64
|
+
setIsLoading(false);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (cancelled) return;
|
|
68
|
+
setHash(null);
|
|
69
|
+
setIsLoading(false);
|
|
70
|
+
});
|
|
71
|
+
onCleanup(() => {
|
|
72
|
+
cancelled = true;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { hash, isLoading };
|
|
77
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createSignal, createEffect, on, type Accessor } from 'solid-js';
|
|
2
|
+
import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { SyncedDb } from '../index';
|
|
4
|
+
import { useDb } from './context';
|
|
5
|
+
import {
|
|
6
|
+
useDownloadFile,
|
|
7
|
+
type UseDownloadFileOptions,
|
|
8
|
+
type UseDownloadFileResult,
|
|
9
|
+
} from './use-download-file';
|
|
10
|
+
import { useBlurhash } from './use-blurhash';
|
|
11
|
+
|
|
12
|
+
export interface UseBucketImageOptions extends UseDownloadFileOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Also resolve the image's blurhash sidecar (see `Sp00kyConfig.blurhash`).
|
|
15
|
+
* Default `true`; the read is registered before the image bytes so the tiny
|
|
16
|
+
* sidecar tends to land first on the serialized remote chain.
|
|
17
|
+
*/
|
|
18
|
+
blurhash?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseBucketImageResult extends UseDownloadFileResult {
|
|
22
|
+
/** Blurhash for the same path, or null (off, missing, still loading). */
|
|
23
|
+
blurhash: Accessor<string | null>;
|
|
24
|
+
/** True once the current `url()` has been decoded and is safe to paint. */
|
|
25
|
+
ready: Accessor<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Ref callback for the `<img>` rendering `url()`: flips `ready` when the
|
|
28
|
+
* bitmap is decoded (resolves on failure too, so a broken blob degrades to
|
|
29
|
+
* paint-on-load instead of hiding the image forever). Re-arms itself when
|
|
30
|
+
* the url changes.
|
|
31
|
+
*/
|
|
32
|
+
gate: (img: HTMLImageElement) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Everything needed to render a bucket image without a pop-in: the refcounted
|
|
37
|
+
* object URL, the blurhash placeholder, and a decode gate so the real bitmap
|
|
38
|
+
* is only revealed once it can paint in full. `BucketImage` wraps this into a
|
|
39
|
+
* drop-in component; use the hook for custom markup.
|
|
40
|
+
*/
|
|
41
|
+
export function useBucketImage<S extends SchemaStructure>(
|
|
42
|
+
bucketName: BucketNames<S>,
|
|
43
|
+
path: Accessor<string | null | undefined>,
|
|
44
|
+
options?: UseBucketImageOptions
|
|
45
|
+
): UseBucketImageResult;
|
|
46
|
+
export function useBucketImage<S extends SchemaStructure>(
|
|
47
|
+
db: SyncedDb<S>,
|
|
48
|
+
bucketName: BucketNames<S>,
|
|
49
|
+
path: Accessor<string | null | undefined>,
|
|
50
|
+
options?: UseBucketImageOptions
|
|
51
|
+
): UseBucketImageResult;
|
|
52
|
+
export function useBucketImage<S extends SchemaStructure>(
|
|
53
|
+
dbOrBucketName: SyncedDb<S> | BucketNames<S>,
|
|
54
|
+
bucketNameOrPath?: BucketNames<S> | Accessor<string | null | undefined>,
|
|
55
|
+
pathOrOptions?: Accessor<string | null | undefined> | UseBucketImageOptions,
|
|
56
|
+
maybeOptions?: UseBucketImageOptions
|
|
57
|
+
): UseBucketImageResult {
|
|
58
|
+
let db: SyncedDb<S>;
|
|
59
|
+
let bucketName: BucketNames<S>;
|
|
60
|
+
let path: Accessor<string | null | undefined>;
|
|
61
|
+
let options: UseBucketImageOptions;
|
|
62
|
+
|
|
63
|
+
if (typeof dbOrBucketName === 'string') {
|
|
64
|
+
db = useDb<S>();
|
|
65
|
+
bucketName = dbOrBucketName as BucketNames<S>;
|
|
66
|
+
path = bucketNameOrPath as Accessor<string | null | undefined>;
|
|
67
|
+
options = (pathOrOptions as UseBucketImageOptions) ?? {};
|
|
68
|
+
} else {
|
|
69
|
+
db = dbOrBucketName as SyncedDb<S>;
|
|
70
|
+
bucketName = bucketNameOrPath as BucketNames<S>;
|
|
71
|
+
path = pathOrOptions as Accessor<string | null | undefined>;
|
|
72
|
+
options = maybeOptions ?? {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Registered BEFORE the download so the sidecar read enters the serialized
|
|
76
|
+
// remote queue first: the placeholder should never wait behind the bytes it
|
|
77
|
+
// is standing in for.
|
|
78
|
+
const wantHash = options.blurhash !== false;
|
|
79
|
+
const { hash } = useBlurhash(db, bucketName, () => (wantHash ? path() : null));
|
|
80
|
+
|
|
81
|
+
const file = useDownloadFile(db, bucketName, path, options);
|
|
82
|
+
|
|
83
|
+
const [ready, setReady] = createSignal(false);
|
|
84
|
+
// A new url (path change, refetch) means a new undecoded bitmap.
|
|
85
|
+
createEffect(on(file.url, () => setReady(false), { defer: true }));
|
|
86
|
+
|
|
87
|
+
const gate = (img: HTMLImageElement) => {
|
|
88
|
+
const done = () => setReady(true);
|
|
89
|
+
if (typeof img.decode === 'function') {
|
|
90
|
+
img.decode().then(done, done);
|
|
91
|
+
} else if (img.complete) {
|
|
92
|
+
done();
|
|
93
|
+
} else {
|
|
94
|
+
img.onload = done;
|
|
95
|
+
img.onerror = done;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return { ...file, blurhash: hash, ready, gate };
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, useContext, type Accessor } from 'solid-js';
|
|
2
|
+
import { Sp00kyContext } from './context';
|
|
3
|
+
import type { CrdtField } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export function useCrdtField(
|
|
6
|
+
table: string,
|
|
7
|
+
recordId: () => string | undefined,
|
|
8
|
+
field: string,
|
|
9
|
+
fallbackText?: () => string | undefined,
|
|
10
|
+
): Accessor<CrdtField | null> {
|
|
11
|
+
const db = useContext(Sp00kyContext);
|
|
12
|
+
if (!db) {
|
|
13
|
+
throw new Error('useCrdtField must be used within a <Sp00kyProvider>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [crdtField, setCrdtField] = createSignal<CrdtField | null>(null);
|
|
17
|
+
let currentId: string | undefined;
|
|
18
|
+
let initialized = false;
|
|
19
|
+
|
|
20
|
+
createEffect(() => {
|
|
21
|
+
const id = recordId();
|
|
22
|
+
|
|
23
|
+
// Skip if the ID hasn't changed (but allow the first non-undefined value through)
|
|
24
|
+
if (initialized && id === currentId) return;
|
|
25
|
+
|
|
26
|
+
// Close previous field
|
|
27
|
+
if (currentId && crdtField()) {
|
|
28
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
29
|
+
setCrdtField(null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
currentId = id;
|
|
33
|
+
initialized = true;
|
|
34
|
+
|
|
35
|
+
if (!id) return;
|
|
36
|
+
|
|
37
|
+
const sp00ky = db.getSp00ky();
|
|
38
|
+
const text = fallbackText?.();
|
|
39
|
+
sp00ky
|
|
40
|
+
.openCrdtField(table, id, field, text)
|
|
41
|
+
.then((cf) => {
|
|
42
|
+
if (currentId === id) {
|
|
43
|
+
setCrdtField(cf);
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
// Silent rejections here leave the consumer's `Show when={field()}`
|
|
48
|
+
// permanently stuck on its fallback (typically a static `<p>` with
|
|
49
|
+
// no editing UI), with no error trail. Surface the failure so the
|
|
50
|
+
// root cause (missing `@crdt` annotation, schema codegen drift,
|
|
51
|
+
// local DB query failure, etc.) is visible in the console instead
|
|
52
|
+
// of silently breaking collaborative fields.
|
|
53
|
+
console.error(
|
|
54
|
+
`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`,
|
|
55
|
+
err,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
onCleanup(() => {
|
|
61
|
+
if (currentId && crdtField()) {
|
|
62
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
63
|
+
setCrdtField(null);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return crdtField;
|
|
68
|
+
}
|
|
@@ -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 {
|