@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.
- package/AGENTS.md +69 -0
- package/README.md +20 -0
- package/dist/index.cjs +815 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +393 -23
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +393 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +805 -61
- package/dist/index.js.map +1 -1
- package/package.json +10 -8
- package/skills/sp00ky-solid/SKILL.md +331 -0
- package/skills/sp00ky-solid/references/file-hooks.md +141 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +188 -57
- package/src/lib/Blurhash.ts +54 -0
- package/src/lib/BucketImage.ts +141 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/context.ts +3 -3
- package/src/lib/create-preload.ts +112 -0
- package/src/lib/models.ts +1 -1
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +77 -0
- package/src/lib/use-bucket-image.ts +100 -0
- package/src/lib/use-crdt-field.ts +68 -0
- package/src/lib/use-download-file.ts +175 -0
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +141 -0
- package/src/lib/use-query.ts +175 -28
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +3 -4
- package/src/lib/SpookyProvider.ts +0 -55
|
@@ -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
|
+
}
|