@spooky-sync/client-solid2 0.0.1-alpha.1

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.
Files changed (44) hide show
  1. package/AGENTS.md +81 -0
  2. package/QUICK_START.md +129 -0
  3. package/README.md +19 -0
  4. package/dist/index.cjs +1290 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +670 -0
  7. package/dist/index.d.cts.map +1 -0
  8. package/dist/index.d.ts +670 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +1266 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +63 -0
  13. package/skills/sp00ky-solid2/SKILL.md +71 -0
  14. package/src/index.ts +410 -0
  15. package/src/lib/Blurhash.ts +64 -0
  16. package/src/lib/BucketImage.ts +170 -0
  17. package/src/lib/Sp00kyProvider.ts +104 -0
  18. package/src/lib/__tests__/conflate.test.ts +120 -0
  19. package/src/lib/__tests__/create-query.test.ts +397 -0
  20. package/src/lib/__tests__/rc-semantics.test.ts +393 -0
  21. package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
  22. package/src/lib/conflate.ts +74 -0
  23. package/src/lib/context.ts +28 -0
  24. package/src/lib/create-preload.ts +116 -0
  25. package/src/lib/create-query.ts +388 -0
  26. package/src/lib/create-submission.ts +57 -0
  27. package/src/lib/from-subscription.ts +32 -0
  28. package/src/lib/merge-rows.test.ts +92 -0
  29. package/src/lib/merge-rows.ts +72 -0
  30. package/src/lib/models.ts +8 -0
  31. package/src/lib/use-app-release.ts +89 -0
  32. package/src/lib/use-blurhash.ts +82 -0
  33. package/src/lib/use-bucket-image.ts +108 -0
  34. package/src/lib/use-crdt-field.ts +57 -0
  35. package/src/lib/use-download-file.ts +181 -0
  36. package/src/lib/use-feature-flag.ts +43 -0
  37. package/src/lib/use-file-upload.ts +146 -0
  38. package/src/lib/use-storage-status.ts +44 -0
  39. package/src/lib/use-sync-activity.ts +99 -0
  40. package/src/lib/use-sync-status.ts +63 -0
  41. package/src/types/index.ts +83 -0
  42. package/tsconfig.json +27 -0
  43. package/tsdown.config.ts +20 -0
  44. package/vitest.config.ts +14 -0
@@ -0,0 +1,72 @@
1
+ import { snapshot } from 'solid-js';
2
+
3
+ /**
4
+ * Keyed in-place merge of a fresh row list into a store draft array.
5
+ *
6
+ * Why not `reconcile(rows, 'id')`: in Solid 2 rc.1 a reconcile REPLACES the row
7
+ * objects, so a component that captured `data()[0]` sees a different object
8
+ * after the next emission, and anything keyed on row identity (`<For>` without
9
+ * an explicit key, a memo comparing rows) re-creates its subtree on every live
10
+ * update. Mutating the draft in place keeps the identity AND keeps updates
11
+ * fine-grained: only the fields that actually changed are written, so a row
12
+ * whose data is unchanged notifies nobody.
13
+ *
14
+ * Rows are matched by `key` (default `id`); unmatched incoming rows are
15
+ * inserted as-is and trailing rows are dropped, so add / remove / reorder all
16
+ * reach coarse readers.
17
+ */
18
+ /**
19
+ * Field-level equality for the merge. `===` is not enough: the decoder hands
20
+ * out a fresh RecordId / Date instance for every record-link and datetime
21
+ * column on every emission, so by identity every one of those fields "changed"
22
+ * on every update - and a store write on an unchanged `id` re-ran everything a
23
+ * page keyed on that row (a thread's anchor, its composer, its whole subtree).
24
+ * Compare those wrappers by value; everything else stays identity (nested
25
+ * objects and arrays are reconciled by their own writes).
26
+ */
27
+ export function sameValue(a: unknown, b: unknown): boolean {
28
+ if (a === b) return true;
29
+ if (a == null || b == null) return false;
30
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
31
+ // RecordId (and the SDK's other value wrappers: Duration, Decimal, Uuid…)
32
+ // all carry a stable `toString`; two of the same class that print alike ARE
33
+ // the same value. Plain objects are excluded: their `toString` is
34
+ // "[object Object]" and would make every object equal to every other.
35
+ if (typeof a === 'object' && typeof b === 'object') {
36
+ const ctor = (a as any).constructor;
37
+ if (ctor && ctor === (b as any).constructor && ctor !== Object && ctor !== Array) {
38
+ return String(a) === String(b);
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ export function mergeRows(draft: any[], next: any[], key = 'id'): void {
45
+ const byKey = new Map<any, any>();
46
+ for (const row of draft) {
47
+ const k = row?.[key];
48
+ if (k !== undefined) byKey.set(String(k), row);
49
+ }
50
+
51
+ for (let i = 0; i < next.length; i++) {
52
+ const incoming = next[i];
53
+ const k = incoming?.[key];
54
+ const reuse = k !== undefined ? byKey.get(String(k)) : undefined;
55
+
56
+ if (reuse) {
57
+ for (const field of Object.keys(incoming)) {
58
+ if (!sameValue(reuse[field], incoming[field])) reuse[field] = incoming[field];
59
+ }
60
+ // `snapshot` first: iterating the proxy's own keys while deleting from it
61
+ // is not safe, and the raw object is what carries the stale fields.
62
+ for (const field of Object.keys(snapshot(reuse))) {
63
+ if (!(field in incoming)) delete reuse[field];
64
+ }
65
+ if (draft[i] !== reuse) draft[i] = reuse;
66
+ } else if (draft[i] !== incoming) {
67
+ draft[i] = incoming;
68
+ }
69
+ }
70
+
71
+ if (draft.length > next.length) draft.splice(next.length);
72
+ }
@@ -0,0 +1,8 @@
1
+ import type { RecordId } from 'surrealdb';
2
+
3
+ // Re-export types from query-builder for backward compatibility
4
+ export type { GenericModel, GenericSchema } from '@spooky-sync/query-builder';
5
+
6
+ // Model and ModelPayload types for the client
7
+ export type Model<T> = T;
8
+ export type ModelPayload<T> = T & { id: RecordId };
@@ -0,0 +1,89 @@
1
+ import type { Accessor } from 'solid-js';
2
+ import { onCleanup } from 'solid-js';
3
+ import { useDb } from './context';
4
+ import { fromSubscription } from './from-subscription';
5
+ import { semverGt, type AppReleaseOptions, type AppReleaseSnapshot } from '@spooky-sync/core';
6
+
7
+ export interface UseAppReleaseOptions extends AppReleaseOptions {
8
+ /** App name from sp00ky.yml, e.g. `web`. */
9
+ app: string;
10
+ /**
11
+ * The running build's version (X.Y.Z), typically baked in at build time
12
+ * (e.g. a vite `define` from package.json). `updateAvailable()` is true when
13
+ * the announced release is semver-newer than this.
14
+ */
15
+ currentVersion: string;
16
+ }
17
+
18
+ export interface UseAppRelease {
19
+ /** Latest announced version for the app, or undefined when no row exists. */
20
+ latestVersion: Accessor<string | undefined>;
21
+ /** Announced version is semver-newer than the running build. */
22
+ updateAvailable: Accessor<boolean>;
23
+ /** The newer release asks clients to update/reload without prompting. */
24
+ mandatory: Accessor<boolean>;
25
+ /** The newer release asks reloads to clear service-worker caches first. */
26
+ cacheBust: Accessor<boolean>;
27
+ /**
28
+ * Reload onto the announced release. Plain `location.reload()` normally;
29
+ * when the release is flagged cache-bust, CacheStorage is cleared, the
30
+ * service-worker registration is nudged to update, and navigation carries a
31
+ * `?cb=` token to punch through intermediary caches. The service worker is
32
+ * deliberately NOT unregistered: navigating while still controlled by a
33
+ * just-unregistered worker strands subresource fetches on the dead worker
34
+ * and the page hangs until a manual reload.
35
+ */
36
+ reload: () => Promise<void>;
37
+ }
38
+
39
+ async function reloadForSnapshot(snapshot: AppReleaseSnapshot): Promise<void> {
40
+ if (typeof window === 'undefined') return;
41
+ if (snapshot.cacheBust) {
42
+ try {
43
+ if (window.caches) {
44
+ const keys = await window.caches.keys();
45
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
46
+ }
47
+ if (navigator.serviceWorker) {
48
+ const regs = await navigator.serviceWorker.getRegistrations();
49
+ for (const r of regs) r.update().catch(() => {});
50
+ }
51
+ window.location.href = window.location.pathname + '?cb=' + Date.now();
52
+ return;
53
+ } catch {
54
+ /* fall through to a plain reload */
55
+ }
56
+ }
57
+ window.location.reload();
58
+ }
59
+
60
+ /**
61
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
62
+ * `spky deploy` / `spky release`) and compare it against the running build.
63
+ *
64
+ * Typical use: mount a small "new version available — Reload" notification
65
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
66
+ * (guard the auto path against reload loops with a per-version marker, since
67
+ * a client can reload while the deploy is still rolling out and land on the
68
+ * old bundle again).
69
+ */
70
+ export function useAppRelease(options: UseAppReleaseOptions): UseAppRelease {
71
+ const db = useDb();
72
+ const handle = db.getSp00ky().appRelease(options.app, { ttl: options.ttl });
73
+ onCleanup(() => handle.close());
74
+
75
+ const snapshot = fromSubscription<AppReleaseSnapshot>(
76
+ (cb) => handle.subscribe(cb),
77
+ handle.snapshot()
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,82 @@
1
+ import { createSignal, createEffect, 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
+ // Written from the lookup continuation, outside any tracking scope.
48
+ const [hash, setHash] = createSignal<string | null>(null, { ownedWrite: true });
49
+ const [isLoading, setIsLoading] = createSignal(false, { ownedWrite: true });
50
+
51
+ // Compute tracks the path; apply runs the lookup and returns the cancel
52
+ // cleanup, which runs before the next apply and on unmount.
53
+ createEffect(
54
+ () => path(),
55
+ (filePath) => {
56
+ if (!filePath) {
57
+ setHash(null);
58
+ setIsLoading(false);
59
+ return;
60
+ }
61
+ let cancelled = false;
62
+ setIsLoading(true);
63
+ db.bucket(bucketName)
64
+ .blurhash(filePath)
65
+ .then((result) => {
66
+ if (cancelled) return;
67
+ setHash(result);
68
+ setIsLoading(false);
69
+ })
70
+ .catch(() => {
71
+ if (cancelled) return;
72
+ setHash(null);
73
+ setIsLoading(false);
74
+ });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }
79
+ );
80
+
81
+ return { hash, isLoading };
82
+ }
@@ -0,0 +1,108 @@
1
+ import { createSignal, createEffect, 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
+ // Written from the decode continuation (and from the url effect's apply
84
+ // phase), outside any tracking scope.
85
+ const [ready, setReady] = createSignal(false, { ownedWrite: true });
86
+ // A new url (path change, refetch) means a new undecoded bitmap.
87
+ createEffect(
88
+ file.url,
89
+ () => {
90
+ setReady(false);
91
+ },
92
+ { defer: true }
93
+ );
94
+
95
+ const gate = (img: HTMLImageElement) => {
96
+ const done = () => setReady(true);
97
+ if (typeof img.decode === 'function') {
98
+ img.decode().then(done, done);
99
+ } else if (img.complete) {
100
+ done();
101
+ } else {
102
+ img.onload = done;
103
+ img.onerror = done;
104
+ }
105
+ };
106
+
107
+ return { ...file, blurhash: hash, ready, gate };
108
+ }
@@ -0,0 +1,57 @@
1
+ import { createEffect, createSignal, type Accessor } from 'solid-js';
2
+ import { useDb } 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 = useDb();
12
+ const [crdtField, setCrdtField] = createSignal<CrdtField | null>(null, { ownedWrite: true });
13
+
14
+ // Two-arg Solid 2 effect: the compute tracks `recordId`, the apply owns the
15
+ // open/close lifecycle and returns the cleanup — which runs both when the id
16
+ // changes (before the next apply) and on unmount. That replaces the Solid 1
17
+ // version's manual currentId/initialized bookkeeping.
18
+ createEffect(
19
+ () => recordId(),
20
+ (id) => {
21
+ if (!id) {
22
+ setCrdtField(null);
23
+ return;
24
+ }
25
+ const sp00ky = db.getSp00ky();
26
+ let superseded = false;
27
+ const text = fallbackText?.();
28
+ sp00ky
29
+ .openCrdtField(table, id, field, text)
30
+ .then((cf) => {
31
+ if (!superseded) {
32
+ setCrdtField(cf);
33
+ } else {
34
+ sp00ky.closeCrdtField(table, id, field);
35
+ }
36
+ })
37
+ .catch((err) => {
38
+ // Silent rejections here leave the consumer's `Show when={field()}`
39
+ // permanently stuck on its fallback (typically a static `<p>` with
40
+ // no editing UI), with no error trail. Surface the failure so the
41
+ // root cause (missing `@crdt` annotation, schema codegen drift,
42
+ // local DB query failure, etc.) is visible in the console instead
43
+ // of silently breaking collaborative fields.
44
+ console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
45
+ });
46
+ return () => {
47
+ superseded = true;
48
+ if (crdtField()) {
49
+ sp00ky.closeCrdtField(table, id, field);
50
+ setCrdtField(null);
51
+ }
52
+ };
53
+ }
54
+ );
55
+
56
+ return crdtField;
57
+ }
@@ -0,0 +1,181 @@
1
+ import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
2
+ import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import type { BlobUrlLease } from '@spooky-sync/core';
4
+ import type { SyncedDb } from '../index';
5
+ import { useDb } from './context';
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
+ */
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';
29
+ }
30
+
31
+ export interface UseDownloadFileResult {
32
+ url: Accessor<string | null>;
33
+ isLoading: Accessor<boolean>;
34
+ error: Accessor<Error | null>;
35
+ refetch: () => void;
36
+ }
37
+
38
+ export function useDownloadFile<S extends SchemaStructure>(
39
+ bucketName: BucketNames<S>,
40
+ path: Accessor<string | null | undefined>,
41
+ options?: UseDownloadFileOptions
42
+ ): UseDownloadFileResult;
43
+ export function useDownloadFile<S extends SchemaStructure>(
44
+ db: SyncedDb<S>,
45
+ bucketName: BucketNames<S>,
46
+ path: Accessor<string | null | undefined>,
47
+ options?: UseDownloadFileOptions
48
+ ): UseDownloadFileResult;
49
+ export function useDownloadFile<S extends SchemaStructure>(
50
+ dbOrBucketName: SyncedDb<S> | BucketNames<S>,
51
+ bucketNameOrPath?: BucketNames<S> | Accessor<string | null | undefined>,
52
+ pathOrOptions?: Accessor<string | null | undefined> | UseDownloadFileOptions,
53
+ maybeOptions?: UseDownloadFileOptions
54
+ ): UseDownloadFileResult {
55
+ let db: SyncedDb<S>;
56
+ let bucketName: BucketNames<S>;
57
+ let path: Accessor<string | null | undefined>;
58
+ let options: UseDownloadFileOptions;
59
+
60
+ if (typeof dbOrBucketName === 'string') {
61
+ db = useDb<S>();
62
+ bucketName = dbOrBucketName as BucketNames<S>;
63
+ path = bucketNameOrPath as Accessor<string | null | undefined>;
64
+ options = (pathOrOptions as UseDownloadFileOptions) ?? {};
65
+ } else {
66
+ db = dbOrBucketName as SyncedDb<S>;
67
+ bucketName = bucketNameOrPath as BucketNames<S>;
68
+ path = pathOrOptions as Accessor<string | null | undefined>;
69
+ options = maybeOptions ?? {};
70
+ }
71
+
72
+ const useCache = options.cache !== false;
73
+
74
+ // Written from fetch continuations — outside any tracking scope.
75
+ const [url, setUrl] = createSignal<string | null>(null, { ownedWrite: true });
76
+ const [isLoading, setIsLoading] = createSignal(false, { ownedWrite: true });
77
+ const [error, setError] = createSignal<Error | null>(null, { ownedWrite: true });
78
+
79
+ // Exactly one of these is held at a time: a refcounted lease on the shared
80
+ // cache entry, or a private URL this instance minted and must revoke itself.
81
+ let lease: BlobUrlLease | null = null;
82
+ let privateUrl: string | null = null;
83
+
84
+ const [refetchSignal, setRefetchSignal] = createSignal(0);
85
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
86
+ let reloadOnce = false;
87
+
88
+ function releaseCurrent() {
89
+ lease?.release();
90
+ lease = null;
91
+ if (privateUrl) {
92
+ URL.revokeObjectURL(privateUrl);
93
+ privateUrl = null;
94
+ }
95
+ }
96
+
97
+ // Two-arg Solid 2 effect: compute tracks path + refetch tick; apply runs the
98
+ // fetch and returns the cancel/release cleanup, which runs before the next
99
+ // apply and on unmount.
100
+ createEffect(
101
+ () => {
102
+ refetchSignal();
103
+ return path();
104
+ },
105
+ (filePath) => {
106
+ releaseCurrent();
107
+
108
+ if (!filePath) {
109
+ setUrl(null);
110
+ setIsLoading(false);
111
+ setError(null);
112
+ return;
113
+ }
114
+
115
+ const reload = reloadOnce;
116
+ reloadOnce = false;
117
+
118
+ let cancelled = false;
119
+ setIsLoading(true);
120
+ setError(null);
121
+
122
+ const bucket = db.bucket(bucketName);
123
+ const resolve = useCache
124
+ ? bucket
125
+ .url(filePath, {
126
+ persist: options.persist !== false,
127
+ pin: options.pin,
128
+ revalidate: options.revalidate,
129
+ reload,
130
+ })
131
+ .then((acquired) => {
132
+ if (!acquired) return null;
133
+ if (cancelled) {
134
+ // Unmounted or the path changed mid-flight — hand the reference
135
+ // straight back, or the entry never drops to zero and its object
136
+ // URL leaks for the life of the tab.
137
+ acquired.release();
138
+ return null;
139
+ }
140
+ lease = acquired;
141
+ return acquired.url;
142
+ })
143
+ : bucket.read(filePath, { persist: false, reload: true }).then((blob) => {
144
+ if (!blob || cancelled) return null;
145
+ privateUrl = URL.createObjectURL(blob);
146
+ return privateUrl;
147
+ });
148
+
149
+ resolve.then(
150
+ (result) => {
151
+ if (!cancelled) {
152
+ setUrl(result);
153
+ setIsLoading(false);
154
+ }
155
+ return undefined;
156
+ },
157
+ (err) => {
158
+ if (!cancelled) {
159
+ setError(err instanceof Error ? err : new Error(String(err)));
160
+ setIsLoading(false);
161
+ }
162
+ }
163
+ );
164
+
165
+ return () => {
166
+ cancelled = true;
167
+ };
168
+ }
169
+ );
170
+
171
+ onCleanup(() => {
172
+ releaseCurrent();
173
+ });
174
+
175
+ const refetch = () => {
176
+ reloadOnce = true;
177
+ setRefetchSignal((n) => n + 1);
178
+ };
179
+
180
+ return { url, isLoading, error, refetch };
181
+ }
@@ -0,0 +1,43 @@
1
+ import type { Accessor } from 'solid-js';
2
+ import { onCleanup } from 'solid-js';
3
+ import { useDb } from './context';
4
+ import { fromSubscription } from './from-subscription';
5
+ import type { FeatureFlagOptions } from '@spooky-sync/core';
6
+
7
+ export interface UseFeatureFlag {
8
+ variant: Accessor<string | undefined>;
9
+ payload: Accessor<unknown | undefined>;
10
+ enabled: Accessor<boolean>;
11
+ }
12
+
13
+ /**
14
+ * Subscribe to a feature flag for the currently authenticated user.
15
+ *
16
+ * Returns three Solid accessors that update reactively whenever the
17
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
18
+ * the same SSP + sync pipeline that powers `createQuery`, so toggling a flag
19
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
20
+ *
21
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
22
+ * For multi-variant flags, prefer `variant()` directly.
23
+ */
24
+ export function useFeatureFlag(key: string, options?: FeatureFlagOptions): UseFeatureFlag {
25
+ const db = useDb();
26
+ const handle = db.getSp00ky().feature(key, options);
27
+ onCleanup(() => handle.close());
28
+
29
+ const state = fromSubscription<{ variant: string | undefined; payload: unknown }>(
30
+ (cb) =>
31
+ handle.subscribe((s) => cb({ variant: s.variant ?? options?.fallback, payload: s.payload })),
32
+ { variant: handle.variant(), payload: handle.payload() }
33
+ );
34
+
35
+ return {
36
+ variant: () => state().variant,
37
+ payload: () => state().payload,
38
+ enabled: () => {
39
+ const v = state().variant;
40
+ return v !== undefined && v !== 'off';
41
+ },
42
+ };
43
+ }