@spooky-sync/client-solid 0.0.1-canary.17 → 0.0.1-canary.170
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 +68 -0
- package/README.md +20 -0
- package/dist/index.cjs +416 -156
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +248 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +248 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +411 -157
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/skills/sp00ky-solid/SKILL.md +335 -0
- package/skills/{spooky-solid → sp00ky-solid}/references/file-hooks.md +34 -5
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +170 -61
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/context.ts +3 -3
- package/src/lib/create-preload.ts +111 -0
- package/src/lib/models.ts +1 -1
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +68 -0
- package/src/lib/use-download-file.ts +66 -110
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +2 -1
- package/src/lib/use-query.ts +143 -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/skills/spooky-solid/SKILL.md +0 -217
- package/src/lib/SpookyProvider.ts +0 -55
package/src/lib/use-query.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {
|
|
2
2
|
ColumnSchema,
|
|
3
3
|
FinalQuery,
|
|
4
4
|
SchemaStructure,
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
QueryResult,
|
|
7
7
|
} from '@spooky-sync/query-builder';
|
|
8
8
|
import { createEffect, createSignal, onCleanup, useContext } from 'solid-js';
|
|
9
|
+
import { createStore, reconcile } from 'solid-js/store';
|
|
9
10
|
import { SyncedDb } from '..';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
|
|
12
|
+
import { Sp00kyContext } from './context';
|
|
12
13
|
|
|
13
14
|
type QueryArg<
|
|
14
15
|
S extends SchemaStructure,
|
|
@@ -17,13 +18,23 @@ type QueryArg<
|
|
|
17
18
|
RelatedFields extends Record<string, any>,
|
|
18
19
|
IsOne extends boolean,
|
|
19
20
|
> =
|
|
20
|
-
| FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
21
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
21
22
|
| (() =>
|
|
22
|
-
| FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
23
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
23
24
|
| null
|
|
24
25
|
| undefined);
|
|
25
26
|
|
|
26
|
-
type QueryOptions = {
|
|
27
|
+
type QueryOptions = {
|
|
28
|
+
enabled?: () => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Tear down the query (remote `_00_query` view + local WASM view) when this
|
|
31
|
+
* hook is disposed and no other subscriber remains, instead of keeping it
|
|
32
|
+
* resident for cheap re-subscription. Use for viewport-windowed lists that
|
|
33
|
+
* mount/unmount a query per scroll window and want off-screen windows
|
|
34
|
+
* cancelled. Trade-off: scrolling back to a torn-down window re-registers it.
|
|
35
|
+
*/
|
|
36
|
+
deregisterOnCleanup?: boolean;
|
|
37
|
+
};
|
|
27
38
|
|
|
28
39
|
// Overload: context-based (no explicit db)
|
|
29
40
|
export function useQuery<
|
|
@@ -36,7 +47,13 @@ export function useQuery<
|
|
|
36
47
|
>(
|
|
37
48
|
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
38
49
|
options?: QueryOptions,
|
|
39
|
-
): {
|
|
50
|
+
): {
|
|
51
|
+
data: () => TData | undefined;
|
|
52
|
+
error: () => Error | undefined;
|
|
53
|
+
isLoading: () => boolean;
|
|
54
|
+
isFetching: () => boolean;
|
|
55
|
+
isSettled: () => boolean;
|
|
56
|
+
};
|
|
40
57
|
|
|
41
58
|
// Overload: explicit db (backward-compatible)
|
|
42
59
|
export function useQuery<
|
|
@@ -50,7 +67,13 @@ export function useQuery<
|
|
|
50
67
|
db: SyncedDb<S>,
|
|
51
68
|
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
52
69
|
options?: QueryOptions,
|
|
53
|
-
): {
|
|
70
|
+
): {
|
|
71
|
+
data: () => TData | undefined;
|
|
72
|
+
error: () => Error | undefined;
|
|
73
|
+
isLoading: () => boolean;
|
|
74
|
+
isFetching: () => boolean;
|
|
75
|
+
isSettled: () => boolean;
|
|
76
|
+
};
|
|
54
77
|
|
|
55
78
|
// Implementation
|
|
56
79
|
export function useQuery<
|
|
@@ -82,11 +105,11 @@ export function useQuery<
|
|
|
82
105
|
options = maybeOptions;
|
|
83
106
|
} else {
|
|
84
107
|
// Context-based overload: useQuery(query, options?)
|
|
85
|
-
const contextDb = useContext(
|
|
108
|
+
const contextDb = useContext(Sp00kyContext);
|
|
86
109
|
if (!contextDb) {
|
|
87
110
|
throw new Error(
|
|
88
|
-
'useQuery: No db argument provided and no
|
|
89
|
-
'Either pass a SyncedDb instance or wrap your app in <
|
|
111
|
+
'useQuery: No db argument provided and no Sp00kyContext found. ' +
|
|
112
|
+
'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.'
|
|
90
113
|
);
|
|
91
114
|
}
|
|
92
115
|
db = contextDb as SyncedDb<S>;
|
|
@@ -94,29 +117,76 @@ export function useQuery<
|
|
|
94
117
|
options = queryOrOptions as QueryOptions | undefined;
|
|
95
118
|
}
|
|
96
119
|
|
|
97
|
-
const [data, setData] = createSignal<TData | undefined>(undefined);
|
|
98
120
|
const [error, setError] = createSignal<Error | undefined>(undefined);
|
|
99
121
|
const [isFetched, setIsFetched] = createSignal(false);
|
|
100
|
-
const [
|
|
122
|
+
const [isFetching, setIsFetching] = createSignal(false);
|
|
123
|
+
// Results live in a store (not a signal) so consecutive live-query emissions
|
|
124
|
+
// are merged with `reconcile`: unchanged rows keep their object identity and
|
|
125
|
+
// changed rows are mutated in place. That keeps Solid's reference-keyed `<For>`
|
|
126
|
+
// rows — and any `useQuery` subscriptions mounted inside them — alive across
|
|
127
|
+
// updates, instead of tearing every row down and re-registering its queries.
|
|
128
|
+
const [state, setState] = createStore<{ value: TData | undefined }>({ value: undefined });
|
|
129
|
+
// `reconcile` (below) merges each emission into `state.value` IN PLACE, keeping
|
|
130
|
+
// the array reference stable. That's ideal for granular per-row reactivity, but
|
|
131
|
+
// it means a *coarse* reader of `data()` — `<For each={data()}>`, or an effect
|
|
132
|
+
// that copies the whole array elsewhere (e.g. GameList's windowed store) — is
|
|
133
|
+
// NOT re-run when rows are added/removed/reordered within a same-length result
|
|
134
|
+
// (the classic case: deleting a row in a windowed list shifts the next one in,
|
|
135
|
+
// so length stays 50 and the array ref never changes). Bump a version on every
|
|
136
|
+
// emission and read it in `data()` so every consumer re-runs on any change while
|
|
137
|
+
// reconcile still preserves row identity underneath.
|
|
138
|
+
const [version, setVersion] = createSignal(0);
|
|
139
|
+
const data = () => {
|
|
140
|
+
version();
|
|
141
|
+
return state.value;
|
|
142
|
+
};
|
|
143
|
+
|
|
101
144
|
let prevQueryString: string | undefined;
|
|
145
|
+
// Monotonic token for each subscription generation. Bumped whenever the query
|
|
146
|
+
// identity changes or the hook is disposed, so a slow async `initQuery`
|
|
147
|
+
// continuation can detect it was superseded and avoid installing a stale (and
|
|
148
|
+
// leaked) subscription.
|
|
149
|
+
let runId = 0;
|
|
150
|
+
let activeUnsub: (() => void) | undefined;
|
|
151
|
+
// The hash of the currently-installed subscription, for opt-in deregister on
|
|
152
|
+
// dispose (see `deregisterOnCleanup`).
|
|
153
|
+
let activeHash: string | undefined;
|
|
154
|
+
|
|
155
|
+
const teardownActive = () => {
|
|
156
|
+
activeUnsub?.();
|
|
157
|
+
activeUnsub = undefined;
|
|
158
|
+
};
|
|
102
159
|
|
|
103
|
-
const
|
|
160
|
+
const sp00ky = db.getSp00ky();
|
|
104
161
|
|
|
105
162
|
const initQuery = async (
|
|
106
|
-
query: FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
163
|
+
query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
|
|
164
|
+
myRun: number
|
|
107
165
|
) => {
|
|
108
166
|
const { hash } = await query.run();
|
|
167
|
+
// A newer query identity (or disposal) won the race while we awaited run().
|
|
168
|
+
if (myRun !== runId) return;
|
|
169
|
+
activeHash = hash;
|
|
109
170
|
setError(undefined);
|
|
110
171
|
|
|
111
172
|
let isFirstCall = true;
|
|
112
|
-
const unsub = await
|
|
173
|
+
const unsub = await sp00ky.subscribe(
|
|
113
174
|
hash,
|
|
114
175
|
(e) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
176
|
+
const queryData = (query.isOne ? e[0] : e) as TData;
|
|
177
|
+
// Merge into the store by record id: unchanged rows keep their identity,
|
|
178
|
+
// changed rows update in place. Replaces wholesale for `one()`/null.
|
|
179
|
+
// Time the reconcile → report as the "frontend" phase for DevTools/MCP.
|
|
180
|
+
const reconcileStart = performance.now();
|
|
181
|
+
setState('value', reconcile(queryData as any, { key: 'id' }));
|
|
182
|
+
// Notify coarse `data()` readers (see the `version` note above): reconcile
|
|
183
|
+
// keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
|
|
184
|
+
// on add/remove/reorder.
|
|
185
|
+
setVersion((v) => v + 1);
|
|
186
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
|
|
117
187
|
// The first (immediate) callback with no data likely means the local DB
|
|
118
188
|
// hasn't synced yet — don't mark as fetched so UI shows loading state
|
|
119
|
-
const hasData = query.isOne ?
|
|
189
|
+
const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
|
|
120
190
|
if (!isFirstCall || hasData) {
|
|
121
191
|
setIsFetched(true);
|
|
122
192
|
}
|
|
@@ -125,7 +195,25 @@ export function useQuery<
|
|
|
125
195
|
{ immediate: true }
|
|
126
196
|
);
|
|
127
197
|
|
|
128
|
-
|
|
198
|
+
// Mirror the query's fetch status so the UI can show a "loading more"
|
|
199
|
+
// state while the sync engine pulls missing records in the background.
|
|
200
|
+
const unsubStatus = sp00ky.subscribeQueryStatus(
|
|
201
|
+
hash,
|
|
202
|
+
(status) => setIsFetching(status === 'fetching'),
|
|
203
|
+
{ immediate: true }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const teardown = () => {
|
|
207
|
+
unsub();
|
|
208
|
+
unsubStatus();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Superseded while awaiting subscribe()? Don't leak — tear down immediately.
|
|
212
|
+
if (myRun !== runId) {
|
|
213
|
+
teardown();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
activeUnsub = teardown;
|
|
129
217
|
};
|
|
130
218
|
|
|
131
219
|
createEffect(() => {
|
|
@@ -143,30 +231,57 @@ export function useQuery<
|
|
|
143
231
|
return;
|
|
144
232
|
}
|
|
145
233
|
|
|
146
|
-
//
|
|
147
|
-
|
|
234
|
+
// Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
|
|
235
|
+
// full `JSON.stringify` of the FinalQuery (which walks the whole schema +
|
|
236
|
+
// inner query on every reactive tick and isn't guaranteed stable). When the
|
|
237
|
+
// identity is unchanged we keep the existing subscription alive.
|
|
238
|
+
const queryString = String(query.hash);
|
|
148
239
|
if (queryString === prevQueryString) {
|
|
149
240
|
return;
|
|
150
241
|
}
|
|
151
242
|
prevQueryString = queryString;
|
|
152
243
|
|
|
153
|
-
//
|
|
244
|
+
// New query identity → supersede the previous subscription and start fresh.
|
|
245
|
+
const myRun = ++runId;
|
|
246
|
+
teardownActive();
|
|
154
247
|
setIsFetched(false);
|
|
155
|
-
initQuery(query);
|
|
248
|
+
initQuery(query, myRun);
|
|
249
|
+
});
|
|
156
250
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
251
|
+
// Tear down the live subscription when the hook's owner is disposed. Registered
|
|
252
|
+
// on the hook (component) scope rather than inside the effect, so an effect
|
|
253
|
+
// re-run that early-returns (unchanged query) doesn't clean up the still-valid
|
|
254
|
+
// subscription. Bumping runId also invalidates any in-flight initQuery.
|
|
255
|
+
onCleanup(() => {
|
|
256
|
+
runId++;
|
|
257
|
+
teardownActive();
|
|
258
|
+
// Opt-in: cancel the query once this hook (its last subscriber) is gone.
|
|
259
|
+
// teardownActive() above already removed this hook's callback, so
|
|
260
|
+
// deregisterQuery's refcount guard sees the true remaining-subscriber count.
|
|
261
|
+
if (options?.deregisterOnCleanup && activeHash) {
|
|
262
|
+
sp00ky.deregisterQuery(activeHash);
|
|
263
|
+
}
|
|
161
264
|
});
|
|
162
265
|
|
|
163
266
|
const isLoading = () => {
|
|
164
267
|
return !isFetched() && error() === undefined;
|
|
165
268
|
};
|
|
166
269
|
|
|
270
|
+
// True once the query has delivered a result AND no fetch cycle is in flight
|
|
271
|
+
// (registration + initial sync included — the core holds `fetching` across
|
|
272
|
+
// the whole registration and flushes debounced results before flipping back
|
|
273
|
+
// to idle). While settled, the results are authoritative: a windowed query
|
|
274
|
+
// returning fewer rows than its LIMIT really is the end of the list, so
|
|
275
|
+
// virtualized lists may size themselves to it without the scrollbar jumping
|
|
276
|
+
// when a still-syncing window transiently reports short. Resets to false
|
|
277
|
+
// whenever the query identity changes.
|
|
278
|
+
const isSettled = () => isFetched() && !isFetching();
|
|
279
|
+
|
|
167
280
|
return {
|
|
168
281
|
data,
|
|
169
282
|
error,
|
|
170
283
|
isLoading,
|
|
284
|
+
isFetching,
|
|
285
|
+
isSettled,
|
|
171
286
|
};
|
|
172
287
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface UseStorageStatus {
|
|
6
|
+
/** Full durability snapshot; updates reactively. */
|
|
7
|
+
health: Accessor<StorageHealth>;
|
|
8
|
+
/** `'unknown'` | `'persistent'` | `'memory'`. */
|
|
9
|
+
status: Accessor<StorageHealthStatus>;
|
|
10
|
+
/** `true` when the local store survives a reload. */
|
|
11
|
+
isPersistent: Accessor<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* `true` only when durable storage was requested and could NOT be opened, so
|
|
14
|
+
* the dataset is sitting in RAM and local writes die on reload. Drive a
|
|
15
|
+
* warning off this, not off `status`: a store configured as in-memory reports
|
|
16
|
+
* `'memory'` too, and that is a choice rather than a problem.
|
|
17
|
+
*/
|
|
18
|
+
isMemoryFallback: Accessor<boolean>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Observe how durable the LOCAL cache is, for a "no local storage" warning.
|
|
23
|
+
*
|
|
24
|
+
* Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
|
|
25
|
+
* the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
|
|
26
|
+
* second tab of the same app cannot get it and runs in memory instead (the
|
|
27
|
+
* engine retries first, so a closing tab's lock is usually waited out). Must be
|
|
28
|
+
* used within a `<Sp00kyProvider>`.
|
|
29
|
+
*/
|
|
30
|
+
export function useStorageStatus(): UseStorageStatus {
|
|
31
|
+
const db = useDb();
|
|
32
|
+
// subscribeToStorageHealth fires synchronously with the current snapshot, so
|
|
33
|
+
// the signal is correct from the first read; the initial value avoids a flash.
|
|
34
|
+
const [health, setHealth] = createSignal<StorageHealth>(db.storageHealth);
|
|
35
|
+
const unsub = db.subscribeToStorageHealth(setHealth);
|
|
36
|
+
onCleanup(unsub);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
health,
|
|
40
|
+
status: () => health().status,
|
|
41
|
+
isPersistent: () => health().status === 'persistent',
|
|
42
|
+
isMemoryFallback: () => health().fallback,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createSignal, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { useDb } from './context';
|
|
3
|
+
import type { ConnectionState, SyncHealth, SyncHealthStatus } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface UseSyncStatus {
|
|
6
|
+
/** Full health snapshot; updates reactively on every transition. */
|
|
7
|
+
health: Accessor<SyncHealth>;
|
|
8
|
+
/** `'healthy'` | `'degraded'`. */
|
|
9
|
+
status: Accessor<SyncHealthStatus>;
|
|
10
|
+
isHealthy: Accessor<boolean>;
|
|
11
|
+
/** `true` once sync has failed for a sustained run — drive a banner off this. */
|
|
12
|
+
isDegraded: Accessor<boolean>;
|
|
13
|
+
/** `true` once at least one sync round has succeeded this session. */
|
|
14
|
+
everConnected: Accessor<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* `true` only for a real lost connection: degraded AFTER a first successful
|
|
17
|
+
* sync. Stays `false` during the initial "connecting" phase (degraded but
|
|
18
|
+
* never reached the server yet), so an indicator can show nothing until the
|
|
19
|
+
* app has actually connected once.
|
|
20
|
+
*/
|
|
21
|
+
isOffline: Accessor<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* Transport state of the remote WebSocket. Flips the instant the socket
|
|
24
|
+
* drops, unlike `status`, which only degrades after a sustained run of failed
|
|
25
|
+
* sync rounds — so this is what to drive a "reconnecting…" affordance off.
|
|
26
|
+
*/
|
|
27
|
+
connection: Accessor<ConnectionState>;
|
|
28
|
+
/**
|
|
29
|
+
* `true` while the connection is being re-established. Usually still
|
|
30
|
+
* `isHealthy()`: a short reconnect is invisible to sync, and writes made
|
|
31
|
+
* during it are queued locally and pushed once the socket is back.
|
|
32
|
+
*/
|
|
33
|
+
isReconnecting: Accessor<boolean>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Observe sync health for a "can't reach the server" banner / indicator.
|
|
38
|
+
*
|
|
39
|
+
* Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
|
|
40
|
+
* remote 500 on query registration, a dropped socket) are absorbed by the
|
|
41
|
+
* retry and never flip this; `isDegraded()` only goes true once failures
|
|
42
|
+
* persist for the configured number of consecutive rounds (sp00ky core config
|
|
43
|
+
* `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
|
|
44
|
+
* the next successful round. Must be used within a `<Sp00kyProvider>`.
|
|
45
|
+
*/
|
|
46
|
+
export function useSyncStatus(): UseSyncStatus {
|
|
47
|
+
const db = useDb();
|
|
48
|
+
// subscribeToSyncHealth fires synchronously with the current status, so the
|
|
49
|
+
// signal is correct from first read; the initial value just avoids a flash.
|
|
50
|
+
const [health, setHealth] = createSignal<SyncHealth>(db.syncHealth);
|
|
51
|
+
const unsub = db.subscribeToSyncHealth(setHealth);
|
|
52
|
+
onCleanup(unsub);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
health,
|
|
56
|
+
status: () => health().status,
|
|
57
|
+
isHealthy: () => health().status === 'healthy',
|
|
58
|
+
isDegraded: () => health().status === 'degraded',
|
|
59
|
+
everConnected: () => health().everConnected,
|
|
60
|
+
isOffline: () => health().status === 'degraded' && health().everConnected,
|
|
61
|
+
connection: () => health().connection,
|
|
62
|
+
isReconnecting: () => health().connection === 'reconnecting',
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { Surreal } from 'surrealdb';
|
|
2
1
|
import type { SyncedDb } from '../index';
|
|
3
|
-
import { GenericSchema } from '../lib/models';
|
|
4
|
-
import type {
|
|
2
|
+
import type { GenericSchema } from '../lib/models';
|
|
3
|
+
import type { Sp00kyConfig } from '@spooky-sync/core';
|
|
5
4
|
import type { SchemaStructure, TableNames, GetTable, TableModel } from '@spooky-sync/query-builder';
|
|
6
5
|
|
|
7
6
|
/**
|
|
@@ -44,7 +43,7 @@ export type InferRelationshipsFromConst<S extends SchemaStructure, Schema extend
|
|
|
44
43
|
// Prettify helper expands types for better intellisense
|
|
45
44
|
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
46
45
|
|
|
47
|
-
export type SyncedDbConfig<S extends SchemaStructure> = Prettify<
|
|
46
|
+
export type SyncedDbConfig<S extends SchemaStructure> = Prettify<Sp00kyConfig<S>>;
|
|
48
47
|
|
|
49
48
|
// export interface LocalDbConfig {
|
|
50
49
|
// name: string;
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spooky-solid
|
|
3
|
-
description: >-
|
|
4
|
-
SolidJS integration for the Spooky reactive local-first SurrealDB framework.
|
|
5
|
-
Use when setting up SpookyProvider, using useQuery for reactive data, building
|
|
6
|
-
queries with QueryBuilder in SolidJS components, handling mutations, auth,
|
|
7
|
-
file uploads/downloads, or working with Spooky types like Model and RecordId.
|
|
8
|
-
metadata:
|
|
9
|
-
author: spooky-sync
|
|
10
|
-
version: "0.0.1"
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
# Spooky SolidJS Client
|
|
14
|
-
|
|
15
|
-
`@spooky-sync/client-solid` provides SolidJS bindings for the Spooky framework. It wraps `@spooky-sync/core` with a context provider, reactive `useQuery` hook, and file operation hooks.
|
|
16
|
-
|
|
17
|
-
## Setup
|
|
18
|
-
|
|
19
|
-
```tsx
|
|
20
|
-
import { SpookyProvider } from '@spooky-sync/client-solid';
|
|
21
|
-
import { schema } from './generated/schema';
|
|
22
|
-
import schemaSurql from './generated/schema.surql?raw';
|
|
23
|
-
|
|
24
|
-
function App() {
|
|
25
|
-
return (
|
|
26
|
-
<SpookyProvider
|
|
27
|
-
config={{
|
|
28
|
-
database: {
|
|
29
|
-
endpoint: 'ws://localhost:8000',
|
|
30
|
-
namespace: 'my_ns',
|
|
31
|
-
database: 'my_db',
|
|
32
|
-
store: 'indexeddb',
|
|
33
|
-
},
|
|
34
|
-
schema,
|
|
35
|
-
schemaSurql,
|
|
36
|
-
logLevel: 'info',
|
|
37
|
-
}}
|
|
38
|
-
fallback={<div>Loading database...</div>}
|
|
39
|
-
onReady={(db) => console.log('DB ready')}
|
|
40
|
-
onError={(err) => console.error('DB failed', err)}
|
|
41
|
-
>
|
|
42
|
-
<MyApp />
|
|
43
|
-
</SpookyProvider>
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### SpookyProvider Props
|
|
49
|
-
|
|
50
|
-
| Prop | Type | Description |
|
|
51
|
-
|------|------|-------------|
|
|
52
|
-
| `config` | `SyncedDbConfig<S>` | Same as `SpookyConfig` from core |
|
|
53
|
-
| `fallback` | `JSX.Element` | Shown while the database is initializing |
|
|
54
|
-
| `onReady` | `(db: SyncedDb<S>) => void` | Called when initialization succeeds |
|
|
55
|
-
| `onError` | `(error: Error) => void` | Called if initialization fails |
|
|
56
|
-
| `children` | `JSX.Element` | App content, rendered after init |
|
|
57
|
-
|
|
58
|
-
## useQuery
|
|
59
|
-
|
|
60
|
-
The primary hook for reactive data fetching. Queries automatically re-subscribe when inputs change.
|
|
61
|
-
|
|
62
|
-
### Context-based usage (recommended)
|
|
63
|
-
|
|
64
|
-
```tsx
|
|
65
|
-
import { useQuery } from '@spooky-sync/client-solid';
|
|
66
|
-
import { QueryBuilder } from '@spooky-sync/query-builder';
|
|
67
|
-
import { schema } from './generated/schema';
|
|
68
|
-
|
|
69
|
-
function PostList() {
|
|
70
|
-
const db = useDb();
|
|
71
|
-
|
|
72
|
-
// Static query
|
|
73
|
-
const posts = useQuery(
|
|
74
|
-
db.query('post').orderBy('createdAt', 'desc').limit(20).build()
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<Show when={!posts.isLoading()} fallback={<div>Loading...</div>}>
|
|
79
|
-
<For each={posts.data()}>
|
|
80
|
-
{(post) => <div>{post.title}</div>}
|
|
81
|
-
</For>
|
|
82
|
-
</Show>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
### Reactive queries (function form)
|
|
88
|
-
|
|
89
|
-
Wrap the query in a function to make it reactive to signal changes:
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
function UserPosts(props: { userId: string }) {
|
|
93
|
-
const db = useDb();
|
|
94
|
-
|
|
95
|
-
// Query re-runs when props.userId changes
|
|
96
|
-
const posts = useQuery(
|
|
97
|
-
() => db.query('post')
|
|
98
|
-
.where({ author: props.userId })
|
|
99
|
-
.related('author')
|
|
100
|
-
.build()
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
return <For each={posts.data()}>{(post) => <div>{post.title}</div>}</For>;
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### Conditional queries
|
|
108
|
-
|
|
109
|
-
Use the `enabled` option to conditionally run queries:
|
|
110
|
-
|
|
111
|
-
```tsx
|
|
112
|
-
const [userId, setUserId] = createSignal<string | null>(null);
|
|
113
|
-
|
|
114
|
-
const user = useQuery(
|
|
115
|
-
() => userId()
|
|
116
|
-
? db.query('user').where({ id: userId()! }).one().build()
|
|
117
|
-
: undefined,
|
|
118
|
-
{ enabled: () => userId() !== null }
|
|
119
|
-
);
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### Return value
|
|
123
|
-
|
|
124
|
-
| Property | Type | Description |
|
|
125
|
-
|----------|------|-------------|
|
|
126
|
-
| `data` | `() => T \| undefined` | Reactive accessor for query results |
|
|
127
|
-
| `error` | `() => Error \| undefined` | Reactive accessor for errors |
|
|
128
|
-
| `isLoading` | `() => boolean` | `true` until first data arrives |
|
|
129
|
-
|
|
130
|
-
### Explicit db overload
|
|
131
|
-
|
|
132
|
-
You can also pass the `SyncedDb` instance directly (legacy):
|
|
133
|
-
|
|
134
|
-
```tsx
|
|
135
|
-
const posts = useQuery(db, db.query('post').build());
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## useDb
|
|
139
|
-
|
|
140
|
-
Access the `SyncedDb` instance from context:
|
|
141
|
-
|
|
142
|
-
```tsx
|
|
143
|
-
import { useDb } from '@spooky-sync/client-solid';
|
|
144
|
-
|
|
145
|
-
function MyComponent() {
|
|
146
|
-
const db = useDb();
|
|
147
|
-
// db.query(), db.create(), db.update(), db.delete(), db.auth, etc.
|
|
148
|
-
}
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
## Mutations
|
|
152
|
-
|
|
153
|
-
Use the `SyncedDb` instance (from `useDb()`) for mutations:
|
|
154
|
-
|
|
155
|
-
```tsx
|
|
156
|
-
const db = useDb();
|
|
157
|
-
|
|
158
|
-
// Create
|
|
159
|
-
await db.create('post:abc', { title: 'Hello', body: 'World', author: 'user:alice' });
|
|
160
|
-
|
|
161
|
-
// Update
|
|
162
|
-
await db.update('post', 'post:abc', { title: 'Updated' });
|
|
163
|
-
|
|
164
|
-
// Update with debounce
|
|
165
|
-
await db.update('post', 'post:abc', { body: newText }, {
|
|
166
|
-
debounced: { key: 'recordId_x_fields', delay: 300 },
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Delete
|
|
170
|
-
await db.delete('post', 'post:abc');
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
## Authentication
|
|
174
|
-
|
|
175
|
-
```tsx
|
|
176
|
-
const db = useDb();
|
|
177
|
-
|
|
178
|
-
await db.auth.signUp('user_access', { email, password, name });
|
|
179
|
-
await db.auth.signIn('user_access', { email, password });
|
|
180
|
-
await db.auth.signOut();
|
|
181
|
-
|
|
182
|
-
// Subscribe to auth state
|
|
183
|
-
const unsub = db.auth.subscribe((userId) => { ... });
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## File Upload & Download
|
|
187
|
-
|
|
188
|
-
See [references/file-hooks.md](references/file-hooks.md) for details.
|
|
189
|
-
|
|
190
|
-
```tsx
|
|
191
|
-
import { useFileUpload, useDownloadFile } from '@spooky-sync/client-solid';
|
|
192
|
-
|
|
193
|
-
// Upload
|
|
194
|
-
const { upload, isUploading, error } = useFileUpload('avatars');
|
|
195
|
-
await upload('alice/photo.png', file);
|
|
196
|
-
|
|
197
|
-
// Download (reactive)
|
|
198
|
-
const { url, isLoading } = useDownloadFile('avatars', () => user()?.avatarPath);
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
## Backend Runs
|
|
202
|
-
|
|
203
|
-
```tsx
|
|
204
|
-
const db = useDb();
|
|
205
|
-
await db.run('email', '/send', { to: 'alice@example.com', subject: 'Hi', body: 'Hello' });
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
## Key Re-exports
|
|
209
|
-
|
|
210
|
-
The package re-exports commonly needed types:
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
import { RecordId, Uuid } from '@spooky-sync/client-solid';
|
|
214
|
-
import type {
|
|
215
|
-
Model, GenericModel, QueryResult, TableModel, TableNames, GetTable,
|
|
216
|
-
} from '@spooky-sync/client-solid';
|
|
217
|
-
```
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { createSignal, onMount, createComponent, createMemo, JSX, mergeProps } from 'solid-js';
|
|
2
|
-
import type { SchemaStructure } from '@spooky/query-builder';
|
|
3
|
-
import type { SyncedDbConfig } from '../types';
|
|
4
|
-
import { SyncedDb } from '../index';
|
|
5
|
-
import { SpookyContext } from './context';
|
|
6
|
-
|
|
7
|
-
export interface SpookyProviderProps<S extends SchemaStructure> {
|
|
8
|
-
config: SyncedDbConfig<S>;
|
|
9
|
-
fallback?: JSX.Element;
|
|
10
|
-
onError?: (error: Error) => void;
|
|
11
|
-
onReady?: (db: SyncedDb<S>) => void;
|
|
12
|
-
children: JSX.Element;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function SpookyProvider<S extends SchemaStructure>(
|
|
16
|
-
props: SpookyProviderProps<S>
|
|
17
|
-
): JSX.Element {
|
|
18
|
-
const merged = mergeProps(
|
|
19
|
-
{
|
|
20
|
-
fallback: undefined as JSX.Element | undefined,
|
|
21
|
-
},
|
|
22
|
-
props
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
|
|
26
|
-
|
|
27
|
-
onMount(async () => {
|
|
28
|
-
try {
|
|
29
|
-
const instance = new SyncedDb<S>(merged.config);
|
|
30
|
-
await instance.init();
|
|
31
|
-
setDb(() => instance);
|
|
32
|
-
merged.onReady?.(instance);
|
|
33
|
-
} catch (e) {
|
|
34
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
35
|
-
if (merged.onError) {
|
|
36
|
-
merged.onError(error);
|
|
37
|
-
} else {
|
|
38
|
-
console.error('SpookyProvider: Failed to initialize database', error);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const content = createMemo(() => {
|
|
44
|
-
const instance = db();
|
|
45
|
-
if (!instance) return merged.fallback;
|
|
46
|
-
return createComponent(SpookyContext.Provider, {
|
|
47
|
-
value: instance,
|
|
48
|
-
get children() {
|
|
49
|
-
return merged.children;
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
return content as unknown as JSX.Element;
|
|
55
|
-
}
|