@spooky-sync/client-solid 0.0.1-canary.8 → 0.0.1-canary.81
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 +66 -0
- package/dist/index.cjs +177 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +57 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +176 -66
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/skills/sp00ky-solid/SKILL.md +264 -0
- package/skills/sp00ky-solid/references/file-hooks.md +112 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +84 -55
- package/src/lib/{SpookyProvider.ts → Sp00kyProvider.ts} +9 -7
- package/src/lib/context.ts +3 -3
- package/src/lib/models.ts +1 -1
- package/src/lib/use-crdt-field.ts +68 -0
- package/src/lib/use-download-file.ts +2 -2
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +2 -1
- package/src/lib/use-query.ts +130 -28
- package/src/types/index.ts +3 -4
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,12 @@ 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
|
+
};
|
|
40
56
|
|
|
41
57
|
// Overload: explicit db (backward-compatible)
|
|
42
58
|
export function useQuery<
|
|
@@ -50,7 +66,12 @@ export function useQuery<
|
|
|
50
66
|
db: SyncedDb<S>,
|
|
51
67
|
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
52
68
|
options?: QueryOptions,
|
|
53
|
-
): {
|
|
69
|
+
): {
|
|
70
|
+
data: () => TData | undefined;
|
|
71
|
+
error: () => Error | undefined;
|
|
72
|
+
isLoading: () => boolean;
|
|
73
|
+
isFetching: () => boolean;
|
|
74
|
+
};
|
|
54
75
|
|
|
55
76
|
// Implementation
|
|
56
77
|
export function useQuery<
|
|
@@ -82,11 +103,11 @@ export function useQuery<
|
|
|
82
103
|
options = maybeOptions;
|
|
83
104
|
} else {
|
|
84
105
|
// Context-based overload: useQuery(query, options?)
|
|
85
|
-
const contextDb = useContext(
|
|
106
|
+
const contextDb = useContext(Sp00kyContext);
|
|
86
107
|
if (!contextDb) {
|
|
87
108
|
throw new Error(
|
|
88
|
-
'useQuery: No db argument provided and no
|
|
89
|
-
'Either pass a SyncedDb instance or wrap your app in <
|
|
109
|
+
'useQuery: No db argument provided and no Sp00kyContext found. ' +
|
|
110
|
+
'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.'
|
|
90
111
|
);
|
|
91
112
|
}
|
|
92
113
|
db = contextDb as SyncedDb<S>;
|
|
@@ -94,29 +115,76 @@ export function useQuery<
|
|
|
94
115
|
options = queryOrOptions as QueryOptions | undefined;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
const [data, setData] = createSignal<TData | undefined>(undefined);
|
|
98
118
|
const [error, setError] = createSignal<Error | undefined>(undefined);
|
|
99
119
|
const [isFetched, setIsFetched] = createSignal(false);
|
|
100
|
-
const [
|
|
120
|
+
const [isFetching, setIsFetching] = createSignal(false);
|
|
121
|
+
// Results live in a store (not a signal) so consecutive live-query emissions
|
|
122
|
+
// are merged with `reconcile`: unchanged rows keep their object identity and
|
|
123
|
+
// changed rows are mutated in place. That keeps Solid's reference-keyed `<For>`
|
|
124
|
+
// rows — and any `useQuery` subscriptions mounted inside them — alive across
|
|
125
|
+
// updates, instead of tearing every row down and re-registering its queries.
|
|
126
|
+
const [state, setState] = createStore<{ value: TData | undefined }>({ value: undefined });
|
|
127
|
+
// `reconcile` (below) merges each emission into `state.value` IN PLACE, keeping
|
|
128
|
+
// the array reference stable. That's ideal for granular per-row reactivity, but
|
|
129
|
+
// it means a *coarse* reader of `data()` — `<For each={data()}>`, or an effect
|
|
130
|
+
// that copies the whole array elsewhere (e.g. GameList's windowed store) — is
|
|
131
|
+
// NOT re-run when rows are added/removed/reordered within a same-length result
|
|
132
|
+
// (the classic case: deleting a row in a windowed list shifts the next one in,
|
|
133
|
+
// so length stays 50 and the array ref never changes). Bump a version on every
|
|
134
|
+
// emission and read it in `data()` so every consumer re-runs on any change while
|
|
135
|
+
// reconcile still preserves row identity underneath.
|
|
136
|
+
const [version, setVersion] = createSignal(0);
|
|
137
|
+
const data = () => {
|
|
138
|
+
version();
|
|
139
|
+
return state.value;
|
|
140
|
+
};
|
|
141
|
+
|
|
101
142
|
let prevQueryString: string | undefined;
|
|
143
|
+
// Monotonic token for each subscription generation. Bumped whenever the query
|
|
144
|
+
// identity changes or the hook is disposed, so a slow async `initQuery`
|
|
145
|
+
// continuation can detect it was superseded and avoid installing a stale (and
|
|
146
|
+
// leaked) subscription.
|
|
147
|
+
let runId = 0;
|
|
148
|
+
let activeUnsub: (() => void) | undefined;
|
|
149
|
+
// The hash of the currently-installed subscription, for opt-in deregister on
|
|
150
|
+
// dispose (see `deregisterOnCleanup`).
|
|
151
|
+
let activeHash: string | undefined;
|
|
152
|
+
|
|
153
|
+
const teardownActive = () => {
|
|
154
|
+
activeUnsub?.();
|
|
155
|
+
activeUnsub = undefined;
|
|
156
|
+
};
|
|
102
157
|
|
|
103
|
-
const
|
|
158
|
+
const sp00ky = db.getSp00ky();
|
|
104
159
|
|
|
105
160
|
const initQuery = async (
|
|
106
|
-
query: FinalQuery<S, TableName, T, RelatedFields, IsOne,
|
|
161
|
+
query: FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>,
|
|
162
|
+
myRun: number
|
|
107
163
|
) => {
|
|
108
164
|
const { hash } = await query.run();
|
|
165
|
+
// A newer query identity (or disposal) won the race while we awaited run().
|
|
166
|
+
if (myRun !== runId) return;
|
|
167
|
+
activeHash = hash;
|
|
109
168
|
setError(undefined);
|
|
110
169
|
|
|
111
170
|
let isFirstCall = true;
|
|
112
|
-
const unsub = await
|
|
171
|
+
const unsub = await sp00ky.subscribe(
|
|
113
172
|
hash,
|
|
114
173
|
(e) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
174
|
+
const queryData = (query.isOne ? e[0] : e) as TData;
|
|
175
|
+
// Merge into the store by record id: unchanged rows keep their identity,
|
|
176
|
+
// changed rows update in place. Replaces wholesale for `one()`/null.
|
|
177
|
+
// Time the reconcile → report as the "frontend" phase for DevTools/MCP.
|
|
178
|
+
const reconcileStart = performance.now();
|
|
179
|
+
setState('value', reconcile(queryData as any, { key: 'id' }));
|
|
180
|
+
// Notify coarse `data()` readers (see the `version` note above): reconcile
|
|
181
|
+
// keeps the array ref stable, so this is what re-runs `<For>`/copy-effects
|
|
182
|
+
// on add/remove/reorder.
|
|
183
|
+
setVersion((v) => v + 1);
|
|
184
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
|
|
117
185
|
// The first (immediate) callback with no data likely means the local DB
|
|
118
186
|
// hasn't synced yet — don't mark as fetched so UI shows loading state
|
|
119
|
-
const hasData = query.isOne ?
|
|
187
|
+
const hasData = query.isOne ? queryData !== null && queryData !== undefined : (e as any[]).length > 0;
|
|
120
188
|
if (!isFirstCall || hasData) {
|
|
121
189
|
setIsFetched(true);
|
|
122
190
|
}
|
|
@@ -125,7 +193,25 @@ export function useQuery<
|
|
|
125
193
|
{ immediate: true }
|
|
126
194
|
);
|
|
127
195
|
|
|
128
|
-
|
|
196
|
+
// Mirror the query's fetch status so the UI can show a "loading more"
|
|
197
|
+
// state while the sync engine pulls missing records in the background.
|
|
198
|
+
const unsubStatus = sp00ky.subscribeQueryStatus(
|
|
199
|
+
hash,
|
|
200
|
+
(status) => setIsFetching(status === 'fetching'),
|
|
201
|
+
{ immediate: true }
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const teardown = () => {
|
|
205
|
+
unsub();
|
|
206
|
+
unsubStatus();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Superseded while awaiting subscribe()? Don't leak — tear down immediately.
|
|
210
|
+
if (myRun !== runId) {
|
|
211
|
+
teardown();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
activeUnsub = teardown;
|
|
129
215
|
};
|
|
130
216
|
|
|
131
217
|
createEffect(() => {
|
|
@@ -143,21 +229,36 @@ export function useQuery<
|
|
|
143
229
|
return;
|
|
144
230
|
}
|
|
145
231
|
|
|
146
|
-
//
|
|
147
|
-
|
|
232
|
+
// Dedup on the query's stable identity hash (cyrb53 of surql + vars), not a
|
|
233
|
+
// full `JSON.stringify` of the FinalQuery (which walks the whole schema +
|
|
234
|
+
// inner query on every reactive tick and isn't guaranteed stable). When the
|
|
235
|
+
// identity is unchanged we keep the existing subscription alive.
|
|
236
|
+
const queryString = String(query.hash);
|
|
148
237
|
if (queryString === prevQueryString) {
|
|
149
238
|
return;
|
|
150
239
|
}
|
|
151
240
|
prevQueryString = queryString;
|
|
152
241
|
|
|
153
|
-
//
|
|
242
|
+
// New query identity → supersede the previous subscription and start fresh.
|
|
243
|
+
const myRun = ++runId;
|
|
244
|
+
teardownActive();
|
|
154
245
|
setIsFetched(false);
|
|
155
|
-
initQuery(query);
|
|
246
|
+
initQuery(query, myRun);
|
|
247
|
+
});
|
|
156
248
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
249
|
+
// Tear down the live subscription when the hook's owner is disposed. Registered
|
|
250
|
+
// on the hook (component) scope rather than inside the effect, so an effect
|
|
251
|
+
// re-run that early-returns (unchanged query) doesn't clean up the still-valid
|
|
252
|
+
// subscription. Bumping runId also invalidates any in-flight initQuery.
|
|
253
|
+
onCleanup(() => {
|
|
254
|
+
runId++;
|
|
255
|
+
teardownActive();
|
|
256
|
+
// Opt-in: cancel the query once this hook (its last subscriber) is gone.
|
|
257
|
+
// teardownActive() above already removed this hook's callback, so
|
|
258
|
+
// deregisterQuery's refcount guard sees the true remaining-subscriber count.
|
|
259
|
+
if (options?.deregisterOnCleanup && activeHash) {
|
|
260
|
+
sp00ky.deregisterQuery(activeHash);
|
|
261
|
+
}
|
|
161
262
|
});
|
|
162
263
|
|
|
163
264
|
const isLoading = () => {
|
|
@@ -168,5 +269,6 @@ export function useQuery<
|
|
|
168
269
|
data,
|
|
169
270
|
error,
|
|
170
271
|
isLoading,
|
|
272
|
+
isFetching,
|
|
171
273
|
};
|
|
172
274
|
}
|
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;
|