@spooky-sync/client-solid2 0.0.1-canary.200
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/LICENSE +21 -0
- package/QUICK_START.md +126 -0
- package/README.md +19 -0
- package/dist/index.cjs +903 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +498 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +498 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +884 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/skills/sp00ky-solid2/SKILL.md +68 -0
- package/src/index.ts +365 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/__tests__/conflate.test.ts +120 -0
- package/src/lib/__tests__/create-query.test.ts +284 -0
- package/src/lib/__tests__/rc-semantics.test.ts +389 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +115 -0
- package/src/lib/create-query.ts +285 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +57 -0
- package/src/lib/use-download-file.ts +181 -0
- package/src/lib/use-feature-flag.ts +43 -0
- package/src/lib/use-file-upload.ts +146 -0
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +63 -0
- package/src/types/index.ts +83 -0
- package/tsconfig.json +27 -0
- package/tsdown.config.ts +18 -0
- package/vitest.config.ts +14 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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: (
|
|
13
|
+
path: string,
|
|
14
|
+
file: File | Blob,
|
|
15
|
+
options?: BucketPutOptions
|
|
16
|
+
) => Promise<BucketPutResult | void>;
|
|
17
|
+
download: (path: string) => Promise<string | null>;
|
|
18
|
+
remove: (path: string) => Promise<void>;
|
|
19
|
+
exists: (path: string) => Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useFileUpload<S extends SchemaStructure>(
|
|
23
|
+
bucketName: BucketNames<S>
|
|
24
|
+
): FileUploadResult;
|
|
25
|
+
export function useFileUpload<S extends SchemaStructure>(
|
|
26
|
+
db: SyncedDb<S>,
|
|
27
|
+
bucketName: BucketNames<S>
|
|
28
|
+
): FileUploadResult;
|
|
29
|
+
export function useFileUpload<S extends SchemaStructure>(
|
|
30
|
+
dbOrBucketName: SyncedDb<S> | BucketNames<S>,
|
|
31
|
+
maybeBucketName?: BucketNames<S>
|
|
32
|
+
): FileUploadResult {
|
|
33
|
+
let db: SyncedDb<S>;
|
|
34
|
+
let bucketName: BucketNames<S>;
|
|
35
|
+
|
|
36
|
+
if (typeof dbOrBucketName === 'string') {
|
|
37
|
+
db = useDb<S>();
|
|
38
|
+
bucketName = dbOrBucketName as BucketNames<S>;
|
|
39
|
+
} else {
|
|
40
|
+
db = dbOrBucketName as SyncedDb<S>;
|
|
41
|
+
// oxlint-disable-next-line no-non-null-assertion
|
|
42
|
+
bucketName = maybeBucketName!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Written from async continuations — outside any tracking scope.
|
|
46
|
+
const [isUploading, setIsUploading] = createSignal(false, { ownedWrite: true });
|
|
47
|
+
const [error, setError] = createSignal<Error | null>(null, { ownedWrite: true });
|
|
48
|
+
|
|
49
|
+
const objectUrls: string[] = [];
|
|
50
|
+
onCleanup(() => {
|
|
51
|
+
for (const url of objectUrls) {
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const clearError = () => setError(null);
|
|
57
|
+
|
|
58
|
+
const validate = (file: File | Blob): void => {
|
|
59
|
+
const config = db.getBucketConfig(bucketName as string);
|
|
60
|
+
if (!config) return;
|
|
61
|
+
|
|
62
|
+
if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
|
|
63
|
+
const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
|
|
64
|
+
throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.allowedExtensions && config.allowedExtensions.length > 0) {
|
|
68
|
+
const fileName = (file as File).name;
|
|
69
|
+
if (fileName) {
|
|
70
|
+
const ext = fileName.split('.').pop()?.toLowerCase();
|
|
71
|
+
if (!ext || !config.allowedExtensions.includes(ext)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`File type not allowed. Accepted: ${config.allowedExtensions.join(', ')}.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const upload = async (
|
|
81
|
+
path: string,
|
|
82
|
+
file: File | Blob,
|
|
83
|
+
options?: BucketPutOptions
|
|
84
|
+
): Promise<BucketPutResult | void> => {
|
|
85
|
+
setError(null);
|
|
86
|
+
try {
|
|
87
|
+
validate(file);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setIsUploading(true);
|
|
94
|
+
try {
|
|
95
|
+
const bytes = await fileToUint8Array(file);
|
|
96
|
+
return await db.bucket(bucketName).put(path, bytes, options);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
99
|
+
} finally {
|
|
100
|
+
setIsUploading(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const download = async (path: string): Promise<string | null> => {
|
|
105
|
+
setError(null);
|
|
106
|
+
try {
|
|
107
|
+
const content = await db.bucket(bucketName).get(path);
|
|
108
|
+
if (!content) return null;
|
|
109
|
+
const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
|
|
110
|
+
objectUrls.push(objectUrl);
|
|
111
|
+
return objectUrl;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const remove = async (path: string): Promise<void> => {
|
|
119
|
+
setError(null);
|
|
120
|
+
try {
|
|
121
|
+
await db.bucket(bucketName).delete(path);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const exists = async (path: string): Promise<boolean> => {
|
|
128
|
+
setError(null);
|
|
129
|
+
try {
|
|
130
|
+
return await db.bucket(bucketName).exists(path);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
isUploading,
|
|
139
|
+
error,
|
|
140
|
+
clearError,
|
|
141
|
+
upload,
|
|
142
|
+
download,
|
|
143
|
+
remove,
|
|
144
|
+
exists,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import { fromSubscription } from './from-subscription';
|
|
4
|
+
import type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
|
|
5
|
+
|
|
6
|
+
export interface UseStorageStatus {
|
|
7
|
+
/** Full durability snapshot; updates reactively. */
|
|
8
|
+
health: Accessor<StorageHealth>;
|
|
9
|
+
/** `'unknown'` | `'persistent'` | `'memory'`. */
|
|
10
|
+
status: Accessor<StorageHealthStatus>;
|
|
11
|
+
/** `true` when the local store survives a reload. */
|
|
12
|
+
isPersistent: Accessor<boolean>;
|
|
13
|
+
/**
|
|
14
|
+
* `true` only when durable storage was requested and could NOT be opened, so
|
|
15
|
+
* the dataset is sitting in RAM and local writes die on reload. Drive a
|
|
16
|
+
* warning off this, not off `status`: a store configured as in-memory reports
|
|
17
|
+
* `'memory'` too, and that is a choice rather than a problem.
|
|
18
|
+
*/
|
|
19
|
+
isMemoryFallback: Accessor<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Observe how durable the LOCAL cache is, for a "no local storage" warning.
|
|
24
|
+
*
|
|
25
|
+
* Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
|
|
26
|
+
* the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
|
|
27
|
+
* second tab of the same app cannot get it and runs in memory instead (the
|
|
28
|
+
* engine retries first, so a closing tab's lock is usually waited out). Must be
|
|
29
|
+
* used within a `<Sp00kyProvider>`.
|
|
30
|
+
*/
|
|
31
|
+
export function useStorageStatus(): UseStorageStatus {
|
|
32
|
+
const db = useDb();
|
|
33
|
+
const health = fromSubscription<StorageHealth>(
|
|
34
|
+
(cb) => db.subscribeToStorageHealth(cb),
|
|
35
|
+
db.storageHealth
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
health,
|
|
40
|
+
status: () => health().status,
|
|
41
|
+
isPersistent: () => health().status === 'persistent',
|
|
42
|
+
isMemoryFallback: () => health().fallback,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import { fromSubscription } from './from-subscription';
|
|
4
|
+
import type { ConnectionState, SyncHealth, SyncHealthStatus } from '@spooky-sync/core';
|
|
5
|
+
|
|
6
|
+
export interface UseSyncStatus {
|
|
7
|
+
/** Full health snapshot; updates reactively on every transition. */
|
|
8
|
+
health: Accessor<SyncHealth>;
|
|
9
|
+
/** `'healthy'` | `'degraded'`. */
|
|
10
|
+
status: Accessor<SyncHealthStatus>;
|
|
11
|
+
isHealthy: Accessor<boolean>;
|
|
12
|
+
/** `true` once sync has failed for a sustained run — drive a banner off this. */
|
|
13
|
+
isDegraded: Accessor<boolean>;
|
|
14
|
+
/** `true` once at least one sync round has succeeded this session. */
|
|
15
|
+
everConnected: Accessor<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* `true` only for a real lost connection: degraded AFTER a first successful
|
|
18
|
+
* sync. Stays `false` during the initial "connecting" phase (degraded but
|
|
19
|
+
* never reached the server yet), so an indicator can show nothing until the
|
|
20
|
+
* app has actually connected once.
|
|
21
|
+
*/
|
|
22
|
+
isOffline: Accessor<boolean>;
|
|
23
|
+
/**
|
|
24
|
+
* Transport state of the remote WebSocket. Flips the instant the socket
|
|
25
|
+
* drops, unlike `status`, which only degrades after a sustained run of failed
|
|
26
|
+
* sync rounds — so this is what to drive a "reconnecting…" affordance off.
|
|
27
|
+
*/
|
|
28
|
+
connection: Accessor<ConnectionState>;
|
|
29
|
+
/**
|
|
30
|
+
* `true` while the connection is being re-established. Usually still
|
|
31
|
+
* `isHealthy()`: a short reconnect is invisible to sync, and writes made
|
|
32
|
+
* during it are queued locally and pushed once the socket is back.
|
|
33
|
+
*/
|
|
34
|
+
isReconnecting: Accessor<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Observe sync health for a "can't reach the server" banner / indicator.
|
|
39
|
+
*
|
|
40
|
+
* Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
|
|
41
|
+
* remote 500 on query registration, a dropped socket) are absorbed by the
|
|
42
|
+
* retry and never flip this; `isDegraded()` only goes true once failures
|
|
43
|
+
* persist for the configured number of consecutive rounds (sp00ky core config
|
|
44
|
+
* `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
|
|
45
|
+
* the next successful round. Must be used within a `<Sp00kyProvider>`.
|
|
46
|
+
*/
|
|
47
|
+
export function useSyncStatus(): UseSyncStatus {
|
|
48
|
+
const db = useDb();
|
|
49
|
+
// The subscription fires synchronously with the current status; the initial
|
|
50
|
+
// value (loadingValue) just avoids a flash before it lands.
|
|
51
|
+
const health = fromSubscription<SyncHealth>((cb) => db.subscribeToSyncHealth(cb), db.syncHealth);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
health,
|
|
55
|
+
status: () => health().status,
|
|
56
|
+
isHealthy: () => health().status === 'healthy',
|
|
57
|
+
isDegraded: () => health().status === 'degraded',
|
|
58
|
+
everConnected: () => health().everConnected,
|
|
59
|
+
isOffline: () => health().status === 'degraded' && health().everConnected,
|
|
60
|
+
connection: () => health().connection,
|
|
61
|
+
isReconnecting: () => health().connection === 'reconnecting',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { SyncedDb } from '../index';
|
|
2
|
+
import type { GenericSchema } from '../lib/models';
|
|
3
|
+
import type { Sp00kyConfig } from '@spooky-sync/core';
|
|
4
|
+
import type { SchemaStructure, TableNames, GetTable, TableModel } from '@spooky-sync/query-builder';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for database provisioning
|
|
8
|
+
*/
|
|
9
|
+
export interface ProvisionOptions {
|
|
10
|
+
/** Force re-provision even if schema already exists */
|
|
11
|
+
force?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare global {
|
|
15
|
+
interface Window {
|
|
16
|
+
db?: SyncedDb<any>;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CacheStrategy = 'memory' | 'indexeddb';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Infer Schema type (Record<TableName, Model>) from schema const
|
|
24
|
+
*/
|
|
25
|
+
export type InferSchemaFromConst<S extends SchemaStructure> = {
|
|
26
|
+
[K in TableNames<S>]: TableModel<GetTable<S, K>>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Infer Relationships type from schema const's relationships array
|
|
31
|
+
* Converts from array format to nested object format
|
|
32
|
+
*/
|
|
33
|
+
export type InferRelationshipsFromConst<S extends SchemaStructure, Schema extends GenericSchema> = {
|
|
34
|
+
[TableName in TableNames<S>]: {
|
|
35
|
+
[Rel in Extract<S['relationships'][number], { from: TableName }> as Rel['field']]: {
|
|
36
|
+
model: Rel['to'] extends keyof Schema ? Schema[Rel['to']] : any;
|
|
37
|
+
table: Rel['to'];
|
|
38
|
+
cardinality: Rel['cardinality'];
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Prettify helper expands types for better intellisense
|
|
44
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
45
|
+
|
|
46
|
+
export type SyncedDbConfig<S extends SchemaStructure> = Prettify<Sp00kyConfig<S>>;
|
|
47
|
+
|
|
48
|
+
// export interface LocalDbConfig {
|
|
49
|
+
// name: string;
|
|
50
|
+
// storageStrategy: CacheStrategy;
|
|
51
|
+
// namespace?: string;
|
|
52
|
+
// database?: string;
|
|
53
|
+
// }
|
|
54
|
+
|
|
55
|
+
// export interface RemoteDbConfig {
|
|
56
|
+
// url: string;
|
|
57
|
+
// token?: string;
|
|
58
|
+
// namespace?: string;
|
|
59
|
+
// database?: string;
|
|
60
|
+
// }
|
|
61
|
+
|
|
62
|
+
// export interface DbConnection {
|
|
63
|
+
// internal: Surreal;
|
|
64
|
+
// local: Surreal;
|
|
65
|
+
// remote?: Surreal;
|
|
66
|
+
// }
|
|
67
|
+
|
|
68
|
+
// export interface SyncStatus {
|
|
69
|
+
// isConnected: boolean;
|
|
70
|
+
// lastSync?: Date;
|
|
71
|
+
// pendingChanges: number;
|
|
72
|
+
// localRecords: number;
|
|
73
|
+
// remoteRecords?: number;
|
|
74
|
+
// }
|
|
75
|
+
|
|
76
|
+
// export interface SyncOptions {
|
|
77
|
+
// /** Force full sync regardless of last sync time */
|
|
78
|
+
// force?: boolean;
|
|
79
|
+
// /** Sync only specific tables */
|
|
80
|
+
// tables?: string[];
|
|
81
|
+
// /** Batch size for sync operations */
|
|
82
|
+
// batchSize?: number;
|
|
83
|
+
// }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"allowSyntheticDefaultImports": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"declaration": true,
|
|
17
|
+
"declarationMap": true,
|
|
18
|
+
"emitDeclarationOnly": false,
|
|
19
|
+
"outDir": "./dist",
|
|
20
|
+
"paths": {
|
|
21
|
+
"@spooky-sync/query-builder": ["../query-builder/src/index.ts"],
|
|
22
|
+
"@spooky-sync/core": ["../core/src/index.ts"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*"],
|
|
26
|
+
"exclude": ["node_modules", "dist"]
|
|
27
|
+
}
|
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'tsdown';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['esm', 'cjs'],
|
|
6
|
+
dts: true,
|
|
7
|
+
external: [
|
|
8
|
+
'surrealdb',
|
|
9
|
+
'@surrealdb/wasm',
|
|
10
|
+
'solid-js',
|
|
11
|
+
'@spooky-sync/core',
|
|
12
|
+
'@spooky-sync/query-builder',
|
|
13
|
+
],
|
|
14
|
+
clean: true,
|
|
15
|
+
hash: false,
|
|
16
|
+
sourcemap: true,
|
|
17
|
+
target: 'es2020',
|
|
18
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
// solid-js's `node` export condition resolves the SSR build, where user
|
|
5
|
+
// effects intentionally never run. Force the browser dev build so tests
|
|
6
|
+
// exercise real client-side reactivity semantics.
|
|
7
|
+
resolve: {
|
|
8
|
+
conditions: ['browser', 'development'],
|
|
9
|
+
},
|
|
10
|
+
test: {
|
|
11
|
+
environment: 'node',
|
|
12
|
+
include: ['src/**/*.test.ts'],
|
|
13
|
+
},
|
|
14
|
+
});
|