@spooky-sync/client-solid2 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 +81 -0
- package/QUICK_START.md +129 -0
- package/README.md +19 -0
- package/dist/index.cjs +1290 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +670 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +670 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1266 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/skills/sp00ky-solid2/SKILL.md +71 -0
- package/src/index.ts +410 -0
- package/src/lib/Blurhash.ts +64 -0
- package/src/lib/BucketImage.ts +170 -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 +397 -0
- package/src/lib/__tests__/rc-semantics.test.ts +393 -0
- package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +116 -0
- package/src/lib/create-query.ts +388 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/merge-rows.test.ts +92 -0
- package/src/lib/merge-rows.ts +72 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +82 -0
- package/src/lib/use-bucket-image.ts +108 -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-activity.ts +99 -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 +20 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnSchema,
|
|
3
|
+
FinalQuery,
|
|
4
|
+
SchemaStructure,
|
|
5
|
+
TableNames,
|
|
6
|
+
} from '@spooky-sync/query-builder';
|
|
7
|
+
import { createEffect } from 'solid-js';
|
|
8
|
+
import { SyncedDb } from '..';
|
|
9
|
+
import type {
|
|
10
|
+
Sp00kyQueryResultPromise,
|
|
11
|
+
PreloadOptions as CorePreloadOptions,
|
|
12
|
+
} from '@spooky-sync/core';
|
|
13
|
+
import { useDb } from './context';
|
|
14
|
+
|
|
15
|
+
type PreloadArg<
|
|
16
|
+
S extends SchemaStructure,
|
|
17
|
+
TableName extends TableNames<S>,
|
|
18
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
19
|
+
RelatedFields extends Record<string, any>,
|
|
20
|
+
IsOne extends boolean,
|
|
21
|
+
> =
|
|
22
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
23
|
+
| (() =>
|
|
24
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
25
|
+
| null
|
|
26
|
+
| undefined);
|
|
27
|
+
|
|
28
|
+
type PreloadOptions = CorePreloadOptions & {
|
|
29
|
+
/** Only preload while this returns true (defaults to always). */
|
|
30
|
+
enabled?: () => boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Overload: context-based (no explicit db)
|
|
34
|
+
export function createPreload<
|
|
35
|
+
S extends SchemaStructure,
|
|
36
|
+
TableName extends TableNames<S>,
|
|
37
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
38
|
+
RelatedFields extends Record<string, any>,
|
|
39
|
+
IsOne extends boolean,
|
|
40
|
+
>(
|
|
41
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
42
|
+
options?: PreloadOptions
|
|
43
|
+
): void;
|
|
44
|
+
|
|
45
|
+
// Overload: explicit db
|
|
46
|
+
export function createPreload<
|
|
47
|
+
S extends SchemaStructure,
|
|
48
|
+
TableName extends TableNames<S>,
|
|
49
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
50
|
+
RelatedFields extends Record<string, any>,
|
|
51
|
+
IsOne extends boolean,
|
|
52
|
+
>(
|
|
53
|
+
db: SyncedDb<S>,
|
|
54
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
55
|
+
options?: PreloadOptions
|
|
56
|
+
): void;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
|
|
60
|
+
* function so it tracks reactive deps), dedupes on the query's stable identity
|
|
61
|
+
* hash, and registers it via `db.preload`: a live query nobody subscribes to,
|
|
62
|
+
* evicted a ttl after it was registered unless a view mounts it first. Nothing
|
|
63
|
+
* to clean up here.
|
|
64
|
+
*
|
|
65
|
+
* Typical use: inside a list row, preload the detail query the user is likely
|
|
66
|
+
* to open next, so navigation paints from cache instead of the network.
|
|
67
|
+
*/
|
|
68
|
+
export function createPreload<
|
|
69
|
+
S extends SchemaStructure,
|
|
70
|
+
TableName extends TableNames<S>,
|
|
71
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
72
|
+
RelatedFields extends Record<string, any>,
|
|
73
|
+
IsOne extends boolean,
|
|
74
|
+
>(
|
|
75
|
+
dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
76
|
+
queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
|
|
77
|
+
maybeOptions?: PreloadOptions
|
|
78
|
+
): void {
|
|
79
|
+
let db: SyncedDb<S>;
|
|
80
|
+
let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
81
|
+
let options: PreloadOptions | undefined;
|
|
82
|
+
|
|
83
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
84
|
+
db = dbOrQuery;
|
|
85
|
+
finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
86
|
+
options = maybeOptions;
|
|
87
|
+
} else {
|
|
88
|
+
db = useDb<S>();
|
|
89
|
+
finalQuery = dbOrQuery;
|
|
90
|
+
options = queryOrOptions as PreloadOptions | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let prevHash: number | undefined;
|
|
94
|
+
|
|
95
|
+
// Two-arg Solid 2 effect: compute resolves the query (tracking its reactive
|
|
96
|
+
// deps) and dedupes on the identity hash; the untracked apply fires the
|
|
97
|
+
// preload. Returning `undefined` from compute skips nothing — apply guards.
|
|
98
|
+
createEffect(
|
|
99
|
+
() => {
|
|
100
|
+
if (!(options?.enabled?.() ?? true)) return undefined;
|
|
101
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
102
|
+
if (!query) return undefined;
|
|
103
|
+
// Dedupe on the query's stable identity hash so a reactive re-run with
|
|
104
|
+
// an unchanged query doesn't refetch (the core also dedupes per session).
|
|
105
|
+
if (query.hash === prevHash) return undefined;
|
|
106
|
+
prevHash = query.hash;
|
|
107
|
+
return query;
|
|
108
|
+
},
|
|
109
|
+
(query) => {
|
|
110
|
+
if (!query) return;
|
|
111
|
+
void db
|
|
112
|
+
.getSp00ky()
|
|
113
|
+
.preload(query, { signal: options?.signal });
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnSchema,
|
|
3
|
+
FinalQuery,
|
|
4
|
+
SchemaStructure,
|
|
5
|
+
TableNames,
|
|
6
|
+
QueryResult,
|
|
7
|
+
} from '@spooky-sync/query-builder';
|
|
8
|
+
import {
|
|
9
|
+
createEffect,
|
|
10
|
+
createMemo,
|
|
11
|
+
createSignal,
|
|
12
|
+
createStore,
|
|
13
|
+
onCleanup,
|
|
14
|
+
type Accessor,
|
|
15
|
+
} from 'solid-js';
|
|
16
|
+
import { SyncedDb } from '..';
|
|
17
|
+
import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
|
|
18
|
+
import { useDb } from './context';
|
|
19
|
+
import { mergeRows } from './merge-rows';
|
|
20
|
+
|
|
21
|
+
type QueryArg<
|
|
22
|
+
S extends SchemaStructure,
|
|
23
|
+
TableName extends TableNames<S>,
|
|
24
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
25
|
+
RelatedFields extends Record<string, any>,
|
|
26
|
+
IsOne extends boolean,
|
|
27
|
+
> =
|
|
28
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
29
|
+
| (() =>
|
|
30
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
31
|
+
| null
|
|
32
|
+
| undefined);
|
|
33
|
+
|
|
34
|
+
export type QueryOptions = {
|
|
35
|
+
enabled?: () => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Tear down the query (remote `_00_query` view + local WASM view) when this
|
|
38
|
+
* hook is disposed and no other subscriber remains, instead of keeping it
|
|
39
|
+
* resident for cheap re-subscription. Use for viewport-windowed lists that
|
|
40
|
+
* mount/unmount a query per scroll window and want off-screen windows
|
|
41
|
+
* cancelled. Trade-off: scrolling back to a torn-down window re-registers it.
|
|
42
|
+
*/
|
|
43
|
+
deregisterOnCleanup?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CreateQueryResult<TData> = {
|
|
47
|
+
/**
|
|
48
|
+
* Reactive result. Never suspends and never throws: born as an empty
|
|
49
|
+
* committed value (`[]` / `null`) and reconciled in place (keyed by `id`) on
|
|
50
|
+
* every live emission — unchanged rows keep identity, and coarse readers
|
|
51
|
+
* (`<For>`) are notified on add/remove/reorder.
|
|
52
|
+
*/
|
|
53
|
+
data: Accessor<TData>;
|
|
54
|
+
/**
|
|
55
|
+
* Suspending read of the same result for `<Loading>` users: throws Solid's
|
|
56
|
+
* not-ready protocol until the query has delivered its first real result
|
|
57
|
+
* (or errored, in which case it returns the empty value and `error()` is
|
|
58
|
+
* set). Read this inside a `<Loading>` boundary.
|
|
59
|
+
*/
|
|
60
|
+
ready: Accessor<TData>;
|
|
61
|
+
error: Accessor<Error | undefined>;
|
|
62
|
+
/**
|
|
63
|
+
* True only on a COLD first load: nothing has painted for this identity,
|
|
64
|
+
* the server has not answered yet, and registration has not failed. A query
|
|
65
|
+
* that painted from the local cache is not loading, however long the server
|
|
66
|
+
* takes; a query the server answered with zero rows is not loading either
|
|
67
|
+
* (see `isEmpty`). Resets when the query identity changes.
|
|
68
|
+
*/
|
|
69
|
+
isLoading: Accessor<boolean>;
|
|
70
|
+
/** True while a fetch cycle is in flight (registration, a sync round). */
|
|
71
|
+
isFetching: Accessor<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* True once server membership is known for this identity: a registration or
|
|
74
|
+
* poll landed, or the durable membership of a previous session was read on
|
|
75
|
+
* boot. From then on `data()` is the server's answer, not a cache guess.
|
|
76
|
+
* Latched until the identity changes (or a bucket switch resets it).
|
|
77
|
+
*/
|
|
78
|
+
isAuthoritative: Accessor<boolean>;
|
|
79
|
+
/** Whether `data()` currently holds anything: rows for a list, a row for `one()`. */
|
|
80
|
+
hasData: Accessor<boolean>;
|
|
81
|
+
/**
|
|
82
|
+
* The server answered and the answer is "no rows". The signal to render an
|
|
83
|
+
* empty state on; a bare `!hasData()` cannot tell empty from not-yet-loaded.
|
|
84
|
+
*/
|
|
85
|
+
isEmpty: Accessor<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* True once server membership is known AND no fetch cycle is in flight.
|
|
88
|
+
* While settled, results are authoritative: a windowed query returning fewer
|
|
89
|
+
* rows than its LIMIT really is the end of the list. Not monotonic: a later
|
|
90
|
+
* sync round re-enters `isFetching`; latch it if a verdict must stick.
|
|
91
|
+
*/
|
|
92
|
+
isSettled: Accessor<boolean>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Overload: context-based (no explicit db)
|
|
96
|
+
export function createQuery<
|
|
97
|
+
S extends SchemaStructure,
|
|
98
|
+
TableName extends TableNames<S>,
|
|
99
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
100
|
+
RelatedFields extends Record<string, any>,
|
|
101
|
+
IsOne extends boolean,
|
|
102
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
103
|
+
>(
|
|
104
|
+
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
105
|
+
options?: QueryOptions
|
|
106
|
+
): CreateQueryResult<TData>;
|
|
107
|
+
|
|
108
|
+
// Overload: explicit db
|
|
109
|
+
export function createQuery<
|
|
110
|
+
S extends SchemaStructure,
|
|
111
|
+
TableName extends TableNames<S>,
|
|
112
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
113
|
+
RelatedFields extends Record<string, any>,
|
|
114
|
+
IsOne extends boolean,
|
|
115
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
116
|
+
>(
|
|
117
|
+
db: SyncedDb<S>,
|
|
118
|
+
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
119
|
+
options?: QueryOptions
|
|
120
|
+
): CreateQueryResult<TData>;
|
|
121
|
+
|
|
122
|
+
// Implementation
|
|
123
|
+
export function createQuery<
|
|
124
|
+
S extends SchemaStructure,
|
|
125
|
+
TableName extends TableNames<S>,
|
|
126
|
+
T extends {
|
|
127
|
+
columns: Record<string, ColumnSchema>;
|
|
128
|
+
},
|
|
129
|
+
RelatedFields extends Record<string, any>,
|
|
130
|
+
IsOne extends boolean,
|
|
131
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
132
|
+
>(
|
|
133
|
+
dbOrQuery: SyncedDb<S> | QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
134
|
+
queryOrOptions?: QueryArg<S, TableName, T, RelatedFields, IsOne> | QueryOptions,
|
|
135
|
+
maybeOptions?: QueryOptions
|
|
136
|
+
): CreateQueryResult<TData> {
|
|
137
|
+
let db: SyncedDb<S>;
|
|
138
|
+
let finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>;
|
|
139
|
+
let options: QueryOptions | undefined;
|
|
140
|
+
|
|
141
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
142
|
+
db = dbOrQuery;
|
|
143
|
+
finalQuery = queryOrOptions as QueryArg<S, TableName, T, RelatedFields, IsOne>;
|
|
144
|
+
options = maybeOptions;
|
|
145
|
+
} else {
|
|
146
|
+
db = useDb<S>();
|
|
147
|
+
finalQuery = dbOrQuery;
|
|
148
|
+
options = queryOrOptions as QueryOptions | undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const sp00ky = db.getSp00ky();
|
|
152
|
+
|
|
153
|
+
// Status channel. Written from subscription callbacks and generator
|
|
154
|
+
// continuations, which run outside any tracking scope — `ownedWrite` opts
|
|
155
|
+
// these signals out of Solid 2's owned-scope write guard.
|
|
156
|
+
const [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true });
|
|
157
|
+
// Server membership known for the current identity (core `membershipKnown`).
|
|
158
|
+
const [isAuthoritative, setIsAuthoritative] = createSignal(false, { ownedWrite: true });
|
|
159
|
+
// An emission WITH data has been applied for the current identity. Together
|
|
160
|
+
// with `isAuthoritative` this is what ends the cold-load state: a cached
|
|
161
|
+
// paint ends it without the server, a server answer ends it without rows.
|
|
162
|
+
const [hasPainted, setHasPainted] = createSignal(false, { ownedWrite: true });
|
|
163
|
+
const [isFetching, setIsFetching] = createSignal(false, { ownedWrite: true });
|
|
164
|
+
|
|
165
|
+
// The hash of the currently-installed subscription, for opt-in deregister on
|
|
166
|
+
// dispose (see `deregisterOnCleanup`).
|
|
167
|
+
let activeHash: string | undefined;
|
|
168
|
+
|
|
169
|
+
// Results live in a plain store, written from the engine's subscription
|
|
170
|
+
// callback and reconciled in place keyed by `id`, so unchanged rows keep
|
|
171
|
+
// identity and coarse readers (`<For>`) are notified on add/remove/reorder.
|
|
172
|
+
//
|
|
173
|
+
// NOT an async-generator projection. A live query's generator never returns:
|
|
174
|
+
// it awaits the next emission forever, which leaves its node permanently
|
|
175
|
+
// PENDING. Solid 2 holds a navigation transition until every pending node in
|
|
176
|
+
// the new tree settles, so with a generator here the first client-side
|
|
177
|
+
// navigation into a screen that opens a query never commits: the URL and
|
|
178
|
+
// effects update while the old DOM stays on screen, with no error and no
|
|
179
|
+
// <Loading> fallback that can rescue it. Subscription writes settle
|
|
180
|
+
// immediately, which is what the Solid 1 binding did too.
|
|
181
|
+
//
|
|
182
|
+
// Wrapped in an object so `one()` queries (row object or null) and list
|
|
183
|
+
// queries share one store shape; `mergeRows` keys `value`'s contents by id.
|
|
184
|
+
const [store, setStore] = createStore<{ value: TData }>({ value: null as TData });
|
|
185
|
+
|
|
186
|
+
// Identity of the installed subscription, so a superseded run cannot write
|
|
187
|
+
// results for a query the caller has already moved off.
|
|
188
|
+
let runId = 0;
|
|
189
|
+
|
|
190
|
+
// Woken by the first result or error, for the suspending `ready()` read.
|
|
191
|
+
let readyWaiters: (() => void)[] = [];
|
|
192
|
+
const wakeReady = () => {
|
|
193
|
+
const waiters = readyWaiters;
|
|
194
|
+
readyWaiters = [];
|
|
195
|
+
for (const w of waiters) w();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Tracked: the query identity and the `enabled` gate. Untracked apply: the
|
|
199
|
+
// registration + subscription, whose teardown is the returned cleanup.
|
|
200
|
+
createEffect(
|
|
201
|
+
() => {
|
|
202
|
+
const enabled = options?.enabled?.() ?? true;
|
|
203
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
204
|
+
return { enabled, query };
|
|
205
|
+
},
|
|
206
|
+
({ enabled, query }) => {
|
|
207
|
+
const myRun = ++runId;
|
|
208
|
+
// A new identity starts clean: a previous identity's failure must not
|
|
209
|
+
// keep this one out of its loading state.
|
|
210
|
+
setIsAuthoritative(false);
|
|
211
|
+
setHasPainted(false);
|
|
212
|
+
setError(undefined);
|
|
213
|
+
if (!enabled || !query) return;
|
|
214
|
+
|
|
215
|
+
const cleanups: (() => void)[] = [];
|
|
216
|
+
let disposed = false;
|
|
217
|
+
// `sp00ky.subscribe` resolves its unsubscribe asynchronously; the status
|
|
218
|
+
// subscription returns one directly. Accept both, and if teardown already
|
|
219
|
+
// happened while the promise was in flight, unsubscribe immediately.
|
|
220
|
+
const addCleanup = (c: (() => void) | Promise<(() => void) | undefined> | undefined) => {
|
|
221
|
+
if (!c) return;
|
|
222
|
+
if (typeof c === 'function') {
|
|
223
|
+
if (disposed) c();
|
|
224
|
+
else cleanups.push(c);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
void Promise.resolve(c).then((fn) => {
|
|
228
|
+
if (typeof fn !== 'function') return;
|
|
229
|
+
if (disposed) fn();
|
|
230
|
+
else cleanups.push(fn);
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Registration can fail — the canonical case is the SSP answering 503
|
|
236
|
+
* NOT_READY while it bootstraps. Surface it as `error()` instead of
|
|
237
|
+
* throwing into the graph: the sync scheduler retries the registration
|
|
238
|
+
* underneath, so a transient failure still recovers, and a spinner
|
|
239
|
+
* driven by `isLoading()` resolves via `error()`.
|
|
240
|
+
*/
|
|
241
|
+
query
|
|
242
|
+
.run()
|
|
243
|
+
.then(({ hash }: { hash: string }) => {
|
|
244
|
+
if (disposed || myRun !== runId) return;
|
|
245
|
+
activeHash = hash;
|
|
246
|
+
|
|
247
|
+
// Mirror the query's fetch status so the UI can show a "loading
|
|
248
|
+
// more" state while the sync engine pulls records in the background.
|
|
249
|
+
addCleanup(
|
|
250
|
+
sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === 'fetching'), {
|
|
251
|
+
immediate: true,
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Mirror whether the server's membership is known. This is the
|
|
256
|
+
// channel that ends a cold load for a query whose honest answer is
|
|
257
|
+
// zero rows: the core emits no second `[]` for it, but it does flip
|
|
258
|
+
// authority once the registration (or a poll) has landed. A flip
|
|
259
|
+
// back to false is a bucket switch (a different principal), whose
|
|
260
|
+
// rebind also empties the rows, so the paint latch goes with it.
|
|
261
|
+
addCleanup(
|
|
262
|
+
sp00ky.subscribeQueryAuthority(
|
|
263
|
+
hash,
|
|
264
|
+
(known) => {
|
|
265
|
+
if (disposed || myRun !== runId) return;
|
|
266
|
+
setIsAuthoritative(known);
|
|
267
|
+
if (known) wakeReady();
|
|
268
|
+
else setHasPainted(false);
|
|
269
|
+
},
|
|
270
|
+
{ immediate: true }
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
addCleanup(
|
|
275
|
+
sp00ky.subscribe(
|
|
276
|
+
hash,
|
|
277
|
+
(rows: Record<string, any>[]) => {
|
|
278
|
+
if (disposed || myRun !== runId) return;
|
|
279
|
+
const queryData = (query.isOne ? (rows[0] ?? null) : rows) as TData;
|
|
280
|
+
// Every emission is applied as-is: the core only emits what
|
|
281
|
+
// it has decided the query holds (cached rows on mount, the
|
|
282
|
+
// server's set once membership is known), and it never emits
|
|
283
|
+
// a wrongful `[]` for a lost or still-publishing view. An
|
|
284
|
+
// emission WITH data ends the cold-load state on its own.
|
|
285
|
+
const hasRows = query.isOne
|
|
286
|
+
? queryData !== null && queryData !== undefined
|
|
287
|
+
: rows.length > 0;
|
|
288
|
+
if (hasRows) {
|
|
289
|
+
setHasPainted(true);
|
|
290
|
+
wakeReady();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const t0 = performance.now();
|
|
294
|
+
setStore((s) => {
|
|
295
|
+
if (query.isOne || queryData === null || !Array.isArray(s.value)) {
|
|
296
|
+
s.value = queryData;
|
|
297
|
+
} else {
|
|
298
|
+
mergeRows(s.value as any[], queryData as any[]);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - t0);
|
|
302
|
+
},
|
|
303
|
+
{ immediate: true }
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
})
|
|
307
|
+
.catch((err: unknown) => {
|
|
308
|
+
if (disposed || myRun !== runId) return;
|
|
309
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
310
|
+
wakeReady();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return () => {
|
|
314
|
+
disposed = true;
|
|
315
|
+
for (const c of cleanups) c();
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Fallback empty value served before the first emission of a list query.
|
|
321
|
+
const emptyList = [] as unknown as TData;
|
|
322
|
+
|
|
323
|
+
const data: Accessor<TData> = () => {
|
|
324
|
+
const v = store.value;
|
|
325
|
+
if (v === null || v === undefined) {
|
|
326
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
327
|
+
if (query && !query.isOne) return emptyList;
|
|
328
|
+
}
|
|
329
|
+
return v as TData;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Suspending read: pends until the first real result (or error). Reading it
|
|
333
|
+
// inside <Loading> integrates with Solid 2's boundary protocol; `data` stays
|
|
334
|
+
// non-throwing.
|
|
335
|
+
//
|
|
336
|
+
// `lazy` matters, and so does the promise actually resolving: an unresolved
|
|
337
|
+
// async node stays PENDING, every queue flush carries pending nodes forward,
|
|
338
|
+
// and a navigation transition waits on them. Lazy means only a query whose
|
|
339
|
+
// `ready()` is read creates the node at all, and the deferred below is
|
|
340
|
+
// settled by the same emission (or authority flip) that ends `isLoading`.
|
|
341
|
+
const readyGate = createMemo(
|
|
342
|
+
async (): Promise<true> => {
|
|
343
|
+
if (isAuthoritative() || hasPainted() || error()) return true;
|
|
344
|
+
await new Promise<void>((resolve) => readyWaiters.push(resolve));
|
|
345
|
+
return true;
|
|
346
|
+
},
|
|
347
|
+
{ lazy: true }
|
|
348
|
+
);
|
|
349
|
+
const ready: Accessor<TData> = () => {
|
|
350
|
+
readyGate();
|
|
351
|
+
return data();
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Tear down the live subscription when the hook's owner is disposed. The
|
|
355
|
+
// projection's own onCleanup (inside the compute) already unsubscribes; this
|
|
356
|
+
// hook-scope cleanup only handles the opt-in query deregistration.
|
|
357
|
+
onCleanup(() => {
|
|
358
|
+
// Opt-in: cancel the query once this hook (its last subscriber) is gone.
|
|
359
|
+
// The compute's cleanup removed this hook's callback, so deregisterQuery's
|
|
360
|
+
// refcount guard sees the true remaining-subscriber count.
|
|
361
|
+
if (options?.deregisterOnCleanup && activeHash) {
|
|
362
|
+
sp00ky.deregisterQuery(activeHash);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const hasData = () => {
|
|
367
|
+
const v = store.value as unknown;
|
|
368
|
+
return Array.isArray(v) ? v.length > 0 : v !== null && v !== undefined;
|
|
369
|
+
};
|
|
370
|
+
const isLoading = () => !isAuthoritative() && !hasPainted() && error() === undefined;
|
|
371
|
+
const isEmpty = () => isAuthoritative() && !hasData();
|
|
372
|
+
const isSettled = () => isAuthoritative() && !isFetching();
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
data,
|
|
376
|
+
ready,
|
|
377
|
+
error,
|
|
378
|
+
isLoading,
|
|
379
|
+
isFetching,
|
|
380
|
+
isAuthoritative,
|
|
381
|
+
hasData,
|
|
382
|
+
isEmpty,
|
|
383
|
+
isSettled,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @deprecated Renamed `createQuery` in the Solid 2 binding. */
|
|
388
|
+
export const useQuery = createQuery;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createSignal, type Accessor } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export interface Submission<Args extends unknown[], R> {
|
|
4
|
+
/** Run the wrapped async fn. Concurrent submits share the pending flag. */
|
|
5
|
+
submit: (...args: Args) => Promise<R | undefined>;
|
|
6
|
+
/** True while at least one submit is in flight. */
|
|
7
|
+
pending: Accessor<boolean>;
|
|
8
|
+
/** Error from the most recent settled submit, cleared on the next submit. */
|
|
9
|
+
error: Accessor<Error | undefined>;
|
|
10
|
+
/** Result of the most recent successful submit. */
|
|
11
|
+
result: Accessor<R | undefined>;
|
|
12
|
+
clearError: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thin submission-state wrapper for mutations — button spinner/disable state
|
|
17
|
+
* around `db.create/update/delete/run` calls.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT built on Solid 2's `action()`/`createOptimisticStore`: the
|
|
20
|
+
* spooky engine is already optimistic local-first (writes commit to the local
|
|
21
|
+
* DB and re-render through live queries before sync; `run()` is an outbox
|
|
22
|
+
* CREATE), so a transaction/revert layer on top buys nothing and `action()`'s
|
|
23
|
+
* await-vs-yield transaction escape is a real footgun. Errors here mean the
|
|
24
|
+
* LOCAL commit failed — sync/push failures surface through `useSyncStatus`
|
|
25
|
+
* and `usePendingMutations` instead.
|
|
26
|
+
*/
|
|
27
|
+
export function createSubmission<Args extends unknown[], R>(
|
|
28
|
+
fn: (...args: Args) => Promise<R>
|
|
29
|
+
): Submission<Args, R> {
|
|
30
|
+
// Written from promise continuations — outside any tracking scope.
|
|
31
|
+
const [inFlight, setInFlight] = createSignal(0, { ownedWrite: true });
|
|
32
|
+
const [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true });
|
|
33
|
+
const [result, setResult] = createSignal<R | undefined>(undefined, { ownedWrite: true });
|
|
34
|
+
|
|
35
|
+
const submit = async (...args: Args): Promise<R | undefined> => {
|
|
36
|
+
setError(undefined);
|
|
37
|
+
setInFlight((n) => n + 1);
|
|
38
|
+
try {
|
|
39
|
+
const r = await fn(...args);
|
|
40
|
+
setResult(() => r);
|
|
41
|
+
return r;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
44
|
+
return undefined;
|
|
45
|
+
} finally {
|
|
46
|
+
setInFlight((n) => n - 1);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
submit,
|
|
52
|
+
pending: () => inFlight() > 0,
|
|
53
|
+
error,
|
|
54
|
+
result,
|
|
55
|
+
clearError: () => setError(undefined),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createMemo, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { conflate } from './conflate';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reactive view over a spooky subscribe-callback API.
|
|
6
|
+
*
|
|
7
|
+
* The memo's async generator pulls from a conflated (latest-wins) iterator;
|
|
8
|
+
* `initial` is committed as the memo's `loadingValue`, so the accessor is
|
|
9
|
+
* readable synchronously from birth and never suspends. Spooky's subscribe
|
|
10
|
+
* APIs fire immediately with the current value, so the real value lands within
|
|
11
|
+
* a tick of the first read.
|
|
12
|
+
*
|
|
13
|
+
* Teardown is manual by contract (see conflate.ts): onCleanup terminates the
|
|
14
|
+
* iterator, which unsubscribes.
|
|
15
|
+
*/
|
|
16
|
+
export function fromSubscription<T>(
|
|
17
|
+
subscribe: (cb: (v: T) => void) => (() => void) | Promise<() => void>,
|
|
18
|
+
initial: T
|
|
19
|
+
): Accessor<T> {
|
|
20
|
+
return createMemo(
|
|
21
|
+
async function* (): AsyncGenerator<T> {
|
|
22
|
+
const it = conflate(subscribe)[Symbol.asyncIterator]();
|
|
23
|
+
onCleanup(() => void it.return?.());
|
|
24
|
+
while (true) {
|
|
25
|
+
const r = await it.next();
|
|
26
|
+
if (r.done) break;
|
|
27
|
+
yield r.value;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ loadingValue: initial }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mergeRows, sameValue } from './merge-rows';
|
|
3
|
+
|
|
4
|
+
// A stand-in for the SDK's RecordId: a class with a value-carrying toString,
|
|
5
|
+
// where two instances of the same record are never `===`.
|
|
6
|
+
class Rid {
|
|
7
|
+
constructor(private tb: string, private id: string) {}
|
|
8
|
+
toString() {
|
|
9
|
+
return `${this.tb}:${this.id}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Counts field writes on a row, the way a store proxy would notify.
|
|
14
|
+
function tracked(row: Record<string, unknown>) {
|
|
15
|
+
const writes: string[] = [];
|
|
16
|
+
const proxy = new Proxy(row, {
|
|
17
|
+
set(target, prop, value) {
|
|
18
|
+
writes.push(String(prop));
|
|
19
|
+
(target as any)[prop] = value;
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
deleteProperty(target, prop) {
|
|
23
|
+
writes.push(`delete:${String(prop)}`);
|
|
24
|
+
delete (target as any)[prop];
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return { proxy, writes };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('sameValue', () => {
|
|
32
|
+
it('treats two RecordId-like wrappers of the same record as equal', () => {
|
|
33
|
+
expect(sameValue(new Rid('user', 'a'), new Rid('user', 'a'))).toBe(true);
|
|
34
|
+
expect(sameValue(new Rid('user', 'a'), new Rid('user', 'b'))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it('compares dates by time', () => {
|
|
37
|
+
expect(sameValue(new Date(5), new Date(5))).toBe(true);
|
|
38
|
+
expect(sameValue(new Date(5), new Date(6))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('keeps identity semantics for plain objects and arrays', () => {
|
|
41
|
+
expect(sameValue({ a: 1 }, { a: 1 })).toBe(false);
|
|
42
|
+
expect(sameValue([1], [1])).toBe(false);
|
|
43
|
+
const o = { a: 1 };
|
|
44
|
+
expect(sameValue(o, o)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('never equates a value with null', () => {
|
|
47
|
+
expect(sameValue(null, new Rid('x', 'y'))).toBe(false);
|
|
48
|
+
expect(sameValue(undefined, null)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('mergeRows', () => {
|
|
53
|
+
it('writes nothing when a re-emitted row only differs by wrapper identity', () => {
|
|
54
|
+
const { proxy, writes } = tracked({
|
|
55
|
+
id: new Rid('conversation', 'c1'),
|
|
56
|
+
user_a: new Rid('user', 'a'),
|
|
57
|
+
last_message_ms: 10,
|
|
58
|
+
created_at: new Date(1000),
|
|
59
|
+
});
|
|
60
|
+
const draft = [proxy];
|
|
61
|
+
mergeRows(draft, [
|
|
62
|
+
{
|
|
63
|
+
id: new Rid('conversation', 'c1'),
|
|
64
|
+
user_a: new Rid('user', 'a'),
|
|
65
|
+
last_message_ms: 10,
|
|
66
|
+
created_at: new Date(1000),
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
expect(writes).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('writes only the field that actually changed', () => {
|
|
73
|
+
const { proxy, writes } = tracked({
|
|
74
|
+
id: new Rid('conversation', 'c1'),
|
|
75
|
+
last_message_ms: 10,
|
|
76
|
+
});
|
|
77
|
+
const draft = [proxy];
|
|
78
|
+
mergeRows(draft, [{ id: new Rid('conversation', 'c1'), last_message_ms: 11 }]);
|
|
79
|
+
expect(writes).toEqual(['last_message_ms']);
|
|
80
|
+
expect((proxy as any).last_message_ms).toBe(11);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('still inserts, drops and reorders rows by key', () => {
|
|
84
|
+
const a = { id: 'a', v: 1 };
|
|
85
|
+
const b = { id: 'b', v: 1 };
|
|
86
|
+
const draft: any[] = [a, b];
|
|
87
|
+
mergeRows(draft, [{ id: 'b', v: 2 }, { id: 'c', v: 1 }]);
|
|
88
|
+
expect(draft.map((r) => r.id)).toEqual(['b', 'c']);
|
|
89
|
+
expect(draft[0]).toBe(b);
|
|
90
|
+
expect(b.v).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
});
|