@spooky-sync/client-solid 0.0.0-canary.1 → 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.
@@ -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
+ }
@@ -0,0 +1,175 @@
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
+ const [url, setUrl] = createSignal<string | null>(null);
75
+ const [isLoading, setIsLoading] = createSignal(false);
76
+ const [error, setError] = createSignal<Error | null>(null);
77
+
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;
81
+ let privateUrl: string | null = null;
82
+
83
+ const [refetchSignal, setRefetchSignal] = createSignal(0);
84
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
85
+ let reloadOnce = false;
86
+
87
+ function releaseCurrent() {
88
+ lease?.release();
89
+ lease = null;
90
+ if (privateUrl) {
91
+ URL.revokeObjectURL(privateUrl);
92
+ privateUrl = null;
93
+ }
94
+ }
95
+
96
+ createEffect(() => {
97
+ const filePath = path();
98
+ // Subscribe to refetch signal so effect re-runs
99
+ refetchSignal();
100
+
101
+ releaseCurrent();
102
+
103
+ if (!filePath) {
104
+ setUrl(null);
105
+ setIsLoading(false);
106
+ setError(null);
107
+ return;
108
+ }
109
+
110
+ const reload = reloadOnce;
111
+ reloadOnce = false;
112
+
113
+ let cancelled = false;
114
+ setIsLoading(true);
115
+ setError(null);
116
+
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(
145
+ (result) => {
146
+ if (!cancelled) {
147
+ setUrl(result);
148
+ setIsLoading(false);
149
+ }
150
+ return undefined;
151
+ },
152
+ (err) => {
153
+ if (!cancelled) {
154
+ setError(err instanceof Error ? err : new Error(String(err)));
155
+ setIsLoading(false);
156
+ }
157
+ },
158
+ );
159
+
160
+ onCleanup(() => {
161
+ cancelled = true;
162
+ });
163
+ });
164
+
165
+ onCleanup(() => {
166
+ releaseCurrent();
167
+ });
168
+
169
+ const refetch = () => {
170
+ reloadOnce = true;
171
+ setRefetchSignal((n) => n + 1);
172
+ };
173
+
174
+ return { url, isLoading, error, refetch };
175
+ }
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import { createSignal, onCleanup } from 'solid-js';
2
+ import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import { fileToUint8Array } from '@spooky-sync/core';
4
+ import type { BucketPutOptions, BucketPutResult } from '@spooky-sync/core';
5
+ import type { SyncedDb } from '../index';
6
+ import { useDb } from './context';
7
+
8
+ export interface FileUploadResult {
9
+ isUploading: () => boolean;
10
+ error: () => Error | null;
11
+ clearError: () => void;
12
+ upload: (path: string, file: File | Blob, options?: BucketPutOptions) => Promise<BucketPutResult | void>;
13
+ download: (path: string) => Promise<string | null>;
14
+ remove: (path: string) => Promise<void>;
15
+ exists: (path: string) => Promise<boolean>;
16
+ }
17
+
18
+ export function useFileUpload<S extends SchemaStructure>(
19
+ bucketName: BucketNames<S>,
20
+ ): FileUploadResult;
21
+ export function useFileUpload<S extends SchemaStructure>(
22
+ db: SyncedDb<S>,
23
+ bucketName: BucketNames<S>,
24
+ ): FileUploadResult;
25
+ export function useFileUpload<S extends SchemaStructure>(
26
+ dbOrBucketName: SyncedDb<S> | BucketNames<S>,
27
+ maybeBucketName?: BucketNames<S>,
28
+ ): FileUploadResult {
29
+ let db: SyncedDb<S>;
30
+ let bucketName: BucketNames<S>;
31
+
32
+ if (typeof dbOrBucketName === 'string') {
33
+ db = useDb<S>();
34
+ bucketName = dbOrBucketName as BucketNames<S>;
35
+ } else {
36
+ db = dbOrBucketName as SyncedDb<S>;
37
+ // oxlint-disable-next-line no-non-null-assertion
38
+ bucketName = maybeBucketName!;
39
+ }
40
+
41
+ const [isUploading, setIsUploading] = createSignal(false);
42
+ const [error, setError] = createSignal<Error | null>(null);
43
+
44
+ const objectUrls: string[] = [];
45
+ onCleanup(() => {
46
+ for (const url of objectUrls) {
47
+ URL.revokeObjectURL(url);
48
+ }
49
+ });
50
+
51
+ const clearError = () => setError(null);
52
+
53
+ const validate = (file: File | Blob): void => {
54
+ const config = db.getBucketConfig(bucketName as string);
55
+ if (!config) return;
56
+
57
+ if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
58
+ const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
59
+ throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
60
+ }
61
+
62
+ if (config.allowedExtensions && config.allowedExtensions.length > 0) {
63
+ const fileName = (file as File).name;
64
+ if (fileName) {
65
+ const ext = fileName.split('.').pop()?.toLowerCase();
66
+ if (!ext || !config.allowedExtensions.includes(ext)) {
67
+ throw new Error(
68
+ `File type not allowed. Accepted: ${config.allowedExtensions.join(', ')}.`
69
+ );
70
+ }
71
+ }
72
+ }
73
+ };
74
+
75
+ const upload = async (
76
+ path: string,
77
+ file: File | Blob,
78
+ options?: BucketPutOptions
79
+ ): Promise<BucketPutResult | void> => {
80
+ setError(null);
81
+ try {
82
+ validate(file);
83
+ } catch (e) {
84
+ setError(e instanceof Error ? e : new Error(String(e)));
85
+ return;
86
+ }
87
+
88
+ setIsUploading(true);
89
+ try {
90
+ const bytes = await fileToUint8Array(file);
91
+ return await db.bucket(bucketName).put(path, bytes, options);
92
+ } catch (e) {
93
+ setError(e instanceof Error ? e : new Error(String(e)));
94
+ } finally {
95
+ setIsUploading(false);
96
+ }
97
+ };
98
+
99
+ const download = async (path: string): Promise<string | null> => {
100
+ setError(null);
101
+ try {
102
+ const content = await db.bucket(bucketName).get(path);
103
+ if (!content) return null;
104
+ const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
105
+ objectUrls.push(objectUrl);
106
+ return objectUrl;
107
+ } catch (e) {
108
+ setError(e instanceof Error ? e : new Error(String(e)));
109
+ return null;
110
+ }
111
+ };
112
+
113
+ const remove = async (path: string): Promise<void> => {
114
+ setError(null);
115
+ try {
116
+ await db.bucket(bucketName).delete(path);
117
+ } catch (e) {
118
+ setError(e instanceof Error ? e : new Error(String(e)));
119
+ }
120
+ };
121
+
122
+ const exists = async (path: string): Promise<boolean> => {
123
+ setError(null);
124
+ try {
125
+ return await db.bucket(bucketName).exists(path);
126
+ } catch (e) {
127
+ setError(e instanceof Error ? e : new Error(String(e)));
128
+ return false;
129
+ }
130
+ };
131
+
132
+ return {
133
+ isUploading,
134
+ error,
135
+ clearError,
136
+ upload,
137
+ download,
138
+ remove,
139
+ exists,
140
+ };
141
+ }