@spooky-sync/client-solid 0.0.0-canary.1 → 0.0.1-alpha.2

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.
@@ -0,0 +1,141 @@
1
+ import { createSignal, createEffect, onMount, onCleanup, children } from 'solid-js';
2
+ import type { JSX } from 'solid-js';
3
+ import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
4
+ import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
5
+ import { Blurhash } from './Blurhash';
6
+
7
+ export interface BucketImageProps {
8
+ /** Bucket name from the schema. */
9
+ bucket: string;
10
+ /** Path within the bucket. Nullish renders only the fallback layers. */
11
+ path: string | null | undefined;
12
+ alt?: string;
13
+ /** Classes for the container element (sizing/positioning). */
14
+ class?: string;
15
+ /** Classes for the inner `<img>` (the layout styles are inline). */
16
+ imgClass?: string;
17
+ /** `object-fit` for the image. Defaults to `cover`. */
18
+ fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
19
+ /**
20
+ * Bottom placeholder layer (your own plate/skeleton), shown until the image
21
+ * settles. The blurhash layer paints on top of it once the sidecar resolves.
22
+ */
23
+ fallback?: JSX.Element;
24
+ /** Crossfade duration in ms. Defaults to 300. */
25
+ transition?: number;
26
+ /** Crossfade easing. Defaults to an ease-out-expo curve. */
27
+ easing?: string;
28
+ /** Resolve the blurhash sidecar. Defaults to true. */
29
+ blurhash?: boolean;
30
+ /** Download tuning, forwarded to the underlying `useDownloadFile`. */
31
+ options?: UseBucketImageOptions;
32
+ }
33
+
34
+ const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
35
+
36
+ /**
37
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
38
+ * plate, the automatically stored blurhash, and the real image, which stays
39
+ * transparent until the bitmap is DECODED and then crossfades over the
40
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
41
+ * prefers-reduced-motion (instant swap). The container is made
42
+ * `position: relative` unless your `class` positions it already.
43
+ *
44
+ * ```tsx
45
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
46
+ * fallback={<MyPlate />} alt="" />
47
+ * ```
48
+ */
49
+ export function BucketImage(props: BucketImageProps): JSX.Element {
50
+ if (typeof document === 'undefined') return null;
51
+
52
+ const image = useBucketImage(
53
+ props.bucket as BucketNames<SchemaStructure>,
54
+ () => props.path,
55
+ { ...props.options, blurhash: props.blurhash !== false }
56
+ );
57
+
58
+ const reducedMotion =
59
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
60
+
61
+ const root = document.createElement('div');
62
+ createEffect(() => {
63
+ root.className = props.class ?? '';
64
+ });
65
+ // Layers are absolutely positioned; give them an anchor without stomping on
66
+ // a caller class that already positions the container (inline would win).
67
+ onMount(() => {
68
+ if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
69
+ });
70
+
71
+ const placeholder = document.createElement('div');
72
+ placeholder.style.cssText = LAYER_STYLE;
73
+ // Solid hands JSX props over as lazy thunks; `children` resolves them (and
74
+ // nested arrays/functions) to real nodes and keeps them alive reactively.
75
+ const fallbackHolder = document.createElement('div');
76
+ fallbackHolder.style.cssText = LAYER_STYLE;
77
+ placeholder.append(fallbackHolder);
78
+ const resolvedFallback = children(() => props.fallback);
79
+ createEffect(() => {
80
+ const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[];
81
+ fallbackHolder.replaceChildren(...nodes);
82
+ });
83
+ const hashCanvas = Blurhash({
84
+ get hash() {
85
+ return image.blurhash();
86
+ },
87
+ style: LAYER_STYLE,
88
+ });
89
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
90
+
91
+ const img = document.createElement('img');
92
+ img.decoding = 'async';
93
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
94
+ createEffect(() => {
95
+ img.className = props.imgClass ?? '';
96
+ });
97
+ createEffect(() => {
98
+ img.style.objectFit = props.fit ?? 'cover';
99
+ });
100
+ createEffect(() => {
101
+ img.alt = props.alt ?? '';
102
+ });
103
+ createEffect(() => {
104
+ img.style.transition = reducedMotion
105
+ ? 'none'
106
+ : `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`;
107
+ });
108
+ createEffect(() => {
109
+ const url = image.url();
110
+ if (!url) {
111
+ img.removeAttribute('src');
112
+ return;
113
+ }
114
+ img.src = url;
115
+ image.gate(img);
116
+ });
117
+ createEffect(() => {
118
+ img.style.opacity = image.ready() ? '1' : '0';
119
+ });
120
+
121
+ // Placeholders leave the DOM once the fade is over (a shelf of covers should
122
+ // not composite three layers each forever) and come back when the path
123
+ // changes mid-life (`ready` re-arms via the hook).
124
+ const [settled, setSettled] = createSignal(false);
125
+ createEffect(() => {
126
+ if (!image.ready()) {
127
+ setSettled(false);
128
+ return;
129
+ }
130
+ const wait = (reducedMotion ? 0 : (props.transition ?? 300)) + 120;
131
+ const timer = setTimeout(() => setSettled(true), wait);
132
+ onCleanup(() => clearTimeout(timer));
133
+ });
134
+ createEffect(() => {
135
+ if (settled()) placeholder.remove();
136
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
137
+ });
138
+
139
+ root.append(placeholder, img);
140
+ return root;
141
+ }
@@ -0,0 +1,104 @@
1
+ import type { JSX } from 'solid-js';
2
+ import {
3
+ createSignal,
4
+ onMount,
5
+ onCleanup,
6
+ createComponent,
7
+ createMemo,
8
+ mergeProps,
9
+ } from 'solid-js';
10
+ import type { SchemaStructure } from '@spooky/query-builder';
11
+ import type { SyncedDbConfig } from '../types';
12
+ import { SyncedDb } from '../index';
13
+ import { Sp00kyContext } from './context';
14
+
15
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
16
+ config: SyncedDbConfig<S>;
17
+ fallback?: JSX.Element;
18
+ onError?: (error: Error) => void;
19
+ onReady?: (db: SyncedDb<S>) => void;
20
+ /**
21
+ * Prewarm data into the local cache before revealing the UI. Runs after
22
+ * `init()`; the `fallback` stays visible until it resolves. Use awaitable
23
+ * `db.preload(...)` calls here to gate first-load on essential data (e.g.
24
+ * config). On warm loads preload returns instantly, so there's no perceptible
25
+ * gate after the first run. Best-effort: a rejection is caught and the UI is
26
+ * revealed anyway.
27
+ */
28
+ preload?: (db: SyncedDb<S>) => Promise<void>;
29
+ children: JSX.Element;
30
+ }
31
+
32
+ export function Sp00kyProvider<S extends SchemaStructure>(
33
+ props: Sp00kyProviderProps<S>
34
+ ): JSX.Element {
35
+ const merged = mergeProps(
36
+ {
37
+ fallback: undefined as JSX.Element | undefined,
38
+ },
39
+ props
40
+ );
41
+
42
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
43
+
44
+ // `onMount` is async, so a dispose can land mid-init. Only that narrow race is
45
+ // handled here: an instance whose init finished AFTER the provider was already
46
+ // gone is closed, because nothing will ever reference it.
47
+ //
48
+ // A live, mounted client is deliberately NOT closed on cleanup. Doing that
49
+ // nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
50
+ // "SyncedDb not initialized" while reads keep rendering from state that is
51
+ // already subscribed — i.e. mutations die silently and the app looks fine. In
52
+ // a host app the provider wraps the whole tree and only unmounts with the
53
+ // page, where the browser reclaims the worker anyway, so the leak this was
54
+ // meant to fix is worth far less than that risk.
55
+ let disposed = false;
56
+
57
+ onCleanup(() => {
58
+ disposed = true;
59
+ });
60
+
61
+ onMount(async () => {
62
+ try {
63
+ const instance = new SyncedDb<S>(merged.config);
64
+ await instance.init();
65
+ if (disposed) {
66
+ await instance.close();
67
+ return;
68
+ }
69
+ // Gate first-load UI on prewarmed data. Best-effort: never let a preload
70
+ // failure keep the app stuck on the fallback.
71
+ if (merged.preload) {
72
+ try {
73
+ await merged.preload(instance);
74
+ } catch (e) {
75
+ // oxlint-disable-next-line no-console
76
+ console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
77
+ }
78
+ }
79
+ setDb(() => instance);
80
+ merged.onReady?.(instance);
81
+ } catch (e) {
82
+ const error = e instanceof Error ? e : new Error(String(e));
83
+ if (merged.onError) {
84
+ merged.onError(error);
85
+ } else {
86
+ // oxlint-disable-next-line no-console
87
+ console.error('Sp00kyProvider: Failed to initialize database', error);
88
+ }
89
+ }
90
+ });
91
+
92
+ const content = createMemo(() => {
93
+ const instance = db();
94
+ if (!instance) return merged.fallback;
95
+ return createComponent(Sp00kyContext.Provider, {
96
+ value: instance,
97
+ get children() {
98
+ return merged.children;
99
+ },
100
+ });
101
+ });
102
+
103
+ return content as unknown as JSX.Element;
104
+ }
@@ -2,12 +2,12 @@ import { createContext, useContext } from 'solid-js';
2
2
  import type { SchemaStructure } from '@spooky/query-builder';
3
3
  import type { SyncedDb } from '../index';
4
4
 
5
- export const SpookyContext = createContext<SyncedDb<any> | undefined>();
5
+ export const Sp00kyContext = createContext<SyncedDb<any> | undefined>();
6
6
 
7
7
  export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
8
- const db = useContext(SpookyContext);
8
+ const db = useContext(Sp00kyContext);
9
9
  if (!db) {
10
- throw new Error('useDb must be used within a <SpookyProvider>. Wrap your app in <SpookyProvider config={...}>.');
10
+ throw new Error('useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.');
11
11
  }
12
12
  return db as SyncedDb<S>;
13
13
  }
@@ -0,0 +1,112 @@
1
+ import type {
2
+ ColumnSchema,
3
+ FinalQuery,
4
+ SchemaStructure,
5
+ TableNames,
6
+ } from '@spooky-sync/query-builder';
7
+ import { createEffect, useContext } from 'solid-js';
8
+ import { SyncedDb } from '..';
9
+ import type { Sp00kyQueryResultPromise, PreloadOptions as CorePreloadOptions } from '@spooky-sync/core';
10
+ import { Sp00kyContext } from './context';
11
+
12
+ type PreloadArg<
13
+ S extends SchemaStructure,
14
+ TableName extends TableNames<S>,
15
+ T extends { columns: Record<string, ColumnSchema> },
16
+ RelatedFields extends Record<string, any>,
17
+ IsOne extends boolean,
18
+ > =
19
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
20
+ | (() =>
21
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
22
+ | null
23
+ | undefined);
24
+
25
+ type PreloadOptions = CorePreloadOptions & {
26
+ /** Only preload while this returns true (defaults to always). */
27
+ enabled?: () => boolean;
28
+ };
29
+
30
+ // Overload: context-based (no explicit db)
31
+ export function createPreload<
32
+ S extends SchemaStructure,
33
+ TableName extends TableNames<S>,
34
+ T extends { columns: Record<string, ColumnSchema> },
35
+ RelatedFields extends Record<string, any>,
36
+ IsOne extends boolean,
37
+ >(
38
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
39
+ options?: PreloadOptions,
40
+ ): void;
41
+
42
+ // Overload: explicit db
43
+ export function createPreload<
44
+ S extends SchemaStructure,
45
+ TableName extends TableNames<S>,
46
+ T extends { columns: Record<string, ColumnSchema> },
47
+ RelatedFields extends Record<string, any>,
48
+ IsOne extends boolean,
49
+ >(
50
+ db: SyncedDb<S>,
51
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
52
+ options?: PreloadOptions,
53
+ ): void;
54
+
55
+ /**
56
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
57
+ * function so it tracks reactive deps), dedupes on the query's stable identity
58
+ * hash, and registers it via `db.preload`: a live query nobody subscribes to,
59
+ * evicted a ttl after it was registered unless a view mounts it first. Nothing
60
+ * to clean up here.
61
+ *
62
+ * Typical use: inside a list row, preload the detail query the user is likely
63
+ * to open next, so navigation paints from cache instead of the network.
64
+ */
65
+ export function createPreload<
66
+ S extends SchemaStructure,
67
+ TableName extends TableNames<S>,
68
+ T extends { columns: Record<string, ColumnSchema> },
69
+ RelatedFields extends Record<string, any>,
70
+ IsOne extends boolean,
71
+ >(
72
+ dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
73
+ queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
74
+ maybeOptions?: PreloadOptions,
75
+ ): void {
76
+ let db: SyncedDb<S>;
77
+ let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
78
+ let options: PreloadOptions | undefined;
79
+
80
+ if (dbOrQuery instanceof SyncedDb) {
81
+ db = dbOrQuery;
82
+ finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
83
+ options = maybeOptions;
84
+ } else {
85
+ const contextDb = useContext(Sp00kyContext);
86
+ if (!contextDb) {
87
+ throw new Error(
88
+ 'createPreload: No db argument provided and no Sp00kyContext found. ' +
89
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.',
90
+ );
91
+ }
92
+ db = contextDb as SyncedDb<S>;
93
+ finalQuery = dbOrQuery;
94
+ options = queryOrOptions as PreloadOptions | undefined;
95
+ }
96
+
97
+ let prevHash: number | undefined;
98
+
99
+ createEffect(() => {
100
+ if (!(options?.enabled?.() ?? true)) return;
101
+
102
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
103
+ if (!query) return;
104
+
105
+ // Dedupe on the query's stable identity hash so a reactive re-run with an
106
+ // unchanged query doesn't refetch (the core also dedupes per session).
107
+ if (query.hash === prevHash) return;
108
+ prevHash = query.hash;
109
+
110
+ void db.getSp00ky().preload(query, { signal: options?.signal });
111
+ });
112
+ }
package/src/lib/models.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RecordId } from 'surrealdb';
1
+ import type { RecordId } from 'surrealdb';
2
2
 
3
3
  // Re-export types from query-builder for backward compatibility
4
4
  export type { GenericModel, GenericSchema } from '@spooky/query-builder';
@@ -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
+ }